From 22e233ad408e66f38fce73054755730edca00f4b Mon Sep 17 00:00:00 2001 From: Yarchik Date: Tue, 16 Jun 2026 22:19:49 +0100 Subject: [PATCH] fix: validate file contents in dynamic mode `t.File`/`t.Files` with a declared type validates the actual file bytes (magic-byte check via `fileType`) in AOT mode, but the dynamic handler (`aot: false`) only ran the synchronous schema check, accepting a file whose contents do not match the declared type. Mirror the AOT path in `createDynamicHandler`: after body validation, run `fileType(...)` for File/Files fields that declare an extension, so both modes enforce the same guarantee. Closes #1922 --- src/dynamic-handle.ts | 35 ++++++++++++++++++++++++++++++++++- test/validator/body.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index c4dcd5198..1ef5a8fde 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -1,3 +1,4 @@ +import { Kind } from '@sinclair/typebox' import { TransformDecodeError } from '@sinclair/typebox/value' import type { Context } from './context' import { parseCookie } from './cookies' @@ -10,8 +11,13 @@ import { } from './error' import type { AnyElysia, CookieOptions } from './index' import { parseQuery } from './parse-query' -import { getSchemaProperties, type ElysiaTypeCheck } from './schema' +import { + getSchemaProperties, + unwrapImportSchema, + type ElysiaTypeCheck +} from './schema' import type { TypeCheck } from './type-system' +import { fileType } from './type-system/utils' import type { Handler, LifeCycleStore, SchemaValidator } from './types' import { hasSetImmediate, redirect, StatusMap, signCookie } from './utils' @@ -616,6 +622,33 @@ export const createDynamicHandler = (app: AnyElysia) => { // Zod returns { value: ... } wrapper context.body = decoded?.value ?? decoded } + + // Validate file contents by magic bytes, mirroring the AOT path + // (`compose.ts`) which injects `fileType(...)` for File/Files + // fields that declare an extension. Without this, dynamic mode + // (`aot: false`) only checks the client-supplied MIME type and + // accepts a file whose bytes do not match the declared type. + if (validator.body) { + const bodyProperties = getSchemaProperties( + unwrapImportSchema(validator.body.schema) + ) + + if (bodyProperties) + for (const key in bodyProperties) { + const property = bodyProperties[key] + + if ( + property.extension && + (property[Kind] === 'File' || + property[Kind] === 'Files') + ) + await fileType( + (context.body as any)?.[key], + property.extension, + `body.${key}` + ) + } + } } if (hooks.beforeHandle) diff --git a/test/validator/body.test.ts b/test/validator/body.test.ts index 2aee32535..1a43932ea 100644 --- a/test/validator/body.test.ts +++ b/test/validator/body.test.ts @@ -836,6 +836,38 @@ describe('Body Validator', () => { } }) + it('validate actual file in dynamic mode', async () => { + const app = new Elysia({ aot: false }).post( + '/upload', + ({ body: { file } }) => file.size, + { + body: t.Object({ + file: t.File({ + type: 'image' + }) + }) + } + ) + + { + const { request, size } = upload('/upload', { + file: 'millenium.jpg' + }) + + const response = await app.handle(request).then((r) => r.text()) + expect(+response).toBe(size) + } + + { + const { request } = upload('/upload', { + file: 'fake.jpg' + }) + + const status = await app.handle(request).then((r) => r.status) + expect(status).toBe(422) + } + }) + it('validate actual file with multiple type', async () => { const app = new Elysia().post( '/upload',