mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[img] fix potential xss
This commit is contained in:
@@ -303,6 +303,20 @@ async function serveStaticAsset(
|
||||
return new Response(asset.body, { headers })
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers that keep a stored object from running as a document. An upload reaches R2
|
||||
* with whatever content-type the uploader claimed (`api` writes `file.type` through),
|
||||
* so a `text/html` body with a `.jpg` name is served back as HTML on this origin.
|
||||
* `nosniff` stops the browser upgrading an image type to HTML on its own, and the
|
||||
* `sandbox` CSP denies scripts, plugins and same-origin access even when the stored
|
||||
* type really is HTML or SVG — the page renders inert. Applied to every response, the
|
||||
* status JSON and 304s included; there is no response here that should ever execute.
|
||||
*/
|
||||
const NO_EXECUTE_HEADERS = {
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-security-policy': "default-src 'none'; sandbox",
|
||||
} as const
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -313,6 +327,12 @@ const app = new Hono<App>()
|
||||
release: c.env.SENTRY_RELEASE,
|
||||
})(c, next)
|
||||
)
|
||||
.use('*', async (c, next) => {
|
||||
await next()
|
||||
for (const [name, value] of Object.entries(NO_EXECUTE_HEADERS)) {
|
||||
c.res.headers.set(name, value)
|
||||
}
|
||||
})
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -70,6 +70,43 @@ describe('img endpoints', () => {
|
||||
expect(await res.json()).toEqual({ service: 'img', status: 'ok' })
|
||||
})
|
||||
|
||||
// An upload keeps whatever content-type the uploader claimed, so a stored HTML
|
||||
// document would otherwise render — and run — on this origin. Every response says
|
||||
// `nosniff` and carries a sandboxing CSP so the body is inert whatever it is.
|
||||
it('serves every response with headers that stop a stored HTML upload executing', async () => {
|
||||
const HTML_KEY = 'not-an-image.jpg'
|
||||
await env.IMAGES.put(HTML_KEY, '<script>alert(1)</script>', {
|
||||
httpMetadata: { contentType: 'text/html' },
|
||||
})
|
||||
|
||||
const html = await SELF.fetch(`${ORIGIN}/${HTML_KEY}`)
|
||||
expect(html.status).toBe(200)
|
||||
expect(html.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
expect(html.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox")
|
||||
|
||||
// The stored object, the static asset, the fallback, a resize, and a 304.
|
||||
const paths = [
|
||||
`/${R2_KEY}`,
|
||||
`/${CDN_NAME}`,
|
||||
'/does-not-exist-anywhere.jpg',
|
||||
'/3DCharades.jpg?width=128',
|
||||
'/',
|
||||
]
|
||||
for (const path of paths) {
|
||||
const res = await SELF.fetch(`${ORIGIN}${path}`)
|
||||
expect(res.status, path).toBe(200)
|
||||
expect(res.headers.get('x-content-type-options'), path).toBe('nosniff')
|
||||
expect(res.headers.get('content-security-policy'), path).toBe("default-src 'none'; sandbox")
|
||||
}
|
||||
|
||||
const etag = (await SELF.fetch(`${ORIGIN}/${R2_KEY}`)).headers.get('etag')
|
||||
const notModified = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, {
|
||||
headers: { 'if-none-match': etag! },
|
||||
})
|
||||
expect(notModified.status).toBe(304)
|
||||
expect(notModified.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
})
|
||||
|
||||
it('streams an image stored in R2 with its content type', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
Reference in New Issue
Block a user