mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[studio] tweaks for old unity multipart handling unquoted boundaries
This commit is contained in:
@@ -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<string, string | File>
|
||||||
|
|
||||||
|
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<string, string> {
|
||||||
|
const params = new Map<string, string>()
|
||||||
|
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<FormFields> {
|
||||||
|
const boundary = boundaryOf(req.headers.get('content-type'))
|
||||||
|
if (boundary == null) return {}
|
||||||
|
const bytes = new Uint8Array(await req.arrayBuffer())
|
||||||
|
return parseParts(bytes, boundary)
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@ import { withNotFound, withOnError } from '@repo/hono-helpers'
|
|||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
import { listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
import { listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
||||||
|
import { parseMultipart } from './multipart'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
import type { FormFields } from './multipart'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A query id the editor already validated as a positive integer. Anything else is
|
* A query id the editor already validated as a positive integer. Anything else is
|
||||||
@@ -55,7 +57,7 @@ function rejected(c: Context<App>, status: 400 | 403 | 404, error: string) {
|
|||||||
return c.json({ success: false, error, value: null }, status)
|
return c.json({ success: false, error, value: null }, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formText(form: Record<string, unknown>, name: string): string | undefined {
|
function formText(form: FormFields, name: string): string | undefined {
|
||||||
const value = form[name]
|
const value = form[name]
|
||||||
return typeof value === 'string' ? value : undefined
|
return typeof value === 'string' ? value : undefined
|
||||||
}
|
}
|
||||||
@@ -68,7 +70,7 @@ function bundleFilename(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bundlePart(
|
async function bundlePart(
|
||||||
form: Record<string, unknown>,
|
form: FormFields,
|
||||||
field: string,
|
field: string,
|
||||||
platform: 'windows' | 'android',
|
platform: 'windows' | 'android',
|
||||||
kind: 'main' | 'stripped'
|
kind: 'main' | 'stripped'
|
||||||
@@ -97,7 +99,9 @@ async function postLocalCloudBuild(c: Context<App>) {
|
|||||||
// Same claim the editor treats as Full Studio access. Developer does not confer it.
|
// Same claim the editor treats as Full Studio access. Developer does not confer it.
|
||||||
if (!roles.includes('betastudio')) return rejected(c, 403, NOT_AUTHORIZED)
|
if (!roles.includes('betastudio')) return rejected(c, 403, NOT_AUTHORIZED)
|
||||||
|
|
||||||
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
// 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'))
|
const roomId = positiveId(formText(form, 'roomId'))
|
||||||
if (roomId == null) return rejected(c, 404, 'No such room.')
|
if (roomId == null) return rejected(c, 404, 'No such room.')
|
||||||
|
|||||||
@@ -434,4 +434,59 @@ describe('local cloud builds', () => {
|
|||||||
expect(third.value.createdByAccountId).toBe(77)
|
expect(third.value.createdByAccountId).toBe(77)
|
||||||
expect(await studioFlag(LOCAL_ROOM)).toBe(first.value.startedAt)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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({})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user