Dev server: a leaf URL cannot write markup into the page it opens
The dev leaf route wrote the URL's parts into the page's <title> and the element's profile-config attribute, refusing only .. and /. A quote or a tag in a crafted link broke out of the attribute on a publicly reachable dev server. Each part must now have the shape every bundle, profile and instance id has, before it reaches either; the title is escaped too. check:dev-leaf-escape drives the plugin's own middleware: every leaf the dev manifest advertises is still served in both URL forms, hostile parts are refused in every position, and the title escapes on its own.
This commit is contained in:
parent
789d90eb2f
commit
484f07382e
3 changed files with 155 additions and 3 deletions
136
internal/check-dev-leaf-escape.mjs
Normal file
136
internal/check-dev-leaf-escape.mjs
Normal file
|
|
@ -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/<bundle>/<profile>[/<instance>] (and a production embed URL,
|
||||||
|
* <bundle>/<profile>[/<instance>]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the
|
||||||
|
* <title> 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`)
|
||||||
|
}
|
||||||
|
|
@ -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 PRODUCTION EMBED URL, ANSWERED BY THE DEV SERVER ─────────────────────────────────────
|
||||||
// The manifest advertises, and a person copies, tree-relative embed URLs of the shape
|
// 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
|
// `<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 '' }
|
try { return decodeURIComponent(s) } catch { return '' }
|
||||||
})
|
})
|
||||||
const [bundle, profile, instance] = parts
|
const [bundle, profile, instance] = parts
|
||||||
// Path traversal guard: these land in a filesystem-ish URL below.
|
// These land in a filesystem-ish URL and in the page's HTML below: LEAF_PART shapes only.
|
||||||
if (!bundle || !profile || parts.some(s => !s || s.includes('..') || s.includes('/'))) {
|
if (!bundle || !profile || parts.some(s => !LEAF_PART.test(s))) {
|
||||||
res.statusCode = 400; return res.end('bad leaf path')
|
res.statusCode = 400; return res.end('bad leaf path')
|
||||||
}
|
}
|
||||||
let reg
|
let reg
|
||||||
|
|
@ -376,7 +388,7 @@ export function autoSpaDevPlugin(token = '') {
|
||||||
res.setHeader('Cache-Control', 'no-cache')
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
return res.end([
|
return res.end([
|
||||||
'<!DOCTYPE html>',
|
'<!DOCTYPE html>',
|
||||||
`<html lang="en"><head><meta charset="UTF-8" /><title>${bundle} · ${profile}</title>`,
|
`<html lang="en"><head><meta charset="UTF-8" /><title>${escHtml(bundle)} · ${escHtml(profile)}</title>`,
|
||||||
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
|
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
|
||||||
'<link rel="icon" href="data:,">',
|
'<link rel="icon" href="data:,">',
|
||||||
leafTok ? `<script>window.MAP_TOKEN=${JSON.stringify(leafTok)};</script>` : '',
|
leafTok ? `<script>window.MAP_TOKEN=${JSON.stringify(leafTok)};</script>` : '',
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@
|
||||||
"check:describe-group": "node internal/check-describe-group.mjs",
|
"check:describe-group": "node internal/check-describe-group.mjs",
|
||||||
"check:descriptions": "node internal/check-profile-descriptions.mjs",
|
"check:descriptions": "node internal/check-profile-descriptions.mjs",
|
||||||
"check:dev-assets": "node internal/check-dev-assets.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:doc-scripts": "node internal/check-doc-quotes-scripts.mjs",
|
||||||
"check:readme-size": "node internal/check-readme-web-folder-size.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",
|
"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: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: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: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/<bundle>/<profile>[/<instance>] route (and a production embed URL rewritten onto it) writes the URL's parts into the page's <title> 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: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-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.",
|
"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.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue