Skip to content
Open
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
20 changes: 20 additions & 0 deletions .changeset/true-banks-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@hono/zod-openapi': patch
---

Fix critical security vulnerability in body validation

Fixes a security issue where request body validation could be bypassed by omitting the Content-Type header (introduced in v0.15.2).

Security Impact:
- Previously, requests without Content-Type headers would skip validation entirely, allowing unvalidated data to reach handlers
- This could lead to type safety violations, unexpected behavior, and potential security vulnerabilities

Changes:
- Made validation strict by default (when required is not specified)
- Requests without Content-Type are now validated instead of bypassing validation
- Added proper handling for multiple content-type scenarios
- Return 400 errors for unsupported content-types

Breaking Change:
To allow optional body validation (previous behavior), explicitly set required: false in the route configuration.
73 changes: 64 additions & 9 deletions packages/zod-openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,21 @@ You can start your app just like you would with Hono. For Cloudflare Workers and
export default app
```

> [!IMPORTANT]
> The request must have the proper `Content-Type` to ensure the validation. For example, if you want to validate a JSON body, the request must have the `Content-Type` to `application/json` in the request. Otherwise, the value of `c.req.valid('json')` will be `{}`.
> [!IMPORTANT] > **Security Notice**: Body validation is now strict by default to prevent validation bypass vulnerabilities.
>
> ### Request Body Validation Behavior
>
> The validation behavior depends on the `required` field in `request.body`:
>
> | `required` value | Content-Type present | Content-Type missing | Wrong Content-Type |
> | ----------------------- | -------------------- | -------------------- | ------------------ |
> | Not specified (default) | ✅ Validates | ✅ Validates | ❌ Returns 400 |
> | `true` | ✅ Validates | ✅ Validates | ❌ Returns 400 |
> | `false` | ✅ Validates | ⚠️ Allows empty body | ❌ Returns 400 |
>
> ### Default Behavior (Secure)
>
> By default, requests are always validated to prevent security vulnerabilities:
>
> ```ts
> import { createRoute, z, OpenAPIHono } from '@hono/zod-openapi'
Expand All @@ -135,6 +148,7 @@ export default app
> }),
> },
> },
> // required is not specified - defaults to strict validation
> },
> },
> responses: {
Expand All @@ -148,20 +162,22 @@ export default app
>
> app.openapi(route, (c) => {
> const validatedBody = c.req.valid('json')
> return c.json(validatedBody) // validatedBody is {}
> return c.json(validatedBody)
> })
>
> // Request without Content-Type will be validated (returns 400 for invalid data)
> const res = await app.request('/books', {
> method: 'POST',
> body: JSON.stringify({ title: 'foo' }),
> // The Content-Type header is lacking.
> body: JSON.stringify({ title: '' }), // Invalid: empty title
> // No Content-Type header
> })
>
> const data = await res.json()
> console.log(data) // {}
> console.log(res.status) // 400 - Validation error
> ```
>
> If you want to force validation of requests that do not have the proper `Content-Type`, set the value of `request.body.required` to `true`.
> ### Optional Body Validation
>
> To allow requests without a body (but still validate when body is present), explicitly set `required: false`:
>
> ```ts
> const route = createRoute({
Expand All @@ -176,11 +192,50 @@ export default app
> }),
> },
> },
> required: true, // <== add
> required: false, // Explicitly allow empty body
> },
> },
> })
>
> // Request without Content-Type and body is allowed
> const res1 = await app.request('/books', {
> method: 'POST',
> })
> console.log(res1.status) // 200 - Empty body allowed
>
> // But if Content-Type is present, body is validated
> const res2 = await app.request('/books', {
> method: 'POST',
> body: JSON.stringify({ title: '' }), // Invalid
> headers: { 'Content-Type': 'application/json' },
> })
> console.log(res2.status) // 400 - Validation error
> ```
>
> ### Multiple Content-Type Support
>
> When multiple content types are supported, the appropriate validator is used based on the Content-Type header:
>
> ```ts
> const route = createRoute({
> method: 'post',
> path: '/data',
> request: {
> body: {
> content: {
> 'application/json': {
> schema: z.object({ jsonField: z.string() }),
> },
> 'application/x-www-form-urlencoded': {
> schema: z.object({ formField: z.string() }),
> },
> },
> },
> },
> })
> ```
>
> Unsupported content types will return a 400 error with a descriptive message.

### Handling Validation Errors

Expand Down
8 changes: 4 additions & 4 deletions packages/zod-openapi/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,12 +491,12 @@ describe('JSON', () => {
expect(res.status).toBe(400)
})

it('Should return 200 response without a content-type', async () => {
it('Should return 400 response without a content-type (security fix)', async () => {
const req = new Request('http://localhost/posts', {
method: 'POST',
})
const res = await app.request(req)
expect(res.status).toBe(200)
expect(res.status).toBe(400) // Now validates by default for security
})
})

Expand Down Expand Up @@ -683,12 +683,12 @@ describe('Form', () => {
})
})

it('Should return 200 response without a content-type', async () => {
it('Should return 400 response without a content-type (security fix)', async () => {
const req = new Request('http://localhost/posts', {
method: 'POST',
})
const res = await app.request(req)
expect(res.status).toBe(200)
expect(res.status).toBe(400) // Now validates by default for security
})

describe('required', () => {
Expand Down
179 changes: 163 additions & 16 deletions packages/zod-openapi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,13 @@ export class OpenAPIHono<
const bodyContent = route.request?.body?.content

if (bodyContent) {
// Check if multiple content types are supported
const supportedContentTypes = Object.keys(bodyContent).filter((mediaType) => {
const schema = (bodyContent[mediaType] as ZodMediaTypeObject)?.['schema']
return schema instanceof ZodType
})
const hasMultipleContentTypes = supportedContentTypes.length > 1

for (const mediaType of Object.keys(bodyContent)) {
if (!bodyContent[mediaType]) {
continue
Expand All @@ -513,39 +520,179 @@ export class OpenAPIHono<
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore we can ignore the type error since Zod Validator's types are not used
const validator = zValidator('json', schema, hook)
if (route.request?.body?.required) {
validators.push(validator)
if (route.request?.body?.required === false) {
// Only bypass validation if explicitly set to false
const mw: MiddlewareHandler = async (c, next) => {
const contentType = c.req.header('content-type')

if (!contentType) {
// No content-type header - allow empty body only if required is explicitly false
c.req.addValidatedData('json', {})
await next()
} else if (isJSONContentType(contentType)) {
// Content-type matches - always validate
return await validator(c, next)
} else if (!hasMultipleContentTypes) {
// Content-type doesn't match and no other content types - return 400
return c.json(
{
success: false,
error: {
message: 'Invalid content-type. Expected application/json',
},
},
400
)
} else {
// Multiple content types supported - skip this validator
await next()
}
}
validators.push(mw)
} else {
// Default behavior: validate strictly but check content-type
const mw: MiddlewareHandler = async (c, next) => {
if (c.req.header('content-type')) {
if (isJSONContentType(c.req.header('content-type')!)) {
return await validator(c, next)
}
const contentType = c.req.header('content-type')

if (!contentType || isJSONContentType(contentType)) {
// No content-type or matching content-type - validate
return await validator(c, next)
} else if (!hasMultipleContentTypes) {
// Content-type doesn't match and no other content types - return 400
return c.json(
{
success: false,
error: {
message: 'Invalid content-type. Expected application/json',
},
},
400
)
} else {
// Multiple content types supported - skip this validator
await next()
}
c.req.addValidatedData('json', {})
await next()
}
validators.push(mw)
}
}
if (isFormContentType(mediaType)) {
const validator = zValidator('form', schema, hook as any)
if (route.request?.body?.required) {
validators.push(validator)
if (route.request?.body?.required === false) {
// Only bypass validation if explicitly set to false
const mw: MiddlewareHandler = async (c, next) => {
const contentType = c.req.header('content-type')

if (!contentType) {
// No content-type header - allow empty body only if required is explicitly false
c.req.addValidatedData('form', {})
await next()
} else if (isFormContentType(contentType)) {
// Content-type matches - always validate
return await validator(c, next)
} else if (!hasMultipleContentTypes) {
// Content-type doesn't match and no other content types - return 400
return c.json(
{
success: false,
error: {
message:
'Invalid content-type. Expected multipart/form-data or application/x-www-form-urlencoded',
},
},
400
)
} else {
// Multiple content types supported - skip this validator
await next()
}
}
validators.push(mw)
} else {
// Default behavior: validate strictly but check content-type
const mw: MiddlewareHandler = async (c, next) => {
if (c.req.header('content-type')) {
if (isFormContentType(c.req.header('content-type')!)) {
return await validator(c, next)
}
const contentType = c.req.header('content-type')

if (!contentType || isFormContentType(contentType)) {
// No content-type or matching content-type - validate
return await validator(c, next)
} else if (!hasMultipleContentTypes) {
// Content-type doesn't match and no other content types - return 400
return c.json(
{
success: false,
error: {
message:
'Invalid content-type. Expected multipart/form-data or application/x-www-form-urlencoded',
},
},
400
)
} else {
// Multiple content types supported - skip this validator
await next()
}
c.req.addValidatedData('form', {})
await next()
}
validators.push(mw)
}
}
}

// Add a final validator to handle cases where no validator matched (for multiple content-types)
if (hasMultipleContentTypes) {
const finalCheck: MiddlewareHandler = async (c, next) => {
const contentType = c.req.header('content-type')

// Check if any validator has added validated data
// We check the internal validated data store
// @ts-expect-error accessing internal property
const validatedData = c.req.validatedData
const hasValidatedData =
validatedData && (validatedData.json !== undefined || validatedData.form !== undefined)

if (!hasValidatedData) {
if (contentType) {
// Check if content-type matches any supported type
const isSupported = supportedContentTypes.some((type) => {
if (isJSONContentType(type) && isJSONContentType(contentType)) return true
if (isFormContentType(type) && isFormContentType(contentType)) return true
return false
})

if (!isSupported) {
// Has content-type but it's not supported
const supportedTypes = supportedContentTypes.join(', ')
return c.json(
{
success: false,
error: {
message: `Invalid content-type. Expected one of: ${supportedTypes}`,
},
},
400
)
}
} else if (route.request?.body?.required !== false) {
// No content-type and body is required (default or explicitly true)
// Validate with the first available validator
const firstMediaType = supportedContentTypes[0]
const schema = (bodyContent[firstMediaType] as ZodMediaTypeObject)['schema']

if (isJSONContentType(firstMediaType)) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const validator = zValidator('json', schema, hook)
return await validator(c, next)
} else if (isFormContentType(firstMediaType)) {
const validator = zValidator('form', schema as any, hook as any)
return await validator(c, next)
}
}
}
await next()
}
validators.push(finalCheck)
}
}

const middleware = routeMiddleware
Expand Down
Loading
Loading