fix: harden theme policy checks

This commit is contained in:
Peter Li 2026-07-16 14:44:41 +08:00
parent 094aabffc3
commit 6242ad8084
3 changed files with 335 additions and 44 deletions

View file

@ -7,29 +7,60 @@ import {
validateVersions,
} from "./theme-policy.mjs";
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
async function readJson(path) {
let contents;
try {
contents = await readFile(path, "utf8");
} catch (error) {
throw new Error(`could not read ${path}: ${error.message}`);
}
const manifest = await readJson("manifest.json");
const packageJson = await readJson("package.json");
const versions = await readJson("versions.json");
const css = await readFile("theme.css", "utf8");
const tagIndex = process.argv.indexOf("--release-tag");
const tag = tagIndex === -1 ? "" : process.argv[tagIndex + 1];
const errors = [
...validateManifest(manifest),
...validateVersions(manifest, versions),
...validateCss(css),
...validateReleaseTag(manifest, tag),
];
if (packageJson.version !== manifest.version) {
errors.push("package.json version must equal manifest version");
try {
return JSON.parse(contents);
} catch (error) {
throw new Error(`could not parse ${path}: ${error.message}`);
}
}
if (errors.length) {
errors.forEach((error) => console.error(`ERROR: ${error}`));
process.exit(1);
function readReleaseTag(args) {
const tagIndex = args.indexOf("--release-tag");
if (tagIndex === -1) return "";
const tag = args[tagIndex + 1];
if (!tag || tag.startsWith("--")) {
throw new Error("--release-tag requires a value");
}
return tag;
}
console.log("Theme policy checks passed");
async function main() {
const tag = readReleaseTag(process.argv.slice(2));
const manifest = await readJson("manifest.json");
const packageJson = await readJson("package.json");
const versions = await readJson("versions.json");
const css = await readFile("theme.css", "utf8");
const errors = [
...validateManifest(manifest),
...validateVersions(manifest, versions),
...validateCss(css),
...validateReleaseTag(manifest, tag),
];
if (packageJson.version !== manifest?.version) {
errors.push("package.json version must equal manifest version");
}
if (errors.length) {
errors.forEach((error) => console.error(`ERROR: ${error}`));
process.exitCode = 1;
return;
}
console.log("Theme policy checks passed");
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`ERROR: ${message.replace(/\s+/g, " ").trim()}`);
process.exitCode = 1;
});

View file

@ -1,3 +1,5 @@
import postcss from "postcss";
const allowedKeys = new Set([
"author",
"authorUrl",
@ -6,13 +8,24 @@ const allowedKeys = new Set([
"name",
"version",
]);
const semver = /^\d+\.\d+\.\d+$/;
const requiredKeys = ["author", "minAppVersion", "name", "version"];
const semver = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
function isPlainObject(value) {
if (value === null || typeof value !== "object") return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
export function validateManifest(manifest) {
if (!isPlainObject(manifest)) {
return ["manifest.json must be a plain object"];
}
const errors = [];
for (const key of ["author", "minAppVersion", "name", "version"]) {
if (typeof manifest[key] !== "string" || !manifest[key]) {
for (const key of requiredKeys) {
if (typeof manifest[key] !== "string" || !manifest[key].trim()) {
errors.push(`manifest.json requires ${key}`);
}
}
@ -23,6 +36,22 @@ export function validateManifest(manifest) {
}
}
if (Object.hasOwn(manifest, "authorUrl") && typeof manifest.authorUrl !== "string") {
errors.push("manifest.json authorUrl must be a string");
}
if (Object.hasOwn(manifest, "fundingUrl")) {
const fundingUrlIsValid =
typeof manifest.fundingUrl === "string" ||
(isPlainObject(manifest.fundingUrl) &&
Object.values(manifest.fundingUrl).every((value) => typeof value === "string"));
if (!fundingUrlIsValid) {
errors.push(
"manifest.json fundingUrl must be a string or plain object with string values",
);
}
}
if (manifest.name !== "Aera") {
errors.push("theme name must remain Aera");
}
@ -37,32 +66,88 @@ export function validateManifest(manifest) {
}
export function validateVersions(manifest, versions) {
return versions[manifest.version] === manifest.minAppVersion
? []
: ["versions.json must map manifest version to minAppVersion"];
if (!isPlainObject(versions)) {
return ["versions.json must be a plain object"];
}
const errors = [];
for (const [version, minAppVersion] of Object.entries(versions)) {
if (!semver.test(version)) {
errors.push(`versions.json version key ${version} must use x.y.z`);
}
if (typeof minAppVersion !== "string" || !semver.test(minAppVersion)) {
errors.push(`versions.json minAppVersion for ${version} must use x.y.z`);
}
}
const manifestVersion = manifest?.version;
const manifestMinAppVersion = manifest?.minAppVersion;
if (
typeof manifestVersion !== "string" ||
typeof manifestMinAppVersion !== "string" ||
versions[manifestVersion] !== manifestMinAppVersion
) {
errors.push("versions.json must map manifest version to minAppVersion");
}
return errors;
}
export function validateCss(css) {
const checks = [
[/!important\b/i, "theme.css must not contain !important"],
[/:has\s*\(/i, "theme.css must not contain :has()"],
[/url\(\s*["']?https?:/i, "theme.css must not load a remote URL"],
[/--font-text-size\s*:/i, "theme.css must not assign --font-text-size"],
[/--file-line-width\s*:/i, "theme.css must not assign --file-line-width"],
[
/--font-interface-theme\s*:/i,
"theme.css must not assign --font-interface-theme",
],
[/sourceMappingURL/i, "theme.css must not contain source map references"],
];
let root;
try {
root = postcss.parse(css, { from: "theme.css" });
} catch (error) {
return [`theme.css must contain valid CSS: ${error.reason ?? error.message}`];
}
return checks
.filter(([pattern]) => pattern.test(css))
.map(([, message]) => message);
const errors = new Set();
const remoteUrl = /url\(\s*["']?\s*(?:https?:)?\/\//i;
root.walkRules((rule) => {
if (/:has\s*\(/i.test(rule.selector)) {
errors.add("theme.css must not contain :has()");
}
});
root.walkDecls((declaration) => {
if (declaration.important) {
errors.add("theme.css must not contain !important");
}
const property = declaration.prop.toLowerCase();
if (property === "--font-text-size") {
errors.add("theme.css must not assign --font-text-size");
}
if (property === "--file-line-width") {
errors.add("theme.css must not assign --file-line-width");
}
if (property === "--font-interface-theme") {
errors.add("theme.css must not assign --font-interface-theme");
}
if (remoteUrl.test(declaration.value)) {
errors.add("theme.css must not load a remote URL");
}
});
root.walkAtRules("import", (atRule) => {
const remoteImport = /^\s*["']?\s*(?:https?:)?\/\//i;
if (remoteImport.test(atRule.params) || remoteUrl.test(atRule.params)) {
errors.add("theme.css must not load a remote URL");
}
});
root.walkComments((comment) => {
if (/sourceMappingURL/i.test(comment.text)) {
errors.add("theme.css must not contain source map references");
}
});
return [...errors];
}
export function validateReleaseTag(manifest, tag) {
return !tag || tag === manifest.version
return !tag || tag === manifest?.version
? []
: [`release tag ${tag} must equal ${manifest.version}`];
: [`release tag ${tag} must equal ${manifest?.version}`];
}

View file

@ -1,5 +1,10 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
validateCss,
@ -14,6 +19,32 @@ const validManifest = {
minAppVersion: "1.12.7",
author: "Peter",
};
const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
const cliPath = join(repoRoot, "scripts/check-theme.mjs");
function runCli(args = [], cwd = repoRoot) {
return spawnSync(process.execPath, [cliPath, ...args], {
cwd,
encoding: "utf8",
});
}
function createThemeFixture(t, overrides = {}) {
const directory = mkdtempSync(join(tmpdir(), "aera-theme-policy-"));
const files = {
"manifest.json": `${JSON.stringify(validManifest)}\n`,
"package.json": `${JSON.stringify({ version: validManifest.version })}\n`,
"versions.json": `${JSON.stringify({ "0.1.0": "1.12.7" })}\n`,
"theme.css": ":root { --aera-accent: #3d6b5f; }\n",
...overrides,
};
for (const [name, contents] of Object.entries(files)) {
if (contents !== null) writeFileSync(join(directory, name), contents);
}
t.after(() => rmSync(directory, { recursive: true, force: true }));
return directory;
}
test("validateManifest accepts the Aera theme manifest", () => {
assert.deepEqual(validateManifest(validManifest), []);
@ -28,6 +59,56 @@ test("validateManifest rejects plugin-only description metadata", () => {
assert.ok(errors.some((error) => error.includes("description")));
});
test("validateManifest rejects values that are not plain objects", () => {
for (const manifest of [null, [], new Date()]) {
assert.match(validateManifest(manifest).join("\n"), /plain object/);
}
});
test("validateManifest requires non-empty trimmed strings", () => {
for (const key of ["author", "minAppVersion", "name", "version"]) {
const errors = validateManifest({ ...validManifest, [key]: " \t " });
assert.ok(errors.some((error) => error.includes(`requires ${key}`)));
}
});
test("validateManifest validates optional URL metadata shapes", () => {
assert.deepEqual(
validateManifest({
...validManifest,
authorUrl: "https://example.com",
fundingUrl: { GitHub: "https://github.com/sponsors/example" },
}),
[],
);
assert.match(
validateManifest({ ...validManifest, authorUrl: 42 }).join("\n"),
/authorUrl.*string/,
);
assert.match(
validateManifest({ ...validManifest, fundingUrl: { GitHub: 42 } }).join("\n"),
/fundingUrl.*string/,
);
assert.match(
validateManifest({ ...validManifest, fundingUrl: ["https://example.com"] }).join(
"\n",
),
/fundingUrl.*string|plain object/,
);
});
test("validateManifest rejects semantic versions with leading zeroes", () => {
assert.match(
validateManifest({ ...validManifest, version: "01.2.3" }).join("\n"),
/manifest version.*x\.y\.z/,
);
assert.match(
validateManifest({ ...validManifest, minAppVersion: "1.02.3" }).join("\n"),
/minAppVersion.*x\.y\.z/,
);
});
test("validateVersions requires the current version to map to minAppVersion", () => {
assert.deepEqual(validateVersions(validManifest, { "0.1.0": "1.12.7" }), []);
@ -35,6 +116,27 @@ test("validateVersions requires the current version to map to minAppVersion", ()
assert.ok(errors.some((error) => error.includes("versions.json")));
});
test("validateVersions requires a plain object with strict semantic versions", () => {
for (const versions of [null, []]) {
assert.match(validateVersions(validManifest, versions).join("\n"), /plain object/);
}
assert.match(
validateVersions(validManifest, {
"0.1.0": "1.12.7",
"01.0.0": "1.12.7",
}).join("\n"),
/version key.*x\.y\.z/,
);
assert.match(
validateVersions(validManifest, {
"0.1.0": "1.12.7",
"0.2.0": "1.02.3",
}).join("\n"),
/minAppVersion.*x\.y\.z/,
);
});
const forbiddenCss = [
["!important", ".notice { color: red !important; }", "!important"],
[":has(", ".item:has(.active) { color: red; }", ":has"],
@ -66,6 +168,27 @@ test("validateCss accepts CSS that follows theme policy", () => {
assert.deepEqual(validateCss(":root { --aera-accent: #3d6b5f; }"), []);
});
test("validateCss rejects remote imports and protocol-relative URLs", () => {
for (const css of [
'@import "https://example.com/theme.css";',
"@import url(//example.com/theme.css);",
".hero { background: url(//example.com/image.png); }",
]) {
assert.match(validateCss(css).join("\n"), /remote URL/);
}
});
test("validateCss ignores policy-like text inside ordinary comments", () => {
const css = `
/* !important :has(.item) url(https://example.com/image.png)
--font-text-size: 18px; --file-line-width: 48rem;
--font-interface-theme: Inter; */
:root { --aera-accent: #3d6b5f; }
`;
assert.deepEqual(validateCss(css), []);
});
test("validateReleaseTag accepts an empty or matching release tag", () => {
assert.deepEqual(validateReleaseTag(validManifest, ""), []);
assert.deepEqual(validateReleaseTag(validManifest, "0.1.0"), []);
@ -75,3 +198,55 @@ test("validateReleaseTag rejects a mismatched release tag", () => {
const errors = validateReleaseTag(validManifest, "0.2.0");
assert.ok(errors.some((error) => error.includes("0.2.0")));
});
test("check-theme CLI accepts an omitted or matching release tag", () => {
for (const args of [[], ["--release-tag", "0.1.0"]]) {
const result = runCli(args);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Theme policy checks passed/);
}
});
test("check-theme CLI rejects a mismatched release tag", () => {
const result = runCli(["--release-tag", "1.0.0"]);
assert.equal(result.status, 1);
assert.match(result.stderr, /^ERROR: release tag 1\.0\.0 must equal 0\.1\.0\n$/);
});
test("check-theme CLI rejects a release-tag option without a value", () => {
for (const args of [["--release-tag"], ["--release-tag", "--other-option"]]) {
const result = runCli(args);
assert.equal(result.status, 1);
assert.match(result.stderr, /^ERROR: --release-tag requires a value\n$/);
}
});
test("check-theme CLI reports missing JSON files without a stack trace", (t) => {
const directory = createThemeFixture(t, { "manifest.json": null });
const result = runCli([], directory);
assert.equal(result.status, 1);
assert.match(result.stderr, /^ERROR: .+manifest\.json.+\n$/);
assert.equal(result.stderr.trim().split("\n").length, 1);
assert.doesNotMatch(result.stderr, /\n\s+at /);
});
test("check-theme CLI reports malformed JSON without a stack trace", (t) => {
const directory = createThemeFixture(t, { "versions.json": "{broken json\n" });
const result = runCli([], directory);
assert.equal(result.status, 1);
assert.match(result.stderr, /^ERROR: .+versions\.json.+\n$/);
assert.equal(result.stderr.trim().split("\n").length, 1);
assert.doesNotMatch(result.stderr, /\n\s+at /);
});
test("check-theme CLI reports manifest schema errors for JSON null", (t) => {
const directory = createThemeFixture(t, { "manifest.json": "null\n" });
const result = runCli([], directory);
assert.equal(result.status, 1);
assert.match(result.stderr, /ERROR: manifest\.json must be a plain object/);
assert.doesNotMatch(result.stderr, /Cannot read properties/);
});