feat(demo): refuse a data: URI in any write body (DM25) #229
@@ -100,6 +100,7 @@ if (isDemo()) {
|
|||||||
demoLock.robots(req, res)
|
demoLock.robots(req, res)
|
||||||
})
|
})
|
||||||
app.use(demoLock.refuseMultipart)
|
app.use(demoLock.refuseMultipart)
|
||||||
|
app.use(demoLock.refuseDataUri)
|
||||||
app.use('/api/v1', demoLock.createLock(() => modules.demoLocks()))
|
app.use('/api/v1', demoLock.createLock(() => modules.demoLocks()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
// ── 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.
|
// 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
|
// • `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.
|
// 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
|
// • `lock` — each write request against core's list below plus every module's
|
||||||
// declared `demoLocks`, at `/api/v1`, ahead of every router.
|
// 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.
|
// • `noindex` + `robots` — nothing a visitor writes reaches a search engine.
|
||||||
//
|
//
|
||||||
// **The server is the lock (DM10).** Everything here is decided from the request
|
// **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)
|
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 `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(/:/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) {
|
function noindex(req, res, next) {
|
||||||
res.set('X-Robots-Tag', 'noindex, nofollow')
|
res.set('X-Robots-Tag', 'noindex, nofollow')
|
||||||
next()
|
next()
|
||||||
@@ -191,6 +237,8 @@ module.exports = {
|
|||||||
PROTECTED_SETTINGS,
|
PROTECTED_SETTINGS,
|
||||||
createLock,
|
createLock,
|
||||||
refuseMultipart,
|
refuseMultipart,
|
||||||
|
refuseDataUri,
|
||||||
|
hasDataUri,
|
||||||
noindex,
|
noindex,
|
||||||
robots,
|
robots,
|
||||||
refuse,
|
refuse,
|
||||||
|
|||||||
@@ -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: '<p><img src="data:image/png;base64,iVBORw0KGgo="></p>' }),
|
||||||
|
})
|
||||||
|
assert.equal(res.status, 403)
|
||||||
|
assert.equal((await res.json()).code, 'DEMO_LOCKED')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('every response carries noindex, and robots.txt disallows everything', async () => {
|
test('every response carries noindex, and robots.txt disallows everything', async () => {
|
||||||
await withServer(async (base) => {
|
await withServer(async (base) => {
|
||||||
const health = await fetch(`${base}/api/health`)
|
const health = await fetch(`${base}/api/health`)
|
||||||
|
|||||||
@@ -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 = '<p>hi</p><img src="data:image/png;base64,iVBORw0KGgo=">'
|
||||||
|
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,<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||||
|
'data:text/html;base64,PHNjcmlwdD4=',
|
||||||
|
'data:,hello',
|
||||||
|
'data:;base64,AAAA',
|
||||||
|
'data: image/png;base64,AAAA',
|
||||||
|
'<div style="background:url(data:image/gif;base64,R0lG)">',
|
||||||
|
'<img src="data:image/png;base64,AAAA">',
|
||||||
|
'<img src="data:image/png;base64,AAAA">',
|
||||||
|
'<img src="data:image/png;base64,AAAA">',
|
||||||
|
'<img src="da\nta:image/png;base64,AAAA">',
|
||||||
|
'<img src="data:im\tage/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: '<p>metadata: none</p>' }))
|
||||||
|
})
|
||||||
|
// 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 ───────────────────────────────────────────────────────────
|
// ── Module locks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test("a module's declared locks are refused alongside core's", async () => {
|
test("a module's declared locks are refused alongside core's", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user