Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/pages-routing/actions-and-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
176 changes: 174 additions & 2 deletions packages/void/src/runtime/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string, unknown> {
return (
typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Blob)
);
}

function own(record: Record<string, unknown>, key: string): unknown {
if (DANGEROUS_KEYS.has(key)) {
return Object.getOwnPropertyDescriptor(record, key)?.value;
}
return record[key];
}

function setOwn(record: Record<string, unknown>, 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<string, FormEntry | Array<FormEntry>>,
): Record<string, unknown> {
const root: Record<string, unknown> = {};
let nodes = 0;

const createContainer = (asArray: boolean): Record<string, unknown> | Array<unknown> => {
if (++nodes > MAX_FORM_NODES) {
throw new Error('Form field has too many nested values');
}
return asArray ? [] : {};
};

const read = (parent: Record<string, unknown> | Array<unknown>, segment: string): unknown => {
if (Array.isArray(parent)) {
return segment === '' ? undefined : parent[Number(segment)];
}
return own(parent, segment);
};

const write = (
parent: Record<string, unknown> | Array<unknown>,
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<string>, value: FormEntry): void => {
let parent: Record<string, unknown> | Array<unknown> = 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<string, unknown> | Array<unknown>)
: 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<unknown> {
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<string, FormEntry | Array<FormEntry>>;
return formRecordToObject(parsed);
}

export async function runValidation<V extends ValidatorSlots>(
validators: V,
c: CloudContext,
Expand Down Expand Up @@ -82,12 +248,18 @@ export async function runValidation<V extends ValidatorSlots>(

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);
Expand Down
173 changes: 173 additions & 0 deletions packages/void/test/unit/define-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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');
});
});
Loading
Loading