[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
+25 -9
View File
@@ -13,6 +13,7 @@ import {
MAX_BULK_OUTFIT_ACCOUNTS,
setOutfit,
} from '@repo/domain'
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
import {
createCustomAvatarItem,
@@ -473,13 +474,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Create a custom avatar item',
description:
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
'(PNG) and `design` (the design PNG). Inserts a `custom_avatar_item` row owned ' +
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
'error_id }` envelope. The item is filed under `OutfitType` 105, the custom shirt, ' +
'which is what the store’s user-generated-content tab searches for.\n\n' +
'The two files go to the shared image bucket (`recflare-img`) under ' +
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.\n\n' +
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row. Each file is ' +
'identified by its bytes — the client posts them under `.bin` names — and must be a ' +
'PNG, JPEG, GIF, WebP or BMP; the key’s extension and the stored content-type come ' +
'from that signature, and anything else is refused with a 400 before either file is ' +
'written.\n\n' +
'`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' +
'0, is refused with a 400 rather than clamped.',
security: AUTHED,
@@ -488,7 +493,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
200: json(CustomAvatarItemResponse, 'The created item'),
400: json(
CustomAvatarItemResponse,
'Missing or malformed metadata / files, or a `Price` outside 0–1000'
'Missing or malformed metadata / files, a file that is not an image, or a `Price` outside 0–1000'
),
401: UNAUTHORIZED_RESPONSE,
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
@@ -550,19 +555,30 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
}
// The parts' filenames and content-types are the uploader's claims (the client
// posts both as `.bin`); the `img` worker serves each back under whatever type is
// stored, so the extension and the stored type come from the bytes. Both are
// checked before either is written, so a refusal leaves nothing in R2.
const thumbnailBytes = new Uint8Array(await body.thumbnailImage.arrayBuffer())
const thumbnail = sniffImage(thumbnailBytes)
if (!thumbnail) return fail(`thumbnailImage is not a ${SNIFFED_IMAGE_FORMATS} image`)
const designBytes = new Uint8Array(await body.design.arrayBuffer())
const design = sniffImage(designBytes)
if (!design) return fail(`design is not a ${SNIFFED_IMAGE_FORMATS} image`)
// Both files go to the shared image bucket, foldered by upload date and keyed by
// the item's id (chosen here so the keys can carry it). The `img` worker serves
// them back by key.
const customAvatarItemId = crypto.randomUUID()
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
const thumbnailImageFilename = `${prefix}-thumb.png`
const designFilename = `${prefix}-design.png`
const thumbnailImageFilename = `${prefix}-thumb${thumbnail.extension}`
const designFilename = `${prefix}-design${design.extension}`
await Promise.all([
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
c.env.IMAGES.put(thumbnailImageFilename, thumbnailBytes, {
httpMetadata: { contentType: thumbnail.contentType },
}),
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
httpMetadata: { contentType: body.design.type || 'image/png' },
c.env.IMAGES.put(designFilename, designBytes, {
httpMetadata: { contentType: design.contentType },
}),
])
+16 -10
View File
@@ -22,6 +22,7 @@ import {
SLIDESHOW_MAX_LIMIT,
toImagesPlayer,
} from '@repo/domain'
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
import { authedId, unauthorized } from '../http'
import {
@@ -217,12 +218,17 @@ export const imageRoutes = new Hono<App>({ strict: false })
'The `imgMeta` multipart field is a JSON `SavedImageMetaDTO` describing the upload; ' +
'malformed JSON is tolerated and the image is still stored, just untyped. A ' +
'`savedImageType` of 4 (profile thumbnail) additionally becomes the account’s ' +
'avatar, persisted on the account row.',
'avatar, persisted on the account row.\n\n' +
'The file is identified by its bytes, not by the part’s filename or content-type: ' +
'it must be a PNG, JPEG, GIF, WebP or BMP, and the stored key’s extension and ' +
'content-type come from that signature. Anything else is refused with a 400 — the ' +
'`img` worker serves the object back under its stored type, so an HTML body under ' +
'an image name would otherwise be a page on the image origin.',
security: AUTHED,
requestBody: form(UploadImageRequest, 'The image file plus its metadata'),
responses: {
200: json(UploadImageResponse, 'The stored bucket key'),
400: json(ErrorResponse, 'No file in the request'),
400: json(ErrorResponse, 'No file in the request, or the file is not an image'),
401: UNAUTHORIZED_RESPONSE,
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
},
@@ -262,10 +268,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
const roomId = num(meta.roomId)
const playerEventId = num(meta.playerEventId)
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
const dot = file.name.lastIndexOf('.')
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
const extension = valid.includes(ext) ? ext : '.jpg'
// The part's filename and content-type are the uploader's claims; the `img` worker
// serves the object back under whatever type is stored, so both the extension and
// the stored type come from the bytes. A body that is not an image is refused.
const bytes = new Uint8Array(await file.arrayBuffer())
const image = sniffImage(bytes)
if (!image) return c.json({ error: `image is not a ${SNIFFED_IMAGE_FORMATS} image` }, 400)
// Store the upload in the shared image bucket under a random key, foldered by
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
@@ -273,10 +281,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
// key (slashes and all), which is the returned ImageName.
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'image/jpeg' },
})
const name = typePrefix + datePrefix + crypto.randomUUID() + image.extension
await c.env.IMAGES.put(name, bytes, { httpMetadata: { contentType: image.contentType } })
// A profile thumbnail becomes the account's avatar — persist it on the
// account row (a JSON blob in the shared accounts table) so it sticks.
+91 -13
View File
@@ -95,6 +95,9 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
/** The PNG signature — the least an upload route accepts as an image. */
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
// `/api/rooms/v1/verifyRole` reads room roles from the shared recflare D1. Set
// up the schema (matching the rooms worker's migration) + a couple of rooms.
const TEST_ROOMS = [
@@ -1788,8 +1791,8 @@ describe('public endpoints', () => {
...metadata,
})
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 'f.bin', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'f.bin', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 'f.bin', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'f.bin', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer(creator),
@@ -4966,11 +4969,8 @@ describe('custom avatar items', () => {
Accessibility: 0,
})
)
form.set(
'thumbnailImage',
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'image/png' })
)
form.set('design', new File([new Uint8Array([4, 5, 6])], 'file.bin', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
@@ -5113,8 +5113,8 @@ describe('custom avatar items', () => {
'metadata',
JSON.stringify({ Name: 'Pricey', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff', Price })
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 't.png', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'd.png', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 't.png', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'd.png', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
@@ -5253,8 +5253,8 @@ describe('custom avatar items', () => {
'metadata',
JSON.stringify({ Name: 'Gone', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 'file.bin', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'file.bin', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
const created = (await (
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
@@ -5359,6 +5359,48 @@ describe('custom avatar items', () => {
}
})
// Both files are identified by their bytes — the client posts them as `.bin` under
// `image/png` — and the `img` worker serves each under its stored type, so a non-image
// is refused before either file or the row is written.
test('POST refuses a file whose bytes are not an image, writing nothing', async () => {
const objectsBefore = (await env.IMAGES.list({ prefix: 'avatar-item/' })).objects.length
const upload = async (thumbnail: string | Uint8Array, design: string | Uint8Array) => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'not-image', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([thumbnail], 'f.bin', { type: 'image/png' }))
form.set('design', new File([design], 'f.bin', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
}
const html = '<script>alert(1)</script>'
const badThumbnail = await upload(html, PNG_BYTES)
expect(badThumbnail.status).toBe(400)
expect(await badThumbnail.json()).toMatchObject({
Success: false,
Error: 'thumbnailImage is not a PNG, JPEG, GIF, WebP or BMP image',
})
const badDesign = await upload(PNG_BYTES, html)
expect(badDesign.status).toBe(400)
expect(await badDesign.json()).toMatchObject({
Success: false,
Error: 'design is not a PNG, JPEG, GIF, WebP or BMP image',
})
const row = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'not-image'"
).first<{ n: number }>()
expect(row?.n).toBe(0)
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(objectsBefore)
})
test('POST 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
@@ -6884,7 +6926,7 @@ describe('rooms', () => {
describe('images', () => {
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
const bytes = new Uint8Array([...PNG_BYTES, 1, 2, 3, 4])
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
@@ -6917,6 +6959,42 @@ describe('images', () => {
expect(meta.CheerCount).toBe(0)
})
// The part's filename and content-type are the uploader's claims, and the `img`
// worker serves the object under whatever type is stored — so an HTML body under an
// image name would be a page on the image origin. The bytes decide, and only images
// get in.
test('POST /api/images/v4/uploadsaved refuses a file whose bytes are not an image', async () => {
const objectsBefore = (await env.IMAGES.list()).objects.length
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 }))
fd.append('image', new File(['<script>alert(1)</script>'], 'photo.jpg', { type: 'image/jpeg' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer(),
body: fd,
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'image is not a PNG, JPEG, GIF, WebP or BMP image' })
expect((await env.IMAGES.list()).objects).toHaveLength(objectsBefore)
})
test('POST /api/images/v4/uploadsaved stores the type and extension the bytes say, not the claim', async () => {
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 }))
// A real PNG posted as a JPEG under an HTML name.
fd.append('image', new File([PNG_BYTES], 'photo.html', { type: 'text/html' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer(),
body: fd,
})
expect(res.status).toBe(200)
const { ImageName } = (await res.json()) as { ImageName: string }
expect(ImageName).toMatch(/\.png$/)
const stored = await env.IMAGES.get(ImageName)
expect(stored?.httpMetadata?.contentType).toBe('image/png')
})
test('GET /api/images/v1/slideshow is public and joins username + room name', async () => {
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
@@ -7378,7 +7456,7 @@ describe('images', () => {
accessibility: 2,
})
)
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' }))
fd.append('image', new File([PNG_BYTES], 'pic.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer('42'),