mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[storage] upload hardening
This commit is contained in:
@@ -4,6 +4,8 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
intVar,
|
||||
SNIFFED_IMAGE_FORMATS,
|
||||
sniffImage,
|
||||
withCleanSpec,
|
||||
withDefaultCors,
|
||||
withNotFound,
|
||||
@@ -309,7 +311,10 @@ const app = new Hono<App>()
|
||||
'`<upload-date>/<random-name>` part the client references it by (the same name the',
|
||||
'`cdn` worker serves back). The subfolder comes from `FileType`; RoomSave (1) lands',
|
||||
'under `room/` so the cdn worker’s `GET /room/:dataBlob` finds it, and an Invention',
|
||||
'(5) keeps a `.inv` extension on both the key and the returned name. Auth-gated —',
|
||||
'(5) keeps a `.inv` extension on both the key and the returned name. An Image (3) is',
|
||||
'identified by its bytes — it must be a PNG, JPEG, GIF, WebP or BMP, and is stored under',
|
||||
'that content-type rather than the part’s claimed one — because the `img` worker serves',
|
||||
'it back under the stored type; anything else is refused with a 400. Auth-gated —',
|
||||
'any valid account token is allowed, no role check; staff (developer/moderator) skip the size limit. A post with no binary part but',
|
||||
'an explicit `imageName` / `filename` / `name` just echoes that name back. Mirrors',
|
||||
'the reference server’s `Upload`.',
|
||||
@@ -318,7 +323,10 @@ const app = new Hono<App>()
|
||||
requestBody: UPLOAD_REQUEST_BODY,
|
||||
responses: {
|
||||
200: json(UploadResponse, 'The stored (or echoed) file name'),
|
||||
400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'),
|
||||
400: json(
|
||||
ErrorResponse,
|
||||
'Unknown/missing FileType, an Image (3) that is not an image, or neither a file nor a name'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
413: json(
|
||||
ErrorResponse,
|
||||
@@ -360,8 +368,19 @@ const app = new Hono<App>()
|
||||
const datePrefix = new Date().toISOString().slice(0, 10)
|
||||
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
|
||||
const bytes = await file.arrayBuffer()
|
||||
// An image is served back by the `img` worker under the stored content-type, so
|
||||
// the type comes from the bytes, not the part's claim — and a body that is not
|
||||
// an image is refused rather than stored as a document on the image origin.
|
||||
let contentType = file.type || 'application/octet-stream'
|
||||
if (subfolder === 'image') {
|
||||
const image = sniffImage(new Uint8Array(bytes))
|
||||
if (!image) {
|
||||
return c.json({ error: `file is not a ${SNIFFED_IMAGE_FORMATS} image` }, 400)
|
||||
}
|
||||
contentType = image.contentType
|
||||
}
|
||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
||||
httpMetadata: { contentType: file.type || 'application/octet-stream' },
|
||||
httpMetadata: { contentType },
|
||||
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
|
||||
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
|
||||
// only a checksum given at put time is readable later — this lets the `api`
|
||||
|
||||
@@ -9,6 +9,9 @@ declare module 'cloudflare:test' {
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
/** The PNG signature — what an Image (FileType 3) upload must at least begin with. */
|
||||
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
@@ -96,7 +99,8 @@ it('POST /upload folders each FileType under its own subfolder', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: uploadForm(fileType, new Uint8Array([1, 2, 3])),
|
||||
// An image has to look like one; the other types take any bytes.
|
||||
body: uploadForm(fileType, subfolder === 'image' ? PNG_BYTES : new Uint8Array([1, 2, 3])),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { filename } = (await res.json()) as { filename: string }
|
||||
@@ -161,17 +165,62 @@ it('POST /upload lets staff upload past the size limit', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/upload`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42', ['gameClient', role]),
|
||||
body: uploadForm('3', new Uint8Array([1, 2, 3, 4])),
|
||||
body: uploadForm('3', PNG_BYTES),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { filename } = (await res.json()) as { filename: string }
|
||||
expect((await env.CDN_ASSETS.get(`image/${filename}`))?.size).toBe(4)
|
||||
expect((await env.CDN_ASSETS.get(`image/${filename}`))?.size).toBe(PNG_BYTES.length)
|
||||
}
|
||||
} finally {
|
||||
env.MAX_UPLOAD_BYTES = original
|
||||
}
|
||||
})
|
||||
|
||||
// An Image (3) is served back by the `img` worker under the stored content-type, so the
|
||||
// type comes from the bytes rather than the part's claim, and a non-image is refused —
|
||||
// otherwise an HTML body would be a page on the image origin.
|
||||
it('POST /upload refuses an Image (FileType 3) whose bytes are not an image', async () => {
|
||||
const form = new FormData()
|
||||
form.set('FileType', '3')
|
||||
form.set('File', new File(['<script>alert(1)</script>'], 'photo.jpg', { type: 'image/jpeg' }))
|
||||
const res = await SELF.fetch(`${ORIGIN}/upload`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
body: form,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'file is not a PNG, JPEG, GIF, WebP or BMP image' })
|
||||
})
|
||||
|
||||
it('POST /upload stores an Image (FileType 3) under the type its bytes say, not the claim', async () => {
|
||||
const form = new FormData()
|
||||
form.set('FileType', '3')
|
||||
form.set('File', new File([PNG_BYTES], 'photo.html', { type: 'text/html' }))
|
||||
const res = await SELF.fetch(`${ORIGIN}/upload`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
body: form,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { filename } = (await res.json()) as { filename: string }
|
||||
const stored = await env.CDN_ASSETS.get(`image/${filename}`)
|
||||
expect(stored?.httpMetadata?.contentType).toBe('image/png')
|
||||
|
||||
// Other types keep the claimed content-type: they are not served as images.
|
||||
const blob = new FormData()
|
||||
blob.set('FileType', '2')
|
||||
blob.set(
|
||||
'File',
|
||||
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'application/octet-stream' })
|
||||
)
|
||||
const other = await SELF.fetch(`${ORIGIN}/upload`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
body: blob,
|
||||
})
|
||||
expect(other.status).toBe(200)
|
||||
})
|
||||
|
||||
it('POST /upload echoes an explicit name when no binary is posted', async () => {
|
||||
const form = new FormData()
|
||||
form.set('FileType', '3')
|
||||
|
||||
Reference in New Issue
Block a user