198 lines
9.6 KiB
TypeScript
198 lines
9.6 KiB
TypeScript
import assert from "node:assert/strict";
|
||
|
||
import { PetBubbleArbiter, type ActiveBubble, type ArbiterSlot } from "../src/plugin-bubble-arbiter.js";
|
||
import { sanitizeSvgText, injectPanelCsp } from "../src/plugin-assets.js";
|
||
import {
|
||
isPrivateIp,
|
||
nextCronRunMs,
|
||
normalizeJson,
|
||
parseCronExpression,
|
||
renderLimitedMarkdown,
|
||
validateCommandFormValues,
|
||
validateDynamicText,
|
||
validatePinnedBubbleText,
|
||
type PluginBubbleDescriptor,
|
||
type PluginCommandForm,
|
||
} from "../src/plugin-sdk-bridge.js";
|
||
|
||
/**
|
||
* Property/fuzz tests for the bridge validators — the plugin security
|
||
* boundary (§18.7). Every validator must either throw an Error or return a
|
||
* well-formed value for arbitrary input; none may crash, hang, or let
|
||
* dangerous content through.
|
||
*/
|
||
|
||
// Deterministic PRNG so failures reproduce.
|
||
let seed = 0x6f70656e; // "open"
|
||
function rand(): number {
|
||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||
return seed / 0xffffffff;
|
||
}
|
||
function randInt(max: number): number { return Math.floor(rand() * max); }
|
||
function randChar(): string {
|
||
const pools = ["abcdefghijklmnopqrstuvwxyz", "0123456789", "*/,-:; <>\"'`\\\n\r\0{}[]()$&|", "中文🎉<E69687>"];
|
||
const pool = pools[randInt(pools.length)]!;
|
||
return pool[randInt(pool.length)] ?? "a";
|
||
}
|
||
function randString(maxLength: number): string {
|
||
let out = "";
|
||
for (let index = 0, length = randInt(maxLength); index < length; index += 1) out += randChar();
|
||
return out;
|
||
}
|
||
function randValue(depth = 0): unknown {
|
||
const pick = randInt(depth > 2 ? 5 : 8);
|
||
if (pick === 0) return null;
|
||
if (pick === 1) return rand() * Number.MAX_SAFE_INTEGER * (rand() > 0.5 ? 1 : -1);
|
||
if (pick === 2) return rand() > 0.5;
|
||
if (pick === 3) return randString(64);
|
||
if (pick === 4) return undefined;
|
||
if (pick === 5) return Array.from({ length: randInt(6) }, () => randValue(depth + 1));
|
||
const out: Record<string, unknown> = {};
|
||
for (let index = 0, length = randInt(6); index < length; index += 1) out[randString(8) || "k"] = randValue(depth + 1);
|
||
return out;
|
||
}
|
||
|
||
const rounds = 2_000;
|
||
|
||
// --- cron ---------------------------------------------------------------
|
||
|
||
for (let index = 0; index < rounds; index += 1) {
|
||
const expr = Array.from({ length: randInt(7) }, () => randString(6)).join(" ");
|
||
try {
|
||
parseCronExpression(expr);
|
||
const next = nextCronRunMs(expr, Date.now());
|
||
assert.ok(next === null || (Number.isFinite(next) && next > Date.now() - 60_000), `cron next-run sane for ${JSON.stringify(expr)}`);
|
||
} catch (error) {
|
||
assert.ok(error instanceof Error, "cron parser throws Error instances only");
|
||
}
|
||
}
|
||
|
||
// Valid cron expressions always produce a future run.
|
||
for (const expr of ["* * * * *", "0 9 * * 1-5", "*/15 * * * *", "30 8 1 * *", "0 0 29 2 *", "5,35 */2 * * 0,6"]) {
|
||
const next = nextCronRunMs(expr, Date.parse("2026-06-10T12:00:00Z"));
|
||
assert.ok(next !== null && next > Date.parse("2026-06-10T12:00:00Z"), `cron ${expr} fires in the future`);
|
||
}
|
||
|
||
// --- markdown / dynamic text ---------------------------------------------
|
||
|
||
for (let index = 0; index < rounds; index += 1) {
|
||
const html = renderLimitedMarkdown(randString(300));
|
||
assert.doesNotMatch(html, /<(?!\/?(strong|em|code|br)\b)/, "markdown renderer only emits the allowed tags");
|
||
assert.doesNotMatch(html, /<script|onerror|javascript:/i, "markdown renderer emits no script vectors");
|
||
}
|
||
|
||
for (let index = 0; index < rounds; index += 1) {
|
||
const input = randString(120) + (rand() > 0.7 ? " sk-abcdefghijklmnopqrstuvwx " : "") + (rand() > 0.7 ? " AKIAABCDEFGHIJKLMNOP " : "");
|
||
try {
|
||
const cleaned = validateDynamicText(input);
|
||
assert.ok(cleaned.length >= 1 && cleaned.length <= 2_000);
|
||
assert.doesNotMatch(cleaned, /\bsk-[A-Za-z0-9_-]{16,}\b/, "dynamic screen strips API-key-shaped secrets");
|
||
assert.doesNotMatch(cleaned, /\bAKIA[0-9A-Z]{16}\b/, "dynamic screen strips AWS-key-shaped secrets");
|
||
} catch (error) {
|
||
assert.ok(error instanceof Error);
|
||
}
|
||
}
|
||
|
||
// --- SVG / panel sanitizers -----------------------------------------------
|
||
|
||
for (let index = 0; index < rounds / 4; index += 1) {
|
||
const svg = `<svg ${randString(20)} onload="alert(1)"><script>${randString(20)}</script><foreignObject>${randString(10)}</foreignObject><a href="https://evil.example/${randString(8)}">x</a><use xlink:href="http://evil/x#y"/>${randString(40)}</svg>`;
|
||
const cleaned = sanitizeSvgText(svg);
|
||
assert.doesNotMatch(cleaned, /<script\b/i, "svg sanitizer strips script elements");
|
||
assert.doesNotMatch(cleaned, /<foreignObject\b/i, "svg sanitizer strips foreignObject");
|
||
assert.doesNotMatch(cleaned, /\son[a-z]+\s*=/i, "svg sanitizer strips event handlers");
|
||
assert.doesNotMatch(cleaned, /href\s*=\s*["']https?:/i, "svg sanitizer strips external hrefs");
|
||
}
|
||
|
||
assert.match(injectPanelCsp("<html><head></head><body></body></html>"), /Content-Security-Policy/);
|
||
assert.match(injectPanelCsp("no head at all"), /^<meta http-equiv="Content-Security-Policy"/);
|
||
assert.equal((injectPanelCsp('<head><meta http-equiv="Content-Security-Policy" content="default-src *"></head>').match(/Content-Security-Policy/g) ?? []).length, 1, "existing CSP metas are replaced, not stacked");
|
||
|
||
// --- pinned bubble text -------------------------------------------------------
|
||
|
||
assert.equal(validatePinnedBubbleText("🍖 ███░ ⚡ ███░\n🎾 ███░ 💛 ██░░"), "🍖 ███░ ⚡ ███░\n🎾 ███░ 💛 ██░░", "pinned HUD text may use a few safe lines");
|
||
assert.throws(() => validatePinnedBubbleText("ok\n\nblank"), /blank lines/);
|
||
assert.throws(() => validatePinnedBubbleText("line 1\nline 2\nline 3\nline 4\nline 5"), /too many lines/);
|
||
assert.throws(() => validatePinnedBubbleText("ok\nhttps://example.com"), /URL|path-like/);
|
||
|
||
// --- normalizeJson ----------------------------------------------------------
|
||
|
||
for (let index = 0; index < rounds; index += 1) {
|
||
try {
|
||
const normalized = normalizeJson(randValue(), 32 * 1024, "fuzz payload");
|
||
JSON.stringify(normalized); // must round-trip
|
||
} catch (error) {
|
||
assert.ok(error instanceof Error);
|
||
}
|
||
}
|
||
assert.throws(() => normalizeJson({ big: "x".repeat(64 * 1024) }, 32 * 1024, "fuzz payload"), /too large/);
|
||
|
||
// --- private-IP guard --------------------------------------------------------
|
||
|
||
for (const address of ["127.0.0.1", "10.1.2.3", "192.168.0.10", "169.254.1.1", "172.16.0.1", "172.31.255.255", "100.64.0.1", "0.0.0.0", "::1", "fd00::1", "fe80::abcd", "::ffff:127.0.0.1", "::ffff:10.0.0.1"]) {
|
||
assert.equal(isPrivateIp(address), true, `${address} is private`);
|
||
}
|
||
for (const address of ["8.8.8.8", "1.1.1.1", "172.32.0.1", "100.128.0.1", "2606:4700::1111"]) {
|
||
assert.equal(isPrivateIp(address), false, `${address} is public`);
|
||
}
|
||
for (let index = 0; index < rounds; index += 1) {
|
||
assert.equal(typeof isPrivateIp(randString(40)), "boolean", "isPrivateIp never throws");
|
||
}
|
||
|
||
// --- command form value validation -------------------------------------------
|
||
|
||
const fuzzForm: PluginCommandForm = {
|
||
fields: [
|
||
{ id: "title", type: "text", label: "Title", maxLength: 40, required: true },
|
||
{ id: "minutes", type: "number", label: "Minutes", min: 1, max: 120 },
|
||
{ id: "enabled", type: "boolean", label: "Enabled", default: true },
|
||
{ id: "mode", type: "select", label: "Mode", options: [{ label: "A", value: "a" }, { label: "B", value: "b" }], default: "a" },
|
||
{ id: "tags", type: "multiSelect", label: "Tags", options: [{ label: "X", value: "x" }, { label: "Y", value: "y" }] },
|
||
{ id: "when", type: "time", label: "When", default: "09:00" },
|
||
{ id: "day", type: "date", label: "Day" },
|
||
{ id: "items", type: "list", label: "Items", maxLength: 50 },
|
||
],
|
||
};
|
||
for (let index = 0; index < rounds; index += 1) {
|
||
try {
|
||
const values = validateCommandFormValues(fuzzForm, randValue());
|
||
assert.equal(typeof values, "object");
|
||
if (typeof values.minutes === "number") assert.ok(values.minutes >= 1 && values.minutes <= 120, "numbers respect min/max");
|
||
if (typeof values.mode === "string" && values.mode !== "") assert.ok(["a", "b"].includes(values.mode as string), "selects respect options");
|
||
} catch (error) {
|
||
assert.ok(error instanceof Error);
|
||
}
|
||
}
|
||
|
||
// --- bubble arbiter invariants -------------------------------------------------
|
||
|
||
{
|
||
const slots: Record<ArbiterSlot, ActiveBubble | null> = { transient: null, pinned: null };
|
||
const arbiter = new PetBubbleArbiter({ present: (slot, content) => { slots[slot] = content; } });
|
||
const live = new Set<string>();
|
||
for (let index = 0; index < 500; index += 1) {
|
||
const bubble: PluginBubbleDescriptor = {
|
||
priority: (["low", "normal", "high", "urgent"] as const)[randInt(4)]!,
|
||
text: randString(10) || "hello",
|
||
sticky: rand() > 0.7,
|
||
pin: rand() > 0.8,
|
||
durationMs: rand() > 0.5 ? 500 + randInt(5_000) : undefined,
|
||
};
|
||
const idBox: { id: string | null } = { id: null };
|
||
const handle = arbiter.show(`plugin-${randInt(4)}`, bubble, {
|
||
onAction: () => undefined,
|
||
onSubmit: () => undefined,
|
||
onDismiss: () => { if (idBox.id) live.delete(idBox.id); idBox.id = "dismissed"; },
|
||
});
|
||
if (idBox.id === null) { idBox.id = handle.id; live.add(handle.id); }
|
||
if (rand() > 0.6) void handle.dismiss();
|
||
if (rand() > 0.8) arbiter.handleDismissed(slots.transient?.token ?? "");
|
||
const snapshot = arbiter.snapshot();
|
||
assert.ok(snapshot.queued <= 16, "arbiter queue stays bounded");
|
||
if (slots.pinned) assert.ok(slots.pinned.bubble.pin === true || slots.pinned.bubble.sticky === true || slots.pinned.bubble.durationMs !== undefined, "pinned slot only holds pinned content");
|
||
}
|
||
for (const token of [...live]) arbiter.handleDismissed(token);
|
||
assert.equal(arbiter.snapshot().current === null && arbiter.snapshot().queued === 0, true, "arbiter drains cleanly");
|
||
}
|
||
|
||
console.error("Plugin bridge fuzz validation passed.");
|