diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index adb9619bf..90220f4f5 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -443,6 +443,8 @@ export const action = defineHandler(async (c) => { }); ``` +`withValidator({ body })` accepts the same upload. Nested names such as `profile[name]`, `tags[0]`, and `tags[]` are objects and arrays again. A multipart schema has to coerce the rest: booleans arrive as `'1'` or `'0'`, numbers and dates as strings, and `null` as `''`. + Pick the storage key on the server and allow only the file types you expect. See [Serving Files](../storage.md#serving-files) to send them back safely. ## Choosing a Primitive diff --git a/packages/void/src/runtime/validator.ts b/packages/void/src/runtime/validator.ts index 6a1c34424..f51ccd7ac 100644 --- a/packages/void/src/runtime/validator.ts +++ b/packages/void/src/runtime/validator.ts @@ -45,6 +45,172 @@ async function validateSlot( return { ok: true, value: result.value }; } +type FormEntry = string | File; + +const MAX_FORM_DEPTH = 32; +const MAX_FORM_NODES = 10_000; +const MAX_FORM_INDEX = 10_000; +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function isFormContentType(contentType: string | undefined): boolean { + const mediaType = contentType?.split(';', 1)[0]?.trim().toLowerCase(); + return mediaType === 'multipart/form-data' || mediaType === 'application/x-www-form-urlencoded'; +} + +/** + * `objectToFormData` writes nested fields as `a[b]`, array items as `a[0]`, + * and file lists as `a[]`. Split one of those keys into path segments. + * A key that is not entirely bracket-encoded stays a single literal segment. + */ +function formKeySegments(key: string): Array { + const bracket = key.indexOf('['); + if (bracket <= 0 || !key.endsWith(']')) { + return [key]; + } + + const segments = [key.slice(0, bracket)]; + const rest = key.slice(bracket); + const pattern = /\[([^[\]]*)\]/g; + let consumed = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(rest)) !== null) { + if (match.index !== consumed) { + return [key]; + } + segments.push(match[1] ?? ''); + consumed = pattern.lastIndex; + if (segments.length > MAX_FORM_DEPTH) { + throw new Error('Form field is nested too deeply'); + } + } + if (consumed !== rest.length) { + return [key]; + } + return segments; +} + +function arrayIndex(segment: string): number | null { + if (segment !== '0' && !/^[1-9]\d*$/.test(segment)) { + return null; + } + if (segment.length > 5) { + throw new Error('Form field index is out of range'); + } + const index = Number(segment); + if (index > MAX_FORM_INDEX) { + throw new Error('Form field index is out of range'); + } + return index; +} + +function isContainerRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Blob) + ); +} + +function own(record: Record, key: string): unknown { + if (DANGEROUS_KEYS.has(key)) { + return Object.getOwnPropertyDescriptor(record, key)?.value; + } + return record[key]; +} + +function setOwn(record: Record, key: string, value: unknown): void { + if (DANGEROUS_KEYS.has(key)) { + Object.defineProperty(record, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + return; + } + record[key] = value; +} + +/** + * Rebuild the object `objectToFormData` flattened. Numeric segments and `[]` + * become arrays. Scalars are left as the client encoded them. + */ +function formRecordToObject( + record: Record>, +): Record { + const root: Record = {}; + let nodes = 0; + + const createContainer = (asArray: boolean): Record | Array => { + if (++nodes > MAX_FORM_NODES) { + throw new Error('Form field has too many nested values'); + } + return asArray ? [] : {}; + }; + + const read = (parent: Record | Array, segment: string): unknown => { + if (Array.isArray(parent)) { + return segment === '' ? undefined : parent[Number(segment)]; + } + return own(parent, segment); + }; + + const write = ( + parent: Record | Array, + segment: string, + value: unknown, + ): void => { + if (Array.isArray(parent)) { + if (segment === '') { + parent.push(value); + return; + } + const index = arrayIndex(segment); + if (index === null) { + throw new Error('Form field shape is inconsistent'); + } + parent[index] = value; + return; + } + setOwn(parent, segment, value); + }; + + const assign = (segments: Array, value: FormEntry): void => { + let parent: Record | Array = root; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i] as string; + const next = segments[i + 1] as string; + const nextIsArray = next === '' || arrayIndex(next) !== null; + const child = read(parent, segment); + const childIsContainer = nextIsArray ? Array.isArray(child) : isContainerRecord(child); + const container = childIsContainer + ? (child as Record | Array) + : createContainer(nextIsArray); + if (!childIsContainer) { + write(parent, segment, container); + } + parent = container; + } + write(parent, segments[segments.length - 1] as string, value); + }; + + for (const [key, raw] of Object.entries(record)) { + const segments = formKeySegments(key); + const values = Array.isArray(raw) ? raw : [raw]; + for (const value of values) { + assign(segments, value); + } + } + return root; +} + +async function readBody(c: CloudContext): Promise { + if (!isFormContentType(c.req.header('content-type'))) { + return c.req.json(); + } + // `[]` keys are arrays at runtime even when the declared return type is not. + const parsed = (await c.req.parseBody()) as Record>; + return formRecordToObject(parsed); +} + export async function runValidation( validators: V, c: CloudContext, @@ -82,12 +248,18 @@ export async function runValidation( if (validators.body) { let raw: unknown; + const formBody = isFormContentType(c.req.header('content-type')); try { - raw = await c.req.json(); + // Multipart and URL-encoded bodies are what `useForm` sends for files. + // JSON stays on `c.req.json()` so scalar types there are unchanged. + raw = await readBody(c); } catch { return { ok: false, - response: Response.json({ error: 'Invalid JSON body' }, { status: 400 }), + response: Response.json( + { error: formBody ? 'Invalid form body' : 'Invalid JSON body' }, + { status: 400 }, + ), }; } const result = await validateSlot(validators.body, raw); diff --git a/packages/void/test/unit/define-handler.test.ts b/packages/void/test/unit/define-handler.test.ts index 9edc6ba7b..14b3e5105 100644 --- a/packages/void/test/unit/define-handler.test.ts +++ b/packages/void/test/unit/define-handler.test.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import type { MiddlewareHandler } from 'hono'; import { describe, expect, it } from 'vite-plus/test'; +import { objectToFormData } from '../../src/pages/navigation-engine.ts'; import { defineHandler, defineMiddleware, @@ -350,4 +351,176 @@ describe('defineHandler.withValidator', () => { body: { name: 'Bob' }, }); }); + + it('passes a multipart file to the schema and the handler', async () => { + const photo = new File(['hello'], 'photo.png', { type: 'image/png' }); + let seenBySchema: unknown; + const handler = defineHandler.withValidator({ + body: { + '~standard': { + version: 1, + vendor: 'test', + validate(value: unknown) { + seenBySchema = value; + const uploaded = (value as { photo?: unknown }).photo; + if (!(uploaded instanceof File)) { + return { issues: [{ message: 'expected a file', path: ['photo'] }] }; + } + return { value }; + }, + }, + }, + })((_c, { body }) => { + const received = body as { title: string; photo: File }; + return { + title: received.title, + name: received.photo.name, + type: received.photo.type, + size: received.photo.size, + }; + }); + + const app = createValidatedApp('post', '/test', handler); + const res = await app.request( + new Request('http://localhost/test', { + method: 'POST', + body: objectToFormData({ title: 'Sunset', photo }), + }), + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + title: 'Sunset', + name: 'photo.png', + type: 'image/png', + size: photo.size, + }); + const parsed = seenBySchema as { title: string; photo: File }; + expect(parsed.title).toBe('Sunset'); + expect(parsed.photo).toBeInstanceOf(File); + expect(parsed.photo.name).toBe('photo.png'); + expect(await parsed.photo.text()).toBe('hello'); + }); + + it('restores nested bracket keys from form bodies', async () => { + const handler = defineHandler.withValidator({ + body: passSchema(), + })((_c, { body }) => ({ received: body })); + const app = createValidatedApp('post', '/test', handler); + const takenAt = new Date('2020-01-02T03:04:05.000Z'); + const multipart = objectToFormData({ + profile: { name: 'Ada' }, + tags: ['work', 'urgent'], + album: { photos: [{ name: 'a.png' }] }, + published: true, + count: 2, + takenAt, + note: null, + }); + multipart.append('photos[]', 'one'); + multipart.append('photos[]', 'two'); + multipart.append('__proto__[polluted]', 'yes'); + + const multipartRes = await app.request( + new Request('http://localhost/test', { method: 'POST', body: multipart }), + ); + expect(multipartRes.status).toBe(200); + expect(await multipartRes.json()).toEqual({ + received: { + profile: { name: 'Ada' }, + tags: ['work', 'urgent'], + album: { photos: [{ name: 'a.png' }] }, + published: '1', + count: '2', + takenAt: '2020-01-02T03:04:05.000Z', + note: '', + photos: ['one', 'two'], + ['__proto__']: { polluted: 'yes' }, + }, + }); + expect('polluted' in Object.prototype).toBe(false); + + const params = new URLSearchParams(); + params.append('profile[name]', 'Ada'); + params.append('tags[0]', 'work'); + params.append('tags[1]', 'urgent'); + params.append('photos[]', 'one'); + params.append('photos[]', 'two'); + const urlencodedRes = await app.request( + new Request('http://localhost/test', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }), + ); + expect(urlencodedRes.status).toBe(200); + expect(await urlencodedRes.json()).toEqual({ + received: { + profile: { name: 'Ada' }, + tags: ['work', 'urgent'], + photos: ['one', 'two'], + }, + }); + }); + + it('keeps JSON body validation unchanged', async () => { + const handler = defineHandler.withValidator({ + body: passSchema(), + })((_c, { body }) => ({ received: body })); + const app = createValidatedApp('post', '/test', handler); + + const res = await app.request( + new Request('http://localhost/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + profile: { name: 'Ada' }, + tags: ['work'], + published: true, + count: 2, + note: null, + }), + }), + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + received: { + profile: { name: 'Ada' }, + tags: ['work'], + published: true, + count: 2, + note: null, + }, + }); + }); + + it('does not report a bad form body as invalid JSON', async () => { + const handler = defineHandler.withValidator({ + body: failSchema('title is required'), + })(() => ({ ok: true })); + const app = createValidatedApp('post', '/test', handler); + + const malformed = await app.request( + new Request('http://localhost/test', { + method: 'POST', + headers: { 'Content-Type': 'multipart/form-data; boundary=----bad' }, + body: 'not a multipart body', + }), + ); + expect(malformed.status).toBe(400); + expect(((await malformed.json()) as { error: string }).error).toBe('Invalid form body'); + + const invalid = await app.request( + new Request('http://localhost/test', { + method: 'POST', + body: objectToFormData({ title: '' }), + }), + ); + expect(invalid.status).toBe(400); + const json = (await invalid.json()) as ValidationIssueBody; + expect(json.error).toBe('Validation failed'); + expect(json.issues[0]?.slot).toBe('body'); + expect(json.issues[0]?.issues[0]?.message).toBe('title is required'); + }); }); diff --git a/packages/void/test/unit/validator.test.ts b/packages/void/test/unit/validator.test.ts index 5e46daf0f..80e2fd51b 100644 --- a/packages/void/test/unit/validator.test.ts +++ b/packages/void/test/unit/validator.test.ts @@ -25,7 +25,12 @@ function failSchema(issues: Array): StandardSchemaV1 { } function mockContext(body?: unknown): CloudContext { - return { req: { json: async () => body } } as unknown as CloudContext; + return { + req: { + json: async () => body, + header: () => undefined, + }, + } as unknown as CloudContext; } describe('runValidation', () => {