[storage] upload hardening

This commit is contained in:
Devin Zuczek
2026-10-21 22:50:07 -04:00
parent ae360e1e83
commit 7abef376b6
11 changed files with 361 additions and 59 deletions
+10
View File
@@ -3,8 +3,10 @@ import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
applyNoExecuteHeaders,
withCleanSpec,
withDefaultCors,
withNoExecute,
withNotFound,
withOnError,
writeContentRange,
@@ -167,6 +169,13 @@ const app = new Hono<App>()
// unchanged by who may read a response they already had to name exactly.
.use('*', withDefaultCors())
// The blob routes force `application/octet-stream` over whatever type an upload was
// stored with, which is what keeps a stored HTML body from rendering; `nosniff` makes
// that type binding on the browser, and the sandboxing CSP covers the config files,
// which go out under their own types. The hand-rolled HEAD below is outside this chain
// and stamps the same headers itself.
.use('*', withNoExecute())
.onError(withOnError())
.notFound(withNotFound())
@@ -458,6 +467,7 @@ async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<
headers.set('accept-ranges', 'bytes')
headers.set('cache-control', CACHE_CONTROL)
headers.set('access-control-allow-origin', '*')
applyNoExecuteHeaders(headers)
const response = new Response(null, { status: 200, headers, encodeBody: 'manual' })
response.headers.set('content-length', String(head.size))
return response
+32
View File
@@ -19,6 +19,38 @@ describe('cdn endpoints', () => {
expect(await res.json()).toEqual({ service: 'cdn', status: 'ok' })
})
// A stored blob carries whatever type it was uploaded with; the routes force
// octet-stream, and these headers make that binding and sandbox anything that is served
// under its own type (the configs). The hand-rolled HEAD sits outside Hono and must
// carry them too.
test('every response carries headers that stop a stored document executing', async () => {
await env.CDN_ASSETS.put('data/a-stored-page', '<script>alert(1)</script>', {
httpMetadata: { contentType: 'text/html' },
})
for (const path of [
'/',
'/config/LoadingScreenTipData',
'/config/RRPlusConfig_v3',
'/data/a-stored-page',
]) {
const res = await exports.default.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 blob = await exports.default.fetch(`${ORIGIN}/data/a-stored-page`)
expect(blob.headers.get('content-type')).toBe('application/octet-stream')
await env.CDN_ASSETS.put('unityasset/plain.assetbundle', new Uint8Array([1, 2, 3]))
const head = await exports.default.fetch(`${ORIGIN}/unityasset/plain.assetbundle`, {
method: 'HEAD',
})
expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('3')
expect(head.headers.get('x-content-type-options')).toBe('nosniff')
expect(head.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox")
})
test('GET /config/LoadingScreenTipData returns the tip array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/config/LoadingScreenTipData`)
expect(res.status).toBe(200)