diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts index 7d9cc6b..bc85312 100644 --- a/apps/img/src/img.app.ts +++ b/apps/img/src/img.app.ts @@ -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() .use( '*', @@ -313,6 +327,12 @@ const app = new Hono() 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()) diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts index 523771a..e22fd1a 100644 --- a/apps/img/src/test/integration/api.test.ts +++ b/apps/img/src/test/integration/api.test.ts @@ -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, '', { + 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)