diff --git a/apps/studio/src/multipart.ts b/apps/studio/src/multipart.ts new file mode 100644 index 0000000..66fa7e2 --- /dev/null +++ b/apps/studio/src/multipart.ts @@ -0,0 +1,130 @@ +/** + * A multipart/form-data parser that tolerates what the Studio editor actually sends. + * + * The editor writes `Content-Disposition: form-data; name=roomId; filename=a.assetbundle` + * with the parameter values UNQUOTED. RFC 7578 wants quotes, and the runtime's own + * parser (`Request.formData()`, and so Hono's `parseBody`) throws + * "Content-Disposition header in FormData part is missing a name" on the first such + * part. Caught and defaulted, that read as an empty form and the upload was refused + * with "No such room." Every field is parsed here instead, quoted or bare. + */ + +export type FormFields = Record + +const CRLF = new Uint8Array([13, 10]) +const HEADER_END = new Uint8Array([13, 10, 13, 10]) +const DASHES = new Uint8Array([45, 45]) + +function indexOf(haystack: Uint8Array, needle: Uint8Array, from: number): number { + const first = needle[0] + const last = haystack.length - needle.length + outer: for (let i = from; i <= last; i++) { + if (haystack[i] !== first) continue + for (let j = 1; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer + } + return i + } + return -1 +} + +function startsWith(bytes: Uint8Array, prefix: Uint8Array, at: number): boolean { + if (at + prefix.length > bytes.length) return false + for (let i = 0; i < prefix.length; i++) if (bytes[at + i] !== prefix[i]) return false + return true +} + +function boundaryOf(contentType: string | null): string | null { + const match = contentType?.match(/multipart\/form-data\s*;.*?boundary=(?:"([^"]+)"|([^;\s]+))/i) + return match ? (match[1] ?? match[2] ?? null) : null +} + +/** + * `; name=value`, `; name="value"`, with `\"` escapes inside quotes. A bare value runs + * to the next `;`, which is how the editor's unquoted filenames end. + */ +function dispositionParams(header: string): Map { + const params = new Map() + const re = /;\s*([^=;\s]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;]*))/g + for (const m of header.matchAll(re)) { + const key = m[1].toLowerCase() + const value = m[2] != null ? m[2].replace(/\\(.)/g, '$1') : (m[3] ?? '').trim() + params.set(key, value) + } + return params +} + +/** RFC 5987 `utf-8''percent%20encoded`; a value that fails to decode is left as is. */ +function extendedValue(raw: string): string { + const m = raw.match(/^(?:utf-8|utf8)'[^']*'(.*)$/i) + if (!m) return raw + try { + return decodeURIComponent(m[1]) + } catch { + return m[1] + } +} + +function headerValue(headers: string, name: string): string | null { + const re = new RegExp(`^${name}\\s*:\\s*(.*)$`, 'im') + const m = headers.match(re) + return m ? m[1].trim() : null +} + +function parseParts(bytes: Uint8Array, boundary: string): FormFields { + const fields: FormFields = {} + const delimiter = new TextEncoder().encode(`--${boundary}`) + const decoder = new TextDecoder() + + // The first delimiter has no CRLF before it; every later one is `CRLF--boundary`. + let pos = indexOf(bytes, delimiter, 0) + if (pos !== 0 && !(pos > 0 && startsWith(bytes, CRLF, pos - 2))) return fields + pos += delimiter.length + + while (pos < bytes.length) { + if (startsWith(bytes, DASHES, pos)) break + // Skip transport padding up to the CRLF that ends the delimiter line. + const lineEnd = indexOf(bytes, CRLF, pos) + if (lineEnd < 0) break + const headersStart = lineEnd + 2 + const headersEnd = indexOf(bytes, HEADER_END, headersStart) + if (headersEnd < 0) break + const contentStart = headersEnd + 4 + // Content runs to the CRLF that precedes the next delimiter. + let next = indexOf(bytes, delimiter, contentStart) + while (next >= 0 && !startsWith(bytes, CRLF, next - 2)) { + next = indexOf(bytes, delimiter, next + delimiter.length) + } + if (next < 0) break + const content = bytes.subarray(contentStart, next - 2) + pos = next + delimiter.length + + const headers = decoder.decode(bytes.subarray(headersStart, headersEnd)) + const disposition = headerValue(headers, 'content-disposition') + if (disposition == null) continue + const params = dispositionParams(disposition) + const name = params.get('name') + if (name == null || name === '') continue + const filenameExt = params.get('filename*') + const filename = filenameExt != null ? extendedValue(filenameExt) : params.get('filename') + if (filename != null) { + const type = headerValue(headers, 'content-type') ?? 'application/octet-stream' + fields[name] = new File([content.slice()], filename, { type }) + } else { + fields[name] = decoder.decode(content) + } + } + return fields +} + +/** + * Parse the request body as multipart/form-data. Text parts are strings, parts with a + * filename are Files. A later part with the same name replaces an earlier one. A + * request that is not multipart, or has no boundary, parses as no fields. + */ +export async function parseMultipart(req: Request): Promise { + const boundary = boundaryOf(req.headers.get('content-type')) + if (boundary == null) return {} + const bytes = new Uint8Array(await req.arrayBuffer()) + return parseParts(bytes, boundary) +} diff --git a/apps/studio/src/studio.app.ts b/apps/studio/src/studio.app.ts index a2848db..9ad2499 100644 --- a/apps/studio/src/studio.app.ts +++ b/apps/studio/src/studio.app.ts @@ -11,9 +11,11 @@ import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' import { listCloudBuilds, storeLocalCloudBuild } from './local-builds' +import { parseMultipart } from './multipart' import type { Context } from 'hono' import type { App } from './context' +import type { FormFields } from './multipart' /** * A query id the editor already validated as a positive integer. Anything else is @@ -55,7 +57,7 @@ function rejected(c: Context, status: 400 | 403 | 404, error: string) { return c.json({ success: false, error, value: null }, status) } -function formText(form: Record, name: string): string | undefined { +function formText(form: FormFields, name: string): string | undefined { const value = form[name] return typeof value === 'string' ? value : undefined } @@ -68,7 +70,7 @@ function bundleFilename(name: string): string { } async function bundlePart( - form: Record, + form: FormFields, field: string, platform: 'windows' | 'android', kind: 'main' | 'stripped' @@ -97,7 +99,9 @@ async function postLocalCloudBuild(c: Context) { // Same claim the editor treats as Full Studio access. Developer does not confer it. if (!roles.includes('betastudio')) return rejected(c, 403, NOT_AUTHORIZED) - const form = await c.req.parseBody().catch(() => ({}) as Record) + // Not `c.req.parseBody()`: the editor leaves its form-data names unquoted, which the + // runtime's parser rejects outright. See multipart.ts. + const form = await parseMultipart(c.req.raw) const roomId = positiveId(formText(form, 'roomId')) if (roomId == null) return rejected(c, 404, 'No such room.') diff --git a/apps/studio/src/test/integration/api.test.ts b/apps/studio/src/test/integration/api.test.ts index c49c2f1..6f97d44 100644 --- a/apps/studio/src/test/integration/api.test.ts +++ b/apps/studio/src/test/integration/api.test.ts @@ -434,4 +434,59 @@ describe('local cloud builds', () => { expect(third.value.createdByAccountId).toBe(77) expect(await studioFlag(LOCAL_ROOM)).toBe(first.value.startedAt) }) + + // The editor writes `name=roomId` and `filename=…` with NO quotes, which the runtime's + // own parser rejects. Build the body by hand, byte for byte like the capture, rather + // than through FormData, which would quote them and hide the bug. + it("accepts the editor's unquoted form-data names", async () => { + const boundary = 'ad9b9597-d65d-4eb1-a989-9fb7bd7ee764' + const text = (name: string, value: string) => + `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n` + + `Content-Disposition: form-data; name=${name}\r\n\r\n${value}\r\n` + const file = (name: string, filename: string) => + `--${boundary}\r\nContent-Type: application/octet-stream\r\n` + + `Content-Disposition: form-data; name=${name}; filename=${filename}; filename*=utf-8''${filename}\r\n\r\n` + const enc = new TextEncoder() + // Bundle bytes include CR, LF and `--` so the part scan has to honour the delimiter. + const winBytes = [85, 110, 105, 116, 121, 70, 83, 13, 10, 45, 45, 0, 255] + const droidBytes = [85, 110, 105, 116, 121, 70, 83, 0, 13, 10] + const body = new Blob([ + enc.encode(text('roomId', String(LOCAL_ROOM))), + enc.encode(text('subRoomId', String(LOCAL_SUB))), + enc.encode(file('windows', '87562fb63c5c468cbb055e6ee3102e0f.WindowsDesktop.assetbundle')), + new Uint8Array(winBytes), + enc.encode( + `\r\n${file('android', '87562fb63c5c468cbb055e6ee3102e0f.MobileAndroid.assetbundle')}` + ), + new Uint8Array(droidBytes), + enc.encode(`\r\n--${boundary}--\r\n`), + ]) + const res = await SELF.fetch(`${ORIGIN}/cloud-builds/from-editor`, { + method: 'POST', + headers: { + ...(await bearer('42', STUDIO_ROLES)), + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + body, + }) + expect(res.status).toBe(200) + const created = (await res.json()) as { + success: boolean + value: { unityAssetId: string } + } + expect(created.success).toBe(true) + + const { results: files } = await env.DB.prepare( + `SELECT platform, byte_length, r2_key FROM studio_unity_asset_file + WHERE unity_asset_id = ?1 AND kind = 'main' ORDER BY platform` + ) + .bind(created.value.unityAssetId) + .all<{ platform: string; byte_length: number; r2_key: string }>() + expect(files.map((f) => [f.platform, f.byte_length])).toEqual([ + ['android', droidBytes.length], + ['windows', winBytes.length], + ]) + const win = await env.CDN_ASSETS.get(files[1]!.r2_key) + expect([...new Uint8Array(await win!.arrayBuffer())]).toEqual(winBytes) + }) }) diff --git a/apps/studio/src/test/multipart.test.ts b/apps/studio/src/test/multipart.test.ts new file mode 100644 index 0000000..31c7466 --- /dev/null +++ b/apps/studio/src/test/multipart.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { parseMultipart } from '../multipart' + +const B = 'xyz' + +function request(body: BodyInit, contentType = `multipart/form-data; boundary=${B}`): Request { + return new Request('https://example.com/', { + method: 'POST', + headers: { 'content-type': contentType }, + body, + }) +} + +describe('parseMultipart', () => { + it('reads unquoted and quoted names alike', async () => { + const raw = + `--${B}\r\nContent-Disposition: form-data; name=a\r\n\r\n1\r\n` + + `--${B}\r\nContent-Disposition: form-data; name="b"\r\n\r\n2\r\n` + + `--${B}--\r\n` + expect(await parseMultipart(request(raw))).toEqual({ a: '1', b: '2' }) + }) + + it('reads a file part with bare filename and filename*', async () => { + const raw = + `--${B}\r\nContent-Type: application/octet-stream\r\n` + + `Content-Disposition: form-data; name=windows; filename=x.assetbundle; filename*=utf-8''x%20y.assetbundle\r\n\r\n` + + `bytes\r\n--${B}--\r\n` + const fields = await parseMultipart(request(raw)) + const file = fields.windows + expect(file).toBeInstanceOf(File) + expect((file as File).name).toBe('x y.assetbundle') + expect((file as File).type).toBe('application/octet-stream') + expect(await (file as File).text()).toBe('bytes') + }) + + it('matches the runtime parser on a FormData body', async () => { + const fd = new FormData() + fd.append('roomId', '1049') + fd.append('windows', new File([new Uint8Array([1, 2, 3])], 'w.assetbundle')) + const req = new Request('https://example.com/', { method: 'POST', body: fd }) + const fields = await parseMultipart(req) + expect(fields.roomId).toBe('1049') + expect([...new Uint8Array(await (fields.windows as File).arrayBuffer())]).toEqual([1, 2, 3]) + }) + + it('keeps bytes that look like a delimiter but lack the leading CRLF', async () => { + const enc = new TextEncoder() + const payload = enc.encode(`x--${B}\r\nnot a delimiter`) + const body = new Blob([ + enc.encode(`--${B}\r\nContent-Disposition: form-data; name=f; filename=f\r\n\r\n`), + payload, + enc.encode(`\r\n--${B}--\r\n`), + ]) + const fields = await parseMultipart(request(body)) + expect(await (fields.f as File).text()).toBe(`x--${B}\r\nnot a delimiter`) + }) + + it('is empty for a non-multipart body or a truncated one', async () => { + expect(await parseMultipart(request('a=1', 'application/x-www-form-urlencoded'))).toEqual({}) + expect( + await parseMultipart(request(`--${B}\r\nContent-Disposition: form-data; name=a\r\n\r\n1`)) + ).toEqual({}) + }) +})