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.
136 lines
7.4 KiB
JavaScript
136 lines
7.4 KiB
JavaScript
#!/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`)
|
|
}
|