feat(demo): refuse a data: URI in any write body (DM25)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 45s
PR Checks / client-build (pull_request) Successful in 1m2s
PR Checks / server-tests (pull_request) Successful in 6m4s

Uploads are locked hard on the demo, but an image pasted into rich text
does not use multipart: it is saved as a data: URI inside the JSON body.
refuseDataUri, mounted beside refuseMultipart, refuses any POST/PUT/PATCH/
DELETE whose body has a data: URI in any string, on any route, core's or
a module's, with the usual 403 DEMO_LOCKED.

Each string is first read the way a browser reads an attribute (HTML
character references decoded, tabs and newlines removed), so data: and
a data: split by a newline are refused too. "metadata:" and prose such as
"Data: none, yet" pass. Reads are never checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-27 13:13:51 -05:00
parent 3b9998539f
commit 61feca50a8
4 changed files with 130 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
//
// With DEMO_MODE on, app.js mounts three middlewares from this file, and with it
// With DEMO_MODE on, app.js mounts four middlewares from this file, and with it
// off none of them is mounted at all, so a real shard runs none of this code.
//
// • `refuseMultipart` — any request with a multipart body, anywhere, before a
@@ -8,6 +8,8 @@
// is locked without anyone remembering to list it, and a module cannot open one.
// • `lock` — each write request against core's list below plus every module's
// declared `demoLocks`, at `/api/v1`, ahead of every router.
// • `refuseDataUri` — any write whose JSON body carries a data: URI in any
// string (DM25): a pasted image is an upload that did not use multipart.
// • `noindex` + `robots` — nothing a visitor writes reaches a search engine.
//
// **The server is the lock (DM10).** Everything here is decided from the request
@@ -174,6 +176,50 @@ function refuseMultipart(req, res, next) {
return refuse(res)
}
// DM25: an image pasted into rich text travels inside the JSON body as a data:
// URI, which is an upload by another name. Any string anywhere in a write's
// body is checked, so a field added later, a setting or a module's route is
// covered without being listed, as with multipart.
//
// A data: URI is `data:` (not the tail of a word like "metadata:") followed by
// a comma, a `;`, or a type/subtype and then a comma. Prose such as
// "Data: none, yet" does not match. Before testing, HTML character references
// are decoded and tabs and newlines removed, because a browser does both when
// it reads an attribute: `&#100;ata:` and `da<newline>ta:` are still data: URIs.
const DATA_URI = /(?<![a-z0-9+.-])data:\s*(?:[,;]|[a-z]+\/[a-z0-9.+-]+[^,<>"']{0,256},)/i
function browserReading(s) {
return s
.replace(/&#x([0-9a-f]+);?/gi, (m, hex) => safeChar(parseInt(hex, 16), m))
.replace(/&#(\d+);?/g, (m, dec) => safeChar(parseInt(dec, 10), m))
.replace(/&colon;/gi, ':')
.replace(/&(?:tab|newline);/gi, '')
.replace(/[\t\n\r]/g, '')
}
function safeChar(code, original) {
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : original
}
function hasDataUri(body) {
const stack = [body]
while (stack.length) {
const value = stack.pop()
if (typeof value === 'string') {
if (value.length > 5 && DATA_URI.test(browserReading(value))) return true
} else if (value && typeof value === 'object') {
for (const v of Object.values(value)) stack.push(v)
}
}
return false
}
function refuseDataUri(req, res, next) {
if (!WRITES.includes(req.method) || !hasDataUri(req.body)) return next()
log.info('refused data: URI', { method: req.method, path: req.originalUrl })
return refuse(res)
}
function noindex(req, res, next) {
res.set('X-Robots-Tag', 'noindex, nofollow')
next()
@@ -191,6 +237,8 @@ module.exports = {
PROTECTED_SETTINGS,
createLock,
refuseMultipart,
refuseDataUri,
hasDataUri,
noindex,
robots,
refuse,