Merge branch 'main' into feature/demo-default-events
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `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) {
|
||||
res.set('X-Robots-Tag', 'noindex, nofollow')
|
||||
next()
|
||||
@@ -191,6 +237,8 @@ module.exports = {
|
||||
PROTECTED_SETTINGS,
|
||||
createLock,
|
||||
refuseMultipart,
|
||||
refuseDataUri,
|
||||
hasDataUri,
|
||||
noindex,
|
||||
robots,
|
||||
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 () => {
|
||||
await withServer(async (base) => {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
test("a module's declared locks are refused alongside core's", async () => {
|
||||
|
||||
Reference in New Issue
Block a user