diff --git a/internal/check-dev-leaf-escape.mjs b/internal/check-dev-leaf-escape.mjs new file mode 100644 index 000000000..e74478c02 --- /dev/null +++ b/internal/check-dev-leaf-escape.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * check-dev-leaf-escape.mjs — a dev leaf URL cannot write markup into the page it opens. + * + * THE DEFECT. The dev server's leaf route, /__leaf//[/] (and a production embed URL, + * /[/]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the + * and the element's profile-config='…' attribute. It refused only `..` and `/`, so a quote or a tag in the path + * was written raw, and a crafted link broke out of the attribute on a publicly reachable dev server. + * internal/plugin-gallery/dev-plugin.mjs now refuses any part outside LEAF_PART (the shape every bundle folder, profile + * id and instance id has) before either sink, and escapes the <title> as well. + * + * The plugin's own middleware answers each request (no server, no browser): + * A. every leaf the dev manifest advertises (galleryModel), as /__leaf/… AND as its production embed URL → 200 with + * its element: the rule refuses no real page; + * B. a hostile part (a quote, a double quote, a tag, an ampersand, a space, a backtick, an encoded slash or + * backslash, `..`, `.`, an attribute break-out) in each position, both URL forms → refused here (400) or left to + * Vite, never a page; + * C. escHtml leaves none of & < > " ' raw; + * D. with the part rule set back to the old one (`..` and `/` only), a tag in the profile still reaches the <title> + * escaped: the title has its own escaping, not only the rule in front of it. + * Response bodies are never printed: a leaf page carries the build config's token. + * `--self-test`: the old guard, a rule too strict for a real profile, an unescaped <title> and an escHtml that misses + * the quote must each FAIL. + */ +import { readFileSync, writeFileSync, rmSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { discoverSpaRegistry } from './plugin-gallery/discover.mjs' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const PLUGIN = join(HERE, 'plugin-gallery/dev-plugin.mjs') +const SRC = readFileSync(PLUGIN, 'utf8') +const GUARD = 'parts.some(s => !LEAF_PART.test(s))' +const OLD_GUARD = "parts.some(s => !s || s.includes('..') || s.includes('/'))" +const RULE = 'export const LEAF_PART = /^[A-Za-z0-9][\\w-]{0,63}$/' +const TITLE = '${escHtml(bundle)} · ${escHtml(profile)}' +const ESC_CLASS = `/[&<>"']/g` + +// A copy of the plugin, altered or not, imported from beside the original so its relative imports resolve; the +// file is removed as soon as it is loaded. +let seq = 0 +async function load(src) { + if (src === SRC) return import(pathToFileURL(PLUGIN).href) + const tmp = join(HERE, 'plugin-gallery', `.tmp-check-dev-leaf-escape-${process.pid}-${++seq}.mjs`) + writeFileSync(tmp, src) + try { return await import(pathToFileURL(tmp).href) } finally { rmSync(tmp, { force: true }) } +} + +// The plugin's middleware, answering one GET: { status, body } ('next' = not answered here, Vite would). +function client(mod) { + let mw = null + mod.autoSpaDevPlugin('').configureServer({ middlewares: { use: (f) => { if (!mw) mw = f } } }) + return (url) => new Promise((done) => { + const res = { statusCode: 200, setHeader() {}, end: (b) => done({ status: res.statusCode, body: String(b ?? '') }) } + try { mw({ url, method: 'GET', headers: {} }, res, () => done({ status: 'next', body: '' })) } + catch (e) { done({ status: 'threw', body: String(e && e.message) }) } + }) +} + +// Every /__leaf/ URL the dev manifest advertises, with the tag its page must mount. +function advertised(mod) { + const out = [] + const walk = (o, tag) => { + if (!o || typeof o !== 'object') return + for (const v of Object.values(o)) { + if (typeof v === 'string' && v.startsWith('/__leaf/')) out.push({ url: v.split(/[?#]/)[0], tag }) + else walk(v, tag) + } + } + for (const b of mod.galleryModel(discoverSpaRegistry()).bundles) walk(b, (b.elements || [])[0]) + return [...new Map(out.map((x) => [x.url, x])).values()] +} + +const HOSTILE = ["a'b", 'a"b', 'a<b', 'a>b', 'a&b', 'a b', 'a`b', 'a/b', 'a\\b', '..', '.', "x'><img src=x onerror=alert(1)>"] + +async function audit(src) { + const f = [] + let mod + try { mod = await load(src) } catch (e) { return [`load: the plugin does not import (${e.message})`] } + const get = client(mod) + const leaves = advertised(mod) + if (leaves.length < 20) f.push(`A: only ${leaves.length} advertised leaves found (the manifest walk is broken)`) + for (const { url, tag } of leaves) { + for (const u of [url, url.replace('/__leaf/', '/') + '/index.html']) { + const r = await get(u) + if (r.status !== 200 || !r.body.includes(`<${tag} `)) f.push(`A: ${u} → ${r.status}${r.status === 200 ? `, no <${tag}>` : ''} (a real page refused)`) + } + } + const [b0, p0] = (leaves[0] ? leaves[0].url : '/__leaf/x/x').slice('/__leaf/'.length).split('/') + for (const h of HOSTILE) { + const e = encodeURIComponent(h) + for (const parts of [[e, p0], [b0, e], [b0, p0, e]]) { + const leaf = '/__leaf/' + parts.join('/') + const r = await get(leaf) + if (r.status !== 400) f.push(`B: ${leaf} → ${r.status} (want 400)`) + const embed = '/' + parts.join('/') + '/index.html' + const r2 = await get(embed) + if (r2.status !== 400 && r2.status !== 'next') f.push(`B: ${embed} → ${r2.status} (want 400, or left to Vite)`) + } + } + const esc = mod.escHtml ? mod.escHtml(`&<>"'`) : null + if (esc !== '&<>"'') f.push(`C: escHtml(& < > " ') = ${JSON.stringify(esc)}`) + if (!src.includes(GUARD)) f.push('D: the leaf guard is not the LEAF_PART rule, so the title cannot be tested behind the old one') + else { + let loose + try { loose = await load(src.replace(GUARD, OLD_GUARD)) } catch (e) { f.push(`D: ${e.message}`) } + if (loose) { + const r = await client(loose)(`/__leaf/${b0}/${encodeURIComponent('<b>x')}`) + const title = (/<title>([^]*?)<\/title>/.exec(r.body) || [])[1] + if (r.status !== 200 || title === undefined) f.push(`D: behind the old rule the page did not render (${r.status})`) + else if (title.includes('<b>') || !title.includes('<b>')) f.push('D: behind the old rule a tag in the profile reached the <title> raw') + } + } + return f +} + +if (process.argv.includes('--self-test')) { + const poisons = [ + ['the old guard (`..` and `/` only)', SRC.replace(GUARD, OLD_GUARD), /^B: /], + ['a rule too strict for a real profile', SRC.replace(RULE, 'export const LEAF_PART = /^[a-z]{1,63}$/'), /^A: /], + ['an unescaped <title>', SRC.replace(TITLE, '${bundle} · ${profile}'), /^D: /], + ['an escHtml that misses the quote', SRC.replace(ESC_CLASS, `/[&<>"]/g`), /^C: /], + ] + let missed = 0 + for (const [name, src, rx] of poisons) { + if (src === SRC) { console.log(`✗ MISSED: ${name} (not planted: its source line was not found)`); missed++; continue } + const f = await audit(src); const hit = f.find((x) => rx.test(x)) + console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`) + if (!hit) missed++ + } + if (missed) process.exitCode = 1 +} else { + const f = await audit(SRC) + if (f.length) { for (const x of f.slice(0, 40)) console.log('✗ ' + x); console.log(`FAIL check:dev-leaf-escape (${f.length})`); process.exitCode = 1 } + else console.log(`PASS check:dev-leaf-escape — every advertised dev leaf is served in both URL forms; ${HOSTILE.length} hostile parts are refused in every position of both forms; the <title> escapes on its own`) +} diff --git a/internal/plugin-gallery/dev-plugin.mjs b/internal/plugin-gallery/dev-plugin.mjs index 5b6ff2b62..3ca53b4d0 100644 --- a/internal/plugin-gallery/dev-plugin.mjs +++ b/internal/plugin-gallery/dev-plugin.mjs @@ -145,6 +145,18 @@ export function galleryModel(reg) { } } +// ── ONE PART OF A LEAF URL ─────────────────────────────────────────────────────────────────── +// /__leaf/<bundle>/<profile>[/<instance>] parts are written INTO the leaf page: its <title> and the +// element's profile-config='…' attribute. Refusing only `..` and `/` let a quote or a tag through, so a +// crafted link broke out of the attribute on a publicly reachable dev server. Every bundle folder, +// profile id and instance id in the tree has this shape (the address keys' own pattern, +// library/composables/useViewAddress.js), so anything else is refused before it reaches either sink; +// it also rules out `..` and `/`. check:dev-leaf-escape proves both directions. +export const LEAF_PART = /^[A-Za-z0-9][\w-]{0,63}$/ + +// For an HTML text node or a quoted attribute value. +export const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) + // ── THE PRODUCTION EMBED URL, ANSWERED BY THE DEV SERVER ───────────────────────────────────── // The manifest advertises, and a person copies, tree-relative embed URLs of the shape // `<bundle>/<profile>/index.html` and `<bundle>/<profile>/<instance>/index.html` — served in @@ -337,8 +349,8 @@ export function autoSpaDevPlugin(token = '') { try { return decodeURIComponent(s) } catch { return '' } }) const [bundle, profile, instance] = parts - // Path traversal guard: these land in a filesystem-ish URL below. - if (!bundle || !profile || parts.some(s => !s || s.includes('..') || s.includes('/'))) { + // These land in a filesystem-ish URL and in the page's HTML below: LEAF_PART shapes only. + if (!bundle || !profile || parts.some(s => !LEAF_PART.test(s))) { res.statusCode = 400; return res.end('bad leaf path') } let reg @@ -376,7 +388,7 @@ export function autoSpaDevPlugin(token = '') { res.setHeader('Cache-Control', 'no-cache') return res.end([ '<!DOCTYPE html>', - `<html lang="en"><head><meta charset="UTF-8" /><title>${bundle} · ${profile}`, + `${escHtml(bundle)} · ${escHtml(profile)}`, '', '', leafTok ? `` : '', diff --git a/package.json b/package.json index 9ddde7c9d..ce72c6a08 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "check:describe-group": "node internal/check-describe-group.mjs", "check:descriptions": "node internal/check-profile-descriptions.mjs", "check:dev-assets": "node internal/check-dev-assets.mjs", + "check:dev-leaf-escape": "node internal/check-dev-leaf-escape.mjs", + "check:dev-leaf-escape:self-test": "node internal/check-dev-leaf-escape.mjs --self-test", "check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs", "check:readme-size": "node internal/check-readme-web-folder-size.mjs", "check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test", @@ -503,6 +505,8 @@ "check:window-globals-released": "a component that puts something on window must take it back. Heap snapshots (leak-722ac7789) measured SR+APG mounted twice at 54.2 -> 65.1 MB with live Vue apps 6 -> 8: every unmounted app stayed in memory, each pinned by one window key holding a closure, which keeps the component, its shadow root and the whole app. Flags only values that RETAIN (closures and object references), not warn-once latches or page-wide caches keyed off themselves. NOT in gates:pre: 19 sites remain across other owners' plugins. Run it directly.", "check:describe-group": "the prayer packet's generated profile sentence (library/packets/describeGroup.js): each GSEC level states its own fact, and an index-ordered language name (\"Arabic, Moroccan\") is un-inverted by library/utils/languageLabel.js without shredding a comma inside parentheses, the 13 names the old split printed reversed. A catalogue arm renders every GSEC level from the REAL packet-strings.json in en and ar and fails on a blank sentence. Headless; in gates:pre.", "check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)", + "check:dev-leaf-escape": "a dev leaf URL cannot write markup into the page it opens. The dev server's /__leaf//[/] route (and a production embed URL rewritten onto it) writes the URL's parts into the page's and the element's profile-config attribute; it refused only .. and /, so a quote or a tag broke out of the attribute on a publicly reachable dev server. internal/plugin-gallery/dev-plugin.mjs refuses any part outside LEAF_PART and escapes the <title>. Drives the plugin's own middleware (no server): every leaf the dev manifest advertises is served in both URL forms; 12 hostile parts are refused in every position; escHtml escapes & < > \" '; and behind the OLD rule the <title> still escapes. Bodies are never printed (a leaf page carries the token). Sub-5s.", + "check:dev-leaf-escape:self-test": "4 poisons must each make check:dev-leaf-escape fail: the old guard (.. and / only), a part rule too strict for a real profile, an unescaped <title>, an escHtml that misses the quote.", "check:element-census-vs-live": "what is on ONE side and not the other, ours vs the live doxa.life page we clone: harvests every visible TEXT NODE through every shadow root on both sides and diffs the SETS. OURS-ONLY unaccounted = a scope violation (FAIL), THEIRS-ONLY = a clone omission (FAIL), SANCTIONED = an allowlist entry carrying the ruling + date + card (refused without them), DATA DRIFT = same label, different digits (reported). The negative control is MANDATORY: a known ours-only string must land in the ours-only set or it prints CENSUS IS BLIND and exits 75; an unreachable live page is also 75. Presets: internal/element-census/<name>.json (--preset <name>, default all); ad hoc: --leaf <bundle/profile/index.html[#hash]> --live <url> --neg <text> [--sanctioned <file>]. Promoted from regions' probe; regions passes (list 229 vs 228, the only difference its sanctioned poster toggle). Through browser-slot.sh.", "check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish.", "check:embed-basemap": "the gospel map's BASEMAP must paint inside a genuinely cross-origin iframe on the token the page BAKES, not one a probe forces on it. Every other browser gate here forces a restricted token (u=doxalife) that 403s on the basemap, then counts PINS — which come from our own GeoJSON and paint with no token at all — so 'the map renders' had never been measured. Runs a negative control every time by rewriting the token on the wire; if the control passes it reports PROVED NOTHING rather than a pass. ~60s, needs a browser.",