openpetswithchatandmcp/apps/desktop/tests/plugin-bridge-fuzz.test.ts
2026-06-11 21:25:55 +02:00

198 lines
9.6 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.");