From 61feca50a8805f30199450492221c889d2ece0d1 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 27 Oct 2026 13:13:51 -0500 Subject: [PATCH] feat(demo): refuse a data: URI in any write body (DM25) 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- server/src/app.js | 1 + server/src/demo/locks.js | 50 +++++++++++++++++++++++++- server/test/demoApp.test.js | 12 +++++++ server/test/demoLocks.test.js | 68 +++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) diff --git a/server/src/app.js b/server/src/app.js index 1519d2d..dcc647c 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -100,6 +100,7 @@ if (isDemo()) { demoLock.robots(req, res) }) app.use(demoLock.refuseMultipart) + app.use(demoLock.refuseDataUri) app.use('/api/v1', demoLock.createLock(() => modules.demoLocks())) } diff --git a/server/src/demo/locks.js b/server/src/demo/locks.js index fa5821a..470c7e6 100644 --- a/server/src/demo/locks.js +++ b/server/src/demo/locks.js @@ -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: `data:` and `data:` are still data: URIs. +const DATA_URI = /(?"']{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(/:/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, diff --git a/server/test/demoApp.test.js b/server/test/demoApp.test.js index d07c4e2..a0d3030 100644 --- a/server/test/demoApp.test.js +++ b/server/test/demoApp.test.js @@ -64,6 +64,18 @@ test('an upload is refused before multer or auth sees it', async () => { }) }) +test('a pasted data: image is refused before auth sees it, on an open route', async () => { + await withServer(async (base) => { + const res = await fetch(`${base}/api/v1/admin/posts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'x', body: '

' }), + }) + assert.equal(res.status, 403) + assert.equal((await res.json()).code, 'DEMO_LOCKED') + }) +}) + test('every response carries noindex, and robots.txt disallows everything', async () => { await withServer(async (base) => { const health = await fetch(`${base}/api/health`) diff --git a/server/test/demoLocks.test.js b/server/test/demoLocks.test.js index cbc128c..e0111fa 100644 --- a/server/test/demoLocks.test.js +++ b/server/test/demoLocks.test.js @@ -279,6 +279,74 @@ test('any multipart request is refused before a router sees it, wherever it is s }) }) +// DM25: a data: URI in any string of a write's body is an upload too. +async function withDataUriLock(fn) { + const app = await startApp((a) => { + a.use(locks.refuseDataUri) + a.all('*', (req, res) => res.json({ reached: true })) + }) + try { + return await fn((method, path, body) => + fetch(`${app.url}/api/v1${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + })) + } finally { + await app.close() + } +} + +test('a data: URI anywhere in a write body is refused, on any route and any write method', async () => { + await withDataUriLock(async (req) => { + const img = '

hi

' + await expectLocked(await req('POST', '/admin/posts', { title: 't', body: img })) + await expectLocked(await req('PUT', '/admin/wiki/1', { content: img })) + await expectLocked(await req('PATCH', '/rust/anything', { deep: [{ x: { y: [img] } }] })) + await expectLocked(await req('PUT', '/admin/settings', { hero_layout_draft: JSON.stringify([{ src: 'data:image/webp;base64,UklG' }]) })) + await expectLocked(await req('DELETE', '/player/teams/x/forum/1', { reason: img })) + }) +}) + +test('every data: URI shape a browser would load is refused', async () => { + const shapes = [ + 'DATA:image/png;base64,AAAA', + 'data:image/svg+xml,', + 'data:text/html;base64,PHNjcmlwdD4=', + 'data:,hello', + 'data:;base64,AAAA', + 'data: image/png;base64,AAAA', + '
', + '', + '', + '', + '', + '', + ] + for (const s of shapes) assert.equal(locks.hasDataUri({ body: s }), true, s) +}) + +test('prose that mentions data, reads, and bodies without strings pass', async () => { + for (const s of ['metadata: none', 'Data: none, yet', 'the data: it is fine', 'see https://example.com/data:x', 'mydata:image/png,x']) { + assert.equal(locks.hasDataUri({ body: s }), false, s) + } + assert.equal(locks.hasDataUri(undefined), false) + assert.equal(locks.hasDataUri({ n: 1, ok: true, list: [null, 2] }), false) + await withDataUriLock(async (req) => { + await expectReached(await req('POST', '/admin/posts', { title: 'json is fine', body: '

metadata: none

' })) + }) + // A read is never refused, whatever it carries. + const app = await startApp((a) => { + a.use(locks.refuseDataUri) + a.all('*', (req, res) => res.json({ reached: true })) + }) + try { + await expectReached(await fetch(`${app.url}/api/v1/public/settings?x=data:image/png;base64,AAAA`)) + } finally { + await app.close() + } +}) + // ── Module locks ─────────────────────────────────────────────────────────── test("a module's declared locks are refused alongside core's", async () => { -- 2.49.1