diff --git a/.changeset/codeql-backlog-security.md b/.changeset/codeql-backlog-security.md new file mode 100644 index 00000000..9d3facba --- /dev/null +++ b/.changeset/codeql-backlog-security.md @@ -0,0 +1,14 @@ +--- +"@cloudhal/core-ts": patch +"@cloudhal/astro-renderer": patch +"@cloudhal/api": patch +"@cloudhal/mcp-server": patch +"@cloudhal/cloudflare": patch +"@cloudhal/cli": patch +--- + +Security fixes from the CodeQL backlog on `main`. + +- **Prototype pollution.** A2UI `updateDataModel` paths (`@cloudhal/core-ts/interop`) that name `__proto__`, `constructor` or `prototype` at any depth are now refused with an `InteropError` instead of being skipped, step into arrays by index only, and write own properties only; an input whose component id is one of those keys gets the data key `-value`. Surface pointers the page writes through (an input's `value`/`checked`, a Scrolly's `step`, a Button action's `path`) may not name those keys (a `checkSurface` issue); read bindings still may. The Build eval's sample inputs never write through them, and `set_chrome` and `update_entry`'s `fields` merge patch refuse them as keys. `RESERVED_KEYS` and `isReservedKey` are new in `@cloudhal/core-ts/names`. +- **Plain text from HTML.** `htmlText` (meta and feed descriptions, JSON-LD, table-of-contents labels) is plain text: `<script>` in markdown is the text `` or ` ">'; +const ORIGIN = "https://haber.example"; + +/** Every element and comment under `node` (its own tag included when it is one). */ +function markup(node: Node, out: string[] = []): string[] { + if (node.nodeName === "#comment") out.push("#comment"); + else if ("tagName" in node) out.push(node.tagName); + for (const child of "childNodes" in node ? node.childNodes : []) markup(child as Node, out); + if ("content" in node && node.content) markup(node.content as Node, out); + return out; +} + +/** The first `tag` element under `node`, depth first. */ +function firstElement(node: Node, tag: string): DefaultTreeAdapterMap["element"] | undefined { + if ("tagName" in node && node.tagName === tag) return node; + for (const child of "childNodes" in node ? node.childNodes : []) { + const found = firstElement(child as Node, tag); + if (found) return found; + } + return undefined; +} + +/** The elements and comments an HTML fragment parses to. */ +const fragmentMarkup = (html: string) => markup(parseFragment(html) as unknown as Node); + +/** The elements and comments inside `` when `html` is all the head holds. */ +function headMarkup(html: string): string[] { + const doc = parse(`${html}`); + const head = doc.childNodes + .flatMap((n) => ("childNodes" in n ? n.childNodes : [])) + .find((n) => n.nodeName === "head") as Node; + return markup(head).slice(1); +} + +const site = { + id: "site_aaaaaaaaaaaa", + name: "Haber", + slug: "haber", + defaultLocale: "en", + locales: ["en"], + domains: [], +} as unknown as Site; + +const archive = { + locale: "en", + page: 1, + path: "/category/news", + root: "/category/news", + shown: { + name: "News", + slug: "news", + href: "/category/news", + taxonomy: { key: "category", labels: { en: { plural: "Categories" } } }, + }, + trail: [], + total: 0, + perPage: 10, + term: { labels: { en: { name: "News", description: `Intro ${ATTACK}` } } }, + taxonomy: { key: "category" }, + entries: [], +} as never; + +describe("htmlText's sinks with decoded markup in the text (alert 41)", () => { + const head = archiveHead(site, archive, ORIGIN); + + it("the description is the decoded text, verbatim", () => { + expect(markdownText(`Intro ${ATTACK}`)).toBe(`Intro ${DECODED}`); + expect(head.description).toBe(`Intro ${DECODED}`); + }); + + it("meta description, og: and twitter: tags: an attribute value, never markup", () => { + // What SeoHead.astro writes for `meta.description`, `meta.openGraph` and `meta.twitter`. + const tags = [ + ``, + ...head.openGraph.map( + ([p, c]) => ``, + ), + ...head.twitter.map( + ([n, c]) => ``, + ), + ]; + expect(head.openGraph).toContainEqual(["og:description", `Intro ${DECODED}`]); + for (const tag of tags) expect(headMarkup(tag), tag).toEqual(["meta"]); + const meta = firstElement(parse(`${tags[0]}`) as unknown as Node, "meta"); + expect(meta?.attrs.find((a) => a.name === "content")?.value).toBe(`Intro ${DECODED}`); + }); + + it("JSON-LD: no , \s*)*(?:]*>\s*)?(?:\s*)*]/i.test( - t, - ); +const isSpace = (c: string | undefined): boolean => c !== undefined && /\s/.test(c); +const skipSpace = (t: string, i: number): number => { + while (isSpace(t[i])) i++; + return i; +}; +const startsWithCi = (t: string, word: string, at: number): boolean => + t.slice(at, at + word.length).toLowerCase() === word; +/** Past any `` comments (each ends at its first `-->`) and the whitespace after them; -1 when one never ends. */ +function skipComments(t: string, at: number): number { + let i = at; + while (t.startsWith("", i + 4); + if (end === -1) return -1; + i = skipSpace(t, end + 3); + } + return i; +} + +/** + * An optional BOM, XML declaration, comments and whitespace, an optional svg DOCTYPE, then the svg + * root element. A linear scan in place of + * `/^\s*(?:<\?xml[^>]*\?>\s*)?(?:\s*)*(?:]*>\s*)?(?:\s*)*]/i`, + * which backtracked exponentially on uploads starting `` as in + * HTML and XML (the regex could stretch one over a later `-->`), which only ever answers "not svg". + */ +export function isSvgText(text: string): boolean { + const t = text.startsWith("\uFEFF") ? text.slice(1) : text; + let i = skipSpace(t, 0); + if (startsWithCi(t, "]*\?>`: the declaration ends at its first `>`, which must follow a `?`. + const gt = t.indexOf(">", i + 5); + if (gt === -1 || gt - 1 < i + 5 || t[gt - 1] !== "?") return false; + i = skipSpace(t, gt + 1); + } + i = skipComments(t, i); + if (i === -1) return false; + if (startsWithCi(t, "", name + 3); + if (gt === -1) return false; + i = skipComments(t, skipSpace(t, gt + 1)); + if (i === -1) return false; + } + return startsWithCi(t, "" || isSpace(t[i + 4])); } /** diff --git a/packages/core-ts/src/names.ts b/packages/core-ts/src/names.ts index 0c0d6e76..8f8b1d84 100644 --- a/packages/core-ts/src/names.ts +++ b/packages/core-ts/src/names.ts @@ -47,6 +47,21 @@ export function trimEndChar(text: string, ch: string): string { return end === text.length ? text : text.slice(0, end); } +/** + * Keys a path, pointer or merge from outside input must never write: through them an assignment + * reaches an object's prototype (`obj.__proto__.x = 1`, `obj.constructor.prototype.x = 1`) and, + * from there, every object (CodeQL js/prototype-polluting-assignment). Writers refuse them with an + * error rather than skipping them, so the caller learns its input was not applied. + */ +export const RESERVED_KEYS: ReadonlySet = new Set([ + "__proto__", + "constructor", + "prototype", +]); + +/** Whether `key` is one of {@link RESERVED_KEYS}. */ +export const isReservedKey = (key: string): boolean => RESERVED_KEYS.has(key); + /** * Folds a name for comparison: NFKC, Turkish-aware lower-casing (İ, I and ı all become i), * diacritics removed, apostrophes and dots dropped (`A.I.` → `ai`), every other run of diff --git a/packages/core-ts/src/surface-interop-data.ts b/packages/core-ts/src/surface-interop-data.ts new file mode 100644 index 00000000..d43d75cb --- /dev/null +++ b/packages/core-ts/src/surface-interop-data.ts @@ -0,0 +1,101 @@ +/** + * The data model of surface interop (RFC-0020 §9): JSON Pointer reads and writes over the data an + * A2UI agent sends (`updateDataModel`) or a component binds. Pointers come from agent and MCP + * input, so writes refuse the reserved keys `__proto__`, `constructor` and `prototype` at every + * depth with an `InteropError`, step into arrays by index only, and read and write own properties + * only (CodeQL js/prototype-polluting-assignment, alerts 42–44). Worker-safe: pure functions. + */ +import { isReservedKey } from "./names.ts"; +import { isObj } from "./surfaces/shared.ts"; + +type Json = Record; + +/** A document that cannot be converted at all (not a wrong prop: those are notes or store issues). */ +export class InteropError extends Error { + override readonly name = "InteropError"; +} + +const ARRAY_INDEX = /^\d+$/; + +/** + * The data key an input with a literal value gets: its component id, with a suffix when the id + * is a reserved key (an A2UI id such as "prototype"), so a valid document never fails on a path + * it never wrote. + */ +export function dataKeyFor(id: string): string { + const key = id.replace(/[^a-z0-9-]/g, "-"); + return isReservedKey(key) ? `${key}-value` : key; +} + +/** RFC 6901 segments of `pointer` (`/a/b~1c` → `["a", "b/c"]`). */ +const segmentsOf = (pointer: string): string[] => + pointer + .replace(/^\//, "") + .split("/") + .map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~")); + +/** The value at `pointer` in `data` (own properties only), or `undefined`. */ +export function pointerGet(data: unknown, pointer: string): unknown { + if (pointer === "" || pointer === "/") return data; + let cur = data; + for (const seg of segmentsOf(pointer)) { + if (Array.isArray(cur)) cur = ARRAY_INDEX.test(seg) ? cur[Number(seg)] : undefined; + else if (isObj(cur) && Object.hasOwn(cur, seg)) cur = cur[seg]; + else return undefined; + } + return cur; +} + +/** Defines `key` as an own data property: never a setter, never the prototype chain. */ +function defineOwn(target: Json, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +} + +/** Refuses a segment that would reach a prototype, or a non-index step into an array. */ +function checkSegment(container: Json | unknown[], seg: string, pointer: string): void { + // Compared inline at each step, so every write below sits behind the check. + if (seg === "__proto__" || seg === "constructor" || seg === "prototype") + throw new InteropError(`data path ${pointer} uses the reserved key "${seg}"`); + if (Array.isArray(container) && !ARRAY_INDEX.test(seg)) + throw new InteropError(`data path ${pointer}: "${seg}" is not an array index`); +} + +/** The child container at `seg`, created (an array when `nextSeg` is an index) when missing. */ +function child(cur: Json | unknown[], seg: string, nextSeg: string): Json | unknown[] { + const next: unknown = Array.isArray(cur) + ? cur[Number(seg)] + : Object.hasOwn(cur, seg) + ? cur[seg] + : undefined; + if (isObj(next) || Array.isArray(next)) return next; + const made: Json | unknown[] = ARRAY_INDEX.test(nextSeg) ? [] : {}; + if (Array.isArray(cur)) cur[Number(seg)] = made; + else defineOwn(cur, seg, made); + return made; +} + +/** `data` with `value` written at `pointer` (deleted when `value` is undefined), on a copy. */ +export function pointerSet(data: Json, pointer: string, value: unknown): Json { + if (pointer === "" || pointer === "/") return isObj(value) ? structuredClone(value) : {}; + const segs = segmentsOf(pointer); + const out = structuredClone(data); + let cur: Json | unknown[] = out; + for (let i = 0; i < segs.length - 1; i++) { + const seg = segs[i] as string; + checkSegment(cur, seg, pointer); + cur = child(cur, seg, segs[i + 1] as string); + } + const last = segs.at(-1) as string; + checkSegment(cur, last, pointer); + if (Array.isArray(cur)) { + if (value === undefined) cur.splice(Number(last), 1); + else cur[Number(last)] = value; + } else if (value !== undefined) defineOwn(cur, last, value); + else if (Object.hasOwn(cur, last)) delete cur[last]; + return out; +} diff --git a/packages/core-ts/src/surface-interop.ts b/packages/core-ts/src/surface-interop.ts index a67ad64f..f751bc91 100644 --- a/packages/core-ts/src/surface-interop.ts +++ b/packages/core-ts/src/surface-interop.ts @@ -14,6 +14,9 @@ import type { SurfaceComponent, SurfaceSection } from "@cloudhal/core-contracts"; import { nearestIcon } from "./icon-names.ts"; import { trimChar } from "./names.ts"; +import { dataKeyFor, InteropError, pointerGet, pointerSet } from "./surface-interop-data.ts"; + +export { InteropError }; export const A2UI_VERSION = "v0.9"; export const A2UI_BASIC_CATALOG = "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"; @@ -70,45 +73,6 @@ class Ids { } } -function pointerGet(data: unknown, pointer: string): unknown { - if (pointer === "" || pointer === "/") return data; - let cur = data; - for (const raw of pointer.replace(/^\//, "").split("/")) { - const seg = raw.replace(/~1/g, "/").replace(/~0/g, "~"); - if (Array.isArray(cur)) cur = /^\d+$/.test(seg) ? cur[Number(seg)] : undefined; - else if (isObj(cur) && Object.hasOwn(cur, seg)) cur = cur[seg]; - else return undefined; - } - return cur; -} - -function pointerSet(data: Json, pointer: string, value: unknown): Json { - if (pointer === "" || pointer === "/") return isObj(value) ? structuredClone(value) : {}; - const segs = pointer - .replace(/^\//, "") - .split("/") - .map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~")); - if (segs.some((s) => s === "__proto__" || s === "prototype" || s === "constructor")) return data; - const out = structuredClone(data); - let cur: Json | unknown[] = out; - for (let i = 0; i < segs.length - 1; i++) { - const s = segs[i] as string; - const next: unknown = Array.isArray(cur) ? cur[Number(s)] : cur[s]; - const container: Json | unknown[] = - isObj(next) || Array.isArray(next) ? next : /^\d+$/.test(segs[i + 1] ?? "") ? [] : {}; - if (Array.isArray(cur)) cur[Number(s)] = container; - else cur[s] = container; - cur = container; - } - const last = segs.at(-1) as string; - if (value === undefined) { - if (Array.isArray(cur)) cur.splice(Number(last), 1); - else delete cur[last]; - } else if (Array.isArray(cur)) cur[Number(last)] = value; - else cur[last] = value; - return out; -} - // ── A2UI → Cloudhal ───────────────────────────────────────────────────────────────────────── interface A2uiState { @@ -514,7 +478,7 @@ export function fromA2ui( // A literal value becomes the surface's data, so the input can write to it. let path = isObj(bound) && typeof bound.path === "string" ? bound.path : undefined; if (!path) { - path = `/${id.replace(/[^a-z0-9-]/g, "-")}`; + path = `/${dataKeyFor(id)}`; data = pointerSet( data, path, @@ -1226,8 +1190,3 @@ export function toJsonRender(surface: Pick /\s/.test(c); + +/** + * A line opening a code fence: the fence (```` ``` ```` or `~~~`, three or more), the info string's + * first word and the rest of the line, as in ```surface {"id":"pricing","catalog":"blog@1"}. + * What `/^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)(.*)$/` matched, in one pass: that regex was + * quadratic on a fence followed by many tabs and a lone `\r` (js/polynomial-redos, alert 30). + */ +export function fenceOpen(line: string): [fence: string, word: string, rest: string] | undefined { + // `.` and `$` (no `m` flag): the regex never matched a line holding a line break. + if (LINE_BREAK.test(line)) return undefined; + let i = 0; + while (i < 3 && line[i] === " ") i++; + const ch = line[i]; + if (ch !== "`" && ch !== "~") return undefined; + const start = i; + while (line[i] === ch) i++; + if (i - start < 3) return undefined; + const fence = line.slice(start, i); + while (line[i] === " " || line[i] === "\t") i++; + const word = i; + while (i < line.length && line[i] !== "`" && !isSpace(line[i] as string)) i++; + return [fence, line.slice(word, i), line.slice(i)]; +} export const SURFACE_ID = /^[a-z][a-z0-9-]{0,40}$/; export const CATALOG_ID = /^[a-z][a-z0-9-]{0,40}@[0-9]{1,4}$/; +/** + * Whether `line` closes a fence opened with `fence`: up to three spaces, at least as many of its + * character, then only spaces or tabs. What `^ {0,3}`{n,}[ \t]*$` tested, without building a + * RegExp from the body's text for every fence (CodeQL followed the line into it, alert 45). + */ +export function closesFence(line: string, fence: string): boolean { + let i = 0; + while (i < 3 && line[i] === " ") i++; + const start = i; + while (line[i] === fence[0]) i++; + if (i - start < fence.length) return false; + while (line[i] === " " || line[i] === "\t") i++; + return i === line.length; +} + /** One ```surface fence of a body, parsed line by line. */ export interface BodySurface { /** The fence's `id` (unique in the body). */ @@ -116,15 +154,13 @@ export function bodySurfaces(markdown: string): BodySurface[] { const lines = markdown.split(/\r?\n/); const out: BodySurface[] = []; for (let i = 0; i < lines.length; i++) { - const m = FENCE_OPEN.exec(lines[i] as string); + const m = fenceOpen(lines[i] as string); if (!m) continue; - const fence = m[1] as string; - const closes = (l: string) => - new RegExp(`^ {0,3}${fence[0] === "`" ? "`" : "~"}{${fence.length},}[ \\t]*$`).test(l); + const [fence, word, rest] = m; let j = i + 1; - while (j < lines.length && !closes(lines[j] as string)) j++; - if (m[2] === "surface") - out.push(parseSurfaceFence((m[3] ?? "").trim(), lines.slice(i + 1, j), j < lines.length, i)); + while (j < lines.length && !closesFence(lines[j] as string, fence)) j++; + if (word === "surface") + out.push(parseSurfaceFence(rest.trim(), lines.slice(i + 1, j), j < lines.length, i)); i = j; } return out; diff --git a/packages/core-ts/src/surfaces/checks/component.ts b/packages/core-ts/src/surfaces/checks/component.ts index cfbdf4d4..4139f25f 100644 --- a/packages/core-ts/src/surfaces/checks/component.ts +++ b/packages/core-ts/src/surfaces/checks/component.ts @@ -1,8 +1,8 @@ /** Rules for one component of a surface: children, props, bindings, motion and per-kind limits. */ import type { SurfaceComponent } from "@cloudhal/core-contracts"; -import { checkCondition, isPathValue } from "../bindings/values.ts"; +import { checkCondition, checkWrittenPointer, isPathValue } from "../bindings/values.ts"; import { type CatalogEntry, childRule } from "../catalog/index.ts"; -import { INPUT_MODEL } from "../catalog/props.ts"; +import { INPUT_COMPONENTS, INPUT_MODEL } from "../catalog/props.ts"; import { checkMotion } from "../motion.ts"; import { isObj } from "../shared.ts"; import { kidsOf, type SurfaceGraph } from "./graph.ts"; @@ -51,11 +51,29 @@ export function checkComponent( }); if (c.component === "Button" && isObj(c.props) && isObj(c.props.onClick)) checkAction(c.props.onClick, `${at}/props/onClick`, issues); + checkWrites(c, at, issues); if (c.visible !== undefined) checkCondition(c.visible, `${at}/visible`, issues); if (c.motion !== undefined) checkMotion(c.motion, c.component, `${at}/motion`, issues); checkEffects(g, c, at); } +/** The pointers this component writes through: no reserved keys on the way (see `checkWrittenPointer`). */ +function checkWrites(c: SurfaceComponent, at: string, issues: SurfaceGraph["issues"]): void { + if (!isObj(c.props)) return; + const model = INPUT_COMPONENTS.has(c.component) + ? (INPUT_MODEL[c.component] ?? "value") + : undefined; + const bound = model ? c.props[model] : c.component === "Scrolly" ? c.props.step : undefined; + if (isPathValue(bound)) + checkWrittenPointer(bound.path, `${at}/props/${model ?? "step"}/path`, issues); + if ( + c.component === "Button" && + isObj(c.props.onClick) && + typeof c.props.onClick.path === "string" + ) + checkWrittenPointer(c.props.onClick.path, `${at}/props/onClick/path`, issues); +} + /** RFC-0024: ambient presets, marquees, rotating and split text, scroll stories. */ function checkEffects(g: SurfaceGraph, c: SurfaceComponent, at: string): void { const { issues } = g; diff --git a/packages/core-ts/src/surfaces/patch/json-patch.ts b/packages/core-ts/src/surfaces/patch/json-patch.ts index a5bb69c2..2b5af724 100644 --- a/packages/core-ts/src/surfaces/patch/json-patch.ts +++ b/packages/core-ts/src/surfaces/patch/json-patch.ts @@ -1,4 +1,5 @@ /** RFC 6902 JSON Patch over plain JSON, with forbidden keys refused. */ +import { RESERVED_KEYS } from "../../names.ts"; import { pointerSegments } from "../bindings/values.ts"; import { isObj, SURFACE_LIMITS } from "../shared.ts"; @@ -24,7 +25,8 @@ export class PatchError extends Error { } export const OPS = new Set(["add", "remove", "replace", "move", "copy", "test"]); -export const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]); +/** The reserved keys of `@cloudhal/core-ts/names`, refused at every level of a patch path. */ +export const FORBIDDEN_KEYS = RESERVED_KEYS; export function container(doc: unknown, segments: string[], index: number, path: string): unknown { let cur = doc; diff --git a/packages/core-ts/test/interop-pollution.test.ts b/packages/core-ts/test/interop-pollution.test.ts new file mode 100644 index 00000000..79c31f00 --- /dev/null +++ b/packages/core-ts/test/interop-pollution.test.ts @@ -0,0 +1,195 @@ +/** + * A2UI `updateDataModel` paths come from agent and MCP input: reserved keys are refused at every + * level with an `InteropError`, and nothing reaches `Object.prototype` (CodeQL + * js/prototype-polluting-assignment, alerts 42–44). + */ +import { afterEach, describe, expect, it } from "vitest"; +import { A2UI_BASIC_CATALOG, fromA2ui, InteropError } from "../src/surface-interop.ts"; +import { checkSurface, createCatalog } from "../src/surfaces.ts"; + +const catalog = createCatalog({ name: "blog", major: 1 }); +const v = "v0.9"; + +const withData = (path: string, value: unknown) => [ + { version: v, createSurface: { surfaceId: "s", catalogId: A2UI_BASIC_CATALOG } }, + { + version: v, + updateComponents: { + surfaceId: "s", + components: [{ id: "root", component: "Text", text: { path: "/title" } }], + }, + }, + { version: v, updateDataModel: { surfaceId: "s", path, value } }, +]; + +const convert = (path: string, value: unknown = { polluted: true }) => + fromA2ui(withData(path, value), { sectionId: "sec_pollute001", catalog: catalog.id }); + +const POINTERS = [ + "/__proto__/x", + "/__proto__", + "/constructor/prototype/x", + "/a/__proto__/x", + "/a/b/constructor/prototype/x", + "/list/0/__proto__/x", + "/prototype/x", + // `~1` decodes to `/`, `~0` to `~`: the decoded segment is what is checked + "/a~1b/__proto__/x", +]; + +afterEach(() => { + // Belt and braces: never leave a polluted prototype behind for other tests. + for (const k of ["x", "polluted"]) delete (Object.prototype as Record)[k]; +}); + +describe("A2UI data paths with reserved keys", () => { + it.each(POINTERS)("refuses %s with an error and leaves Object.prototype alone", (pointer) => { + expect(() => convert(pointer)).toThrow(InteropError); + expect(() => convert(pointer)).toThrow(/reserved key/); + expect(({} as Record).x).toBeUndefined(); + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.getPrototypeOf({})).toBe(Object.prototype); + }); + + it("refuses a delete (no value) through a reserved key too", () => { + expect(() => convert("/__proto__/hasOwnProperty", undefined)).toThrow(InteropError); + expect(typeof Object.prototype.hasOwnProperty).toBe("function"); + }); + + it("refuses a non-index step into an array", () => { + const messages = [ + ...withData("/list", ["a"]), + { version: v, updateDataModel: { surfaceId: "s", path: "/list/length", value: 0 } }, + ]; + expect(() => fromA2ui(messages, { sectionId: "sec_pollute001", catalog: catalog.id })).toThrow( + /not an array index/, + ); + }); + + it("still writes ordinary paths, inherited names included, as own data", () => { + const r = fromA2ui( + [ + ...withData("/title", "Hello"), + { version: v, updateDataModel: { surfaceId: "s", path: "/toString/y", value: 1 } }, + { version: v, updateDataModel: { surfaceId: "s", path: "/list/1/name", value: "Pro" } }, + ], + { sectionId: "sec_pollute001", catalog: catalog.id }, + ); + expect(r.surface.data).toEqual({ + title: "Hello", + toString: { y: 1 }, + list: [undefined, { name: "Pro" }], + }); + expect(Object.hasOwn(r.surface.data as object, "toString")).toBe(true); + expect(checkSurface(r.surface, catalog).issues).toEqual([]); + }); +}); + +describe("A2UI inputs whose component id is a reserved key", () => { + it.each(["prototype", "constructor", "Prototype"])( + "convert, with %s-value as the data key", + (raw) => { + const r = fromA2ui( + [ + { version: v, createSurface: { surfaceId: "s", catalogId: A2UI_BASIC_CATALOG } }, + { + version: v, + updateComponents: { + surfaceId: "s", + components: [ + { id: "root", component: "Column", children: [raw, "ok"] }, + { id: raw, component: "TextField", label: "Name", text: "Ada" }, + { id: "ok", component: "CheckBox", label: "Agree", value: true }, + ], + }, + }, + ], + { sectionId: "sec_pollute003", catalog: catalog.id }, + ); + const key = `${raw.toLowerCase()}-value`; + expect(r.surface.data).toEqual({ [key]: "Ada", ok: true }); + expect(r.surface.components.find((c) => c.component === "TextField")?.props).toMatchObject({ + value: { path: `/${key}` }, + }); + expect(checkSurface(r.surface, catalog).issues).toEqual([]); + }, + ); +}); + +describe("an A2UI input whose component id is __proto__", () => { + it("converts: the id is folded to a plain one, and so is its data key", () => { + const r = fromA2ui( + [ + { version: v, createSurface: { surfaceId: "s", catalogId: A2UI_BASIC_CATALOG } }, + { + version: v, + updateComponents: { + surfaceId: "s", + components: [ + { id: "root", component: "Column", children: ["__proto__"] }, + { id: "__proto__", component: "TextField", label: "Name", text: "Ada" }, + ], + }, + }, + ], + { sectionId: "sec_pollute004", catalog: catalog.id }, + ); + expect(r.surface.data).toEqual({ proto: "Ada" }); + expect(Object.getPrototypeOf(r.surface.data)).toBe(Object.prototype); + expect(r.surface.components.find((c) => c.component === "TextField")).toMatchObject({ + id: "proto", + props: { value: { path: "/proto" } }, + }); + expect(({} as Record).Ada).toBeUndefined(); + expect(checkSurface(r.surface, catalog).issues).toEqual([]); + }); +}); + +describe("surface pointers with reserved keys", () => { + const surface = (data: unknown, components: unknown[]) => + ({ id: "sec_pollute002", kind: "surface", catalog: catalog.id, data, components }) as never; + const reserved = (issues: { message: string }[]) => + issues.filter((i) => /reserved keys/.test(i.message)); + + it.each(["/__proto__/x", "/constructor/prototype/x", "/a/__proto__", "/a/prototype"])( + "checkSurface refuses %s where the page writes through it", + (path) => { + const written = [ + { id: "root", component: "TextField", props: { label: "N", value: { path } } }, + ]; + expect(reserved(checkSurface(surface({}, written), catalog).issues)).toEqual([ + expect.objectContaining({ path: "/components/0/props/value/path" }), + ]); + const checkbox = [ + { id: "root", component: "Checkbox", props: { label: "N", checked: { path } } }, + ]; + expect(reserved(checkSurface(surface({}, checkbox), catalog).issues)).toHaveLength(1); + const button = [ + { + id: "root", + component: "Button", + props: { label: "Go", onClick: { action: "toggle", path } }, + }, + ]; + expect(reserved(checkSurface(surface({}, button), catalog).issues)).toEqual([ + expect.objectContaining({ path: "/components/0/props/onClick/path" }), + ]); + const scrolly = [ + { id: "root", component: "Scrolly", props: { step: { path } }, children: ["s1"] }, + { id: "s1", component: "Step", children: [] }, + ]; + expect(reserved(checkSurface(surface({}, scrolly), catalog).issues)).toEqual([ + expect.objectContaining({ path: "/components/0/props/step/path" }), + ]); + }, + ); + + it("accepts read bindings through a data key named constructor or prototype", () => { + const page = surface({ stages: { prototype: "Q1", constructor: "Q2" } }, [ + { id: "root", component: "Column", children: ["a", "b"] }, + { id: "a", component: "Text", props: { text: { path: "/stages/prototype" } } }, + { id: "b", component: "Text", props: { text: { path: "/stages/constructor" } } }, + ]); + expect(checkSurface(page, catalog).issues).toEqual([]); + }); +}); diff --git a/packages/core-ts/test/linear-scans.test.ts b/packages/core-ts/test/linear-scans.test.ts new file mode 100644 index 00000000..3ee815bf --- /dev/null +++ b/packages/core-ts/test/linear-scans.test.ts @@ -0,0 +1,235 @@ +/** + * core-ts's linear scanners (AGENTS.md rule 13, CodeQL alerts 20, 23, 24, 30, 35, 36): each gives + * the regex it replaced's answers on ordinary input and stays fast on 100k adversarial ones. + */ +import { describe, expect, it } from "vitest"; +import { normalizeHostname } from "../src/domains.ts"; +import { escapeMarkdown, markdownToPlain } from "../src/fields/conversions.ts"; +import { isSvgText, sniff } from "../src/media/sniff.ts"; +import { bodySurfaces, closesFence, fenceOpen } from "../src/surfaces/body.ts"; + +const N = 100_000; +function fast(f: () => T): T { + const start = performance.now(); + const out = f(); + expect(performance.now() - start).toBeLessThan(1500); + return out; +} + +describe("isSvgText (alerts 24, 35, 36)", () => { + const old = (text: string) => + /^\s*(?:<\?xml[^>]*\?>\s*)?(?:\s*)*(?:]*>\s*)?(?:\s*)*]/i.test( + text.replace(/^/, ""), + ); + it("answers as the regex did on real headers", () => { + for (const t of [ + '', + "", + ' \n', + '\n', + " \n", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ]) + expect(isSvgText(t), JSON.stringify(t)).toBe(old(t)); + }); + it("ends a comment at its first --> (the one intended difference: never more svg)", () => { + const t = " x --> "; + expect(old(t)).toBe(true); + expect(isSvgText(t)).toBe(false); + }); + it("is linear on the exponential inputs", () => { + expect(fast(() => isSvgText(`".repeat(N / 7)}`))).toBe(false); + expect(fast(() => isSvgText(`${"".repeat(N / 7)}`))).toBe(true); + const bytes = new TextEncoder().encode(`