From a174d39747c78e25f20d6b68f862bf5bac89e230 Mon Sep 17 00:00:00 2001 From: shkumbinhasani Date: Mon, 11 Aug 2025 11:26:00 +0200 Subject: [PATCH 1/4] fix(zod-openapi): prevent validation bypass when content-type is omitted This fixes a critical security issue where body validation could be bypassed by omitting the content-type header. The vulnerability was introduced in v0.15.2. Changes: - Made validation strict by default (when `required` is not specified) - Only allow empty body when `required` is explicitly set to `false` AND no content-type is provided - Return 400 error for mismatched content-types in single content-type scenarios - Skip validators gracefully when multiple content-types are supported - Added comprehensive security tests to prevent regression BREAKING CHANGE: Requests without content-type are now validated by default instead of bypassing validation. To allow optional body, explicitly set `required: false` in the route configuration. Fixes the security vulnerability reported where attackers could bypass validation by omitting the content-type header, potentially leading to unexpected behavior in handlers that expect validated payloads. --- packages/zod-openapi/src/index.test.ts | 8 +- packages/zod-openapi/src/index.ts | 121 ++++++- .../src/validation-security.test.ts | 334 ++++++++++++++++++ 3 files changed, 443 insertions(+), 20 deletions(-) create mode 100644 packages/zod-openapi/src/validation-security.test.ts diff --git a/packages/zod-openapi/src/index.test.ts b/packages/zod-openapi/src/index.test.ts index 0ab351831..bd7f3f78b 100644 --- a/packages/zod-openapi/src/index.test.ts +++ b/packages/zod-openapi/src/index.test.ts @@ -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 }) }) @@ -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', () => { diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index b8ce3e2b9..2495b927a 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -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 @@ -513,34 +520,116 @@ 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) } diff --git a/packages/zod-openapi/src/validation-security.test.ts b/packages/zod-openapi/src/validation-security.test.ts new file mode 100644 index 000000000..e53d204e9 --- /dev/null +++ b/packages/zod-openapi/src/validation-security.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from 'vitest' +import { OpenAPIHono, createRoute, z } from './index' + +describe('Validation Security - Body Required Behavior', () => { + const PostSchema = z.object({ + title: z.string().min(5), + content: z.string() + }) + + describe('JSON body validation', () => { + describe('when body.required is not specified (default behavior)', () => { + const route = createRoute({ + method: 'post', + path: '/posts', + request: { + body: { + content: { + 'application/json': { + schema: PostSchema + } + } + // required is not specified - should default to strict validation + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({ success: z.boolean() }) + } + } + } + } + }) + + const app = new OpenAPIHono() + app.openapi(route, async (c) => { + const body = c.req.valid('json') + return c.json({ success: true }, 200) + }) + + it('should validate when content-type is application/json', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Valid Title', content: 'Content' }), + headers: { 'Content-Type': 'application/json' } + }) + expect(res.status).toBe(200) + }) + + it('should reject invalid body when content-type is application/json', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Bad' }), // title too short + headers: { 'Content-Type': 'application/json' } + }) + expect(res.status).toBe(400) + }) + + it('should reject request without content-type (security fix)', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Bad' }) + }) + expect(res.status).toBe(400) // Should validate by default + }) + + it('should return 400 for wrong content-type', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Valid Title', content: 'Content' }), + headers: { 'Content-Type': 'text/plain' } + }) + expect(res.status).toBe(400) + const json = await res.json() + expect(json).toHaveProperty('error') + expect(json.error.message).toContain('Invalid content-type') + }) + }) + + describe('when body.required is explicitly true', () => { + const route = createRoute({ + method: 'post', + path: '/posts', + request: { + body: { + content: { + 'application/json': { + schema: PostSchema + } + }, + required: true + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({ success: z.boolean() }) + } + } + } + } + }) + + const app = new OpenAPIHono() + app.openapi(route, async (c) => { + const body = c.req.valid('json') + return c.json({ success: true }, 200) + }) + + it('should always validate the request body', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Bad' }), + headers: { 'Content-Type': 'application/json' } + }) + expect(res.status).toBe(400) + }) + + it('should reject request without content-type', async () => { + const res = await app.request('/posts', { + method: 'POST' + }) + expect(res.status).toBe(400) + }) + }) + + describe('when body.required is explicitly false', () => { + const route = createRoute({ + method: 'post', + path: '/posts', + request: { + body: { + content: { + 'application/json': { + schema: PostSchema + } + }, + required: false + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({ success: z.boolean() }) + } + } + } + } + }) + + const app = new OpenAPIHono() + app.openapi(route, async (c) => { + const body = c.req.valid('json') + return c.json({ success: true, body }, 200) + }) + + it('should validate when content-type is application/json', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Bad' }), // Invalid + headers: { 'Content-Type': 'application/json' } + }) + expect(res.status).toBe(400) + }) + + it('should allow empty body when no content-type is provided', async () => { + const res = await app.request('/posts', { + method: 'POST' + }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.body).toEqual({}) + }) + + it('should return 400 for wrong content-type', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: 'plain text', + headers: { 'Content-Type': 'text/plain' } + }) + expect(res.status).toBe(400) + const json = await res.json() + expect(json.error.message).toContain('Invalid content-type') + }) + }) + }) + + describe('Form body validation', () => { + const FormSchema = z.object({ + username: z.string().min(3), + password: z.string().min(8) + }) + + describe('when body.required is not specified (default behavior)', () => { + const route = createRoute({ + method: 'post', + path: '/login', + request: { + body: { + content: { + 'application/x-www-form-urlencoded': { + schema: FormSchema + } + } + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({ success: z.boolean() }) + } + } + } + } + }) + + const app = new OpenAPIHono() + app.openapi(route, async (c) => { + const body = c.req.valid('form') + return c.json({ success: true }, 200) + }) + + it('should validate when content-type is form-urlencoded', async () => { + const params = new URLSearchParams() + params.append('username', 'john') + params.append('password', 'password123') + + const res = await app.request('/login', { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) + expect(res.status).toBe(200) + }) + + it('should reject invalid form data', async () => { + const params = new URLSearchParams() + params.append('username', 'jo') // Too short + params.append('password', 'pass') // Too short + + const res = await app.request('/login', { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) + expect(res.status).toBe(400) + }) + + it('should reject request without content-type (security fix)', async () => { + const params = new URLSearchParams() + params.append('username', 'jo') + + const res = await app.request('/login', { + method: 'POST', + body: params + }) + expect(res.status).toBe(400) + }) + + it('should return 400 for wrong content-type', async () => { + const res = await app.request('/login', { + method: 'POST', + body: JSON.stringify({ username: 'john', password: 'password123' }), + headers: { 'Content-Type': 'application/json' } + }) + expect(res.status).toBe(400) + const json = await res.json() + expect(json.error.message).toContain('Invalid content-type') + }) + }) + + describe('when body.required is explicitly false', () => { + const route = createRoute({ + method: 'post', + path: '/login', + request: { + body: { + content: { + 'multipart/form-data': { + schema: FormSchema + } + }, + required: false + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({ success: z.boolean() }) + } + } + } + } + }) + + const app = new OpenAPIHono() + app.openapi(route, async (c) => { + const body = c.req.valid('form') + return c.json({ success: true, body }, 200) + }) + + it('should allow empty body when no content-type is provided', async () => { + const res = await app.request('/login', { + method: 'POST' + }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.body).toEqual({}) + }) + + it('should return 400 for wrong content-type', async () => { + const res = await app.request('/login', { + method: 'POST', + body: JSON.stringify({ username: 'john' }), + headers: { 'Content-Type': 'application/json' } + }) + expect(res.status).toBe(400) + const json = await res.json() + expect(json.error.message).toContain('Invalid content-type') + expect(json.error.message).toContain('multipart/form-data') + }) + }) + }) +}) \ No newline at end of file From d903d49e4959ecff0fc5bcf9f51d938bc76bd0a8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:06:56 +0000 Subject: [PATCH 2/4] ci: apply automated fixes --- packages/zod-openapi/src/index.ts | 52 +++---- .../src/validation-security.test.ts | 132 +++++++++--------- 2 files changed, 93 insertions(+), 91 deletions(-) diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index 2495b927a..ebe9c13b4 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -502,7 +502,7 @@ export class OpenAPIHono< if (bodyContent) { // Check if multiple content types are supported - const supportedContentTypes = Object.keys(bodyContent).filter(mediaType => { + const supportedContentTypes = Object.keys(bodyContent).filter((mediaType) => { const schema = (bodyContent[mediaType] as ZodMediaTypeObject)?.['schema'] return schema instanceof ZodType }) @@ -524,7 +524,7 @@ export class OpenAPIHono< // 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', {}) @@ -535,12 +535,12 @@ export class OpenAPIHono< } else if (!hasMultipleContentTypes) { // Content-type doesn't match and no other content types - return 400 return c.json( - { - success: false, + { + success: false, error: { - message: 'Invalid content-type. Expected application/json' - } - }, + message: 'Invalid content-type. Expected application/json', + }, + }, 400 ) } else { @@ -553,19 +553,19 @@ export class OpenAPIHono< // Default behavior: validate strictly but check content-type const mw: MiddlewareHandler = async (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, + { + success: false, error: { - message: 'Invalid content-type. Expected application/json' - } - }, + message: 'Invalid content-type. Expected application/json', + }, + }, 400 ) } else { @@ -582,7 +582,7 @@ export class OpenAPIHono< // 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', {}) @@ -593,12 +593,13 @@ export class OpenAPIHono< } else if (!hasMultipleContentTypes) { // Content-type doesn't match and no other content types - return 400 return c.json( - { - success: false, + { + success: false, error: { - message: 'Invalid content-type. Expected multipart/form-data or application/x-www-form-urlencoded' - } - }, + message: + 'Invalid content-type. Expected multipart/form-data or application/x-www-form-urlencoded', + }, + }, 400 ) } else { @@ -611,19 +612,20 @@ export class OpenAPIHono< // Default behavior: validate strictly but check content-type const mw: MiddlewareHandler = async (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, + { + success: false, error: { - message: 'Invalid content-type. Expected multipart/form-data or application/x-www-form-urlencoded' - } - }, + message: + 'Invalid content-type. Expected multipart/form-data or application/x-www-form-urlencoded', + }, + }, 400 ) } else { diff --git a/packages/zod-openapi/src/validation-security.test.ts b/packages/zod-openapi/src/validation-security.test.ts index e53d204e9..ef212fc57 100644 --- a/packages/zod-openapi/src/validation-security.test.ts +++ b/packages/zod-openapi/src/validation-security.test.ts @@ -4,7 +4,7 @@ import { OpenAPIHono, createRoute, z } from './index' describe('Validation Security - Body Required Behavior', () => { const PostSchema = z.object({ title: z.string().min(5), - content: z.string() + content: z.string(), }) describe('JSON body validation', () => { @@ -16,22 +16,22 @@ describe('Validation Security - Body Required Behavior', () => { body: { content: { 'application/json': { - schema: PostSchema - } - } + schema: PostSchema, + }, + }, // required is not specified - should default to strict validation - } + }, }, responses: { 200: { description: 'Success', content: { 'application/json': { - schema: z.object({ success: z.boolean() }) - } - } - } - } + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, }) const app = new OpenAPIHono() @@ -44,7 +44,7 @@ describe('Validation Security - Body Required Behavior', () => { const res = await app.request('/posts', { method: 'POST', body: JSON.stringify({ title: 'Valid Title', content: 'Content' }), - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(200) }) @@ -53,7 +53,7 @@ describe('Validation Security - Body Required Behavior', () => { const res = await app.request('/posts', { method: 'POST', body: JSON.stringify({ title: 'Bad' }), // title too short - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(400) }) @@ -61,7 +61,7 @@ describe('Validation Security - Body Required Behavior', () => { it('should reject request without content-type (security fix)', async () => { const res = await app.request('/posts', { method: 'POST', - body: JSON.stringify({ title: 'Bad' }) + body: JSON.stringify({ title: 'Bad' }), }) expect(res.status).toBe(400) // Should validate by default }) @@ -70,7 +70,7 @@ describe('Validation Security - Body Required Behavior', () => { const res = await app.request('/posts', { method: 'POST', body: JSON.stringify({ title: 'Valid Title', content: 'Content' }), - headers: { 'Content-Type': 'text/plain' } + headers: { 'Content-Type': 'text/plain' }, }) expect(res.status).toBe(400) const json = await res.json() @@ -87,22 +87,22 @@ describe('Validation Security - Body Required Behavior', () => { body: { content: { 'application/json': { - schema: PostSchema - } + schema: PostSchema, + }, }, - required: true - } + required: true, + }, }, responses: { 200: { description: 'Success', content: { 'application/json': { - schema: z.object({ success: z.boolean() }) - } - } - } - } + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, }) const app = new OpenAPIHono() @@ -115,14 +115,14 @@ describe('Validation Security - Body Required Behavior', () => { const res = await app.request('/posts', { method: 'POST', body: JSON.stringify({ title: 'Bad' }), - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(400) }) it('should reject request without content-type', async () => { const res = await app.request('/posts', { - method: 'POST' + method: 'POST', }) expect(res.status).toBe(400) }) @@ -136,22 +136,22 @@ describe('Validation Security - Body Required Behavior', () => { body: { content: { 'application/json': { - schema: PostSchema - } + schema: PostSchema, + }, }, - required: false - } + required: false, + }, }, responses: { 200: { description: 'Success', content: { 'application/json': { - schema: z.object({ success: z.boolean() }) - } - } - } - } + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, }) const app = new OpenAPIHono() @@ -164,14 +164,14 @@ describe('Validation Security - Body Required Behavior', () => { const res = await app.request('/posts', { method: 'POST', body: JSON.stringify({ title: 'Bad' }), // Invalid - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(400) }) it('should allow empty body when no content-type is provided', async () => { const res = await app.request('/posts', { - method: 'POST' + method: 'POST', }) expect(res.status).toBe(200) const json = await res.json() @@ -182,7 +182,7 @@ describe('Validation Security - Body Required Behavior', () => { const res = await app.request('/posts', { method: 'POST', body: 'plain text', - headers: { 'Content-Type': 'text/plain' } + headers: { 'Content-Type': 'text/plain' }, }) expect(res.status).toBe(400) const json = await res.json() @@ -194,7 +194,7 @@ describe('Validation Security - Body Required Behavior', () => { describe('Form body validation', () => { const FormSchema = z.object({ username: z.string().min(3), - password: z.string().min(8) + password: z.string().min(8), }) describe('when body.required is not specified (default behavior)', () => { @@ -205,21 +205,21 @@ describe('Validation Security - Body Required Behavior', () => { body: { content: { 'application/x-www-form-urlencoded': { - schema: FormSchema - } - } - } + schema: FormSchema, + }, + }, + }, }, responses: { 200: { description: 'Success', content: { 'application/json': { - schema: z.object({ success: z.boolean() }) - } - } - } - } + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, }) const app = new OpenAPIHono() @@ -232,11 +232,11 @@ describe('Validation Security - Body Required Behavior', () => { const params = new URLSearchParams() params.append('username', 'john') params.append('password', 'password123') - + const res = await app.request('/login', { method: 'POST', body: params, - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }) expect(res.status).toBe(200) }) @@ -245,11 +245,11 @@ describe('Validation Security - Body Required Behavior', () => { const params = new URLSearchParams() params.append('username', 'jo') // Too short params.append('password', 'pass') // Too short - + const res = await app.request('/login', { method: 'POST', body: params, - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }) expect(res.status).toBe(400) }) @@ -257,10 +257,10 @@ describe('Validation Security - Body Required Behavior', () => { it('should reject request without content-type (security fix)', async () => { const params = new URLSearchParams() params.append('username', 'jo') - + const res = await app.request('/login', { method: 'POST', - body: params + body: params, }) expect(res.status).toBe(400) }) @@ -269,7 +269,7 @@ describe('Validation Security - Body Required Behavior', () => { const res = await app.request('/login', { method: 'POST', body: JSON.stringify({ username: 'john', password: 'password123' }), - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(400) const json = await res.json() @@ -285,22 +285,22 @@ describe('Validation Security - Body Required Behavior', () => { body: { content: { 'multipart/form-data': { - schema: FormSchema - } + schema: FormSchema, + }, }, - required: false - } + required: false, + }, }, responses: { 200: { description: 'Success', content: { 'application/json': { - schema: z.object({ success: z.boolean() }) - } - } - } - } + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, }) const app = new OpenAPIHono() @@ -311,7 +311,7 @@ describe('Validation Security - Body Required Behavior', () => { it('should allow empty body when no content-type is provided', async () => { const res = await app.request('/login', { - method: 'POST' + method: 'POST', }) expect(res.status).toBe(200) const json = await res.json() @@ -322,7 +322,7 @@ describe('Validation Security - Body Required Behavior', () => { const res = await app.request('/login', { method: 'POST', body: JSON.stringify({ username: 'john' }), - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(400) const json = await res.json() @@ -331,4 +331,4 @@ describe('Validation Security - Body Required Behavior', () => { }) }) }) -}) \ No newline at end of file +}) From ba256ba213e19113f46d680a97b61309e77fdbf3 Mon Sep 17 00:00:00 2001 From: shkumbinhasani Date: Wed, 13 Aug 2025 10:34:06 +0200 Subject: [PATCH 3/4] feat: add comprehensive tests and improve validation logic - Add multiple content-type support scenarios tests - Fix validation logic for multiple content types - Add final check to ensure validation runs for unsupported content-types - Update README with detailed body validation behavior documentation - Add changeset for the security patch This ensures requests with unsupported content-types or missing content-types are properly validated when multiple content types are supported. --- .changeset/true-banks-pump.md | 20 ++ packages/zod-openapi/README.md | 72 ++++++- packages/zod-openapi/src/index.ts | 55 ++++++ .../src/validation-security.test.ts | 177 ++++++++++++++++++ 4 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 .changeset/true-banks-pump.md diff --git a/.changeset/true-banks-pump.md b/.changeset/true-banks-pump.md new file mode 100644 index 000000000..11a4283ae --- /dev/null +++ b/.changeset/true-banks-pump.md @@ -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. \ No newline at end of file diff --git a/packages/zod-openapi/README.md b/packages/zod-openapi/README.md index d02172613..4e794c0e3 100644 --- a/packages/zod-openapi/README.md +++ b/packages/zod-openapi/README.md @@ -118,7 +118,21 @@ 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 `{}`. +> **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' @@ -135,6 +149,7 @@ export default app > }), > }, > }, +> // required is not specified - defaults to strict validation > }, > }, > responses: { @@ -148,20 +163,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({ @@ -176,11 +193,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 diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index ebe9c13b4..f87865712 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -637,6 +637,61 @@ export class OpenAPIHono< } } } + + // 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 diff --git a/packages/zod-openapi/src/validation-security.test.ts b/packages/zod-openapi/src/validation-security.test.ts index ef212fc57..842233211 100644 --- a/packages/zod-openapi/src/validation-security.test.ts +++ b/packages/zod-openapi/src/validation-security.test.ts @@ -331,4 +331,181 @@ describe('Validation Security - Body Required Behavior', () => { }) }) }) + + describe('Multiple content-type support scenarios', () => { + const PostSchema = z.object({ + title: z.string().min(5), + content: z.string(), + }) + + const FormSchema = z.object({ + username: z.string().min(3), + password: z.string().min(8), + }) + + describe('when both JSON and Form content-types are supported', () => { + const route = createRoute({ + method: 'post', + path: '/posts', + request: { + body: { + content: { + 'application/json': { + schema: PostSchema, + }, + 'application/x-www-form-urlencoded': { + schema: FormSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, + }) + + const app = new OpenAPIHono() + app.openapi(route, async (c) => { + const jsonBody = c.req.valid('json') + const formBody = c.req.valid('form') + return c.json({ success: true, jsonBody, formBody }, 200) + }) + + it('should validate JSON when content-type is application/json', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Valid Title', content: 'Content' }), + headers: { 'Content-Type': 'application/json' }, + }) + expect(res.status).toBe(200) + }) + + it('should validate Form when content-type is application/x-www-form-urlencoded', async () => { + const params = new URLSearchParams() + params.append('username', 'validuser') + params.append('password', 'password123') + + const res = await app.request('/posts', { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(res.status).toBe(200) + }) + + it('should reject invalid JSON data', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Bad' }), // title too short + headers: { 'Content-Type': 'application/json' }, + }) + expect(res.status).toBe(400) + }) + + it('should reject invalid Form data', async () => { + const params = new URLSearchParams() + params.append('username', 'ab') // username too short + params.append('password', 'short') // password too short + + const res = await app.request('/posts', { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(res.status).toBe(400) + }) + + it('should return 400 for unsupported content-type even with multiple content-types', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Valid Title', content: 'Content' }), + headers: { 'Content-Type': 'text/plain' }, + }) + expect(res.status).toBe(400) + const json = await res.json() + expect(json).toHaveProperty('error') + expect(json.error.message).toContain('content-type') + }) + + it('should reject when no content-type is provided even with valid data (security test)', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Valid Title', content: 'Valid Content' }), // Valid data + }) + expect(res.status).toBe(400) + }) + }) + + describe('when multiple content-types are supported with required: false', () => { + const route = createRoute({ + method: 'post', + path: '/posts', + request: { + body: { + content: { + 'application/json': { + schema: PostSchema, + }, + 'multipart/form-data': { + schema: FormSchema, + }, + }, + required: false, + }, + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: z.object({ success: z.boolean() }), + }, + }, + }, + }, + }) + + const app = new OpenAPIHono() + app.openapi(route, async (c) => { + const jsonBody = c.req.valid('json') + const formBody = c.req.valid('form') + return c.json({ success: true, jsonBody, formBody }, 200) + }) + + it('should allow empty body when no content-type is provided', async () => { + const res = await app.request('/posts', { + method: 'POST', + }) + expect(res.status).toBe(200) + }) + + it('should validate when matching content-type is provided', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Bad' }), // Invalid + headers: { 'Content-Type': 'application/json' }, + }) + expect(res.status).toBe(400) // Should validate + }) + + it('should return 400 for unrecognized content-type even with required: false and multiple content-types', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: 'plain text', + headers: { 'Content-Type': 'text/plain' }, + }) + expect(res.status).toBe(400) + const json = await res.json() + expect(json).toHaveProperty('error') + expect(json.error.message).toContain('content-type') + }) + }) + }) }) From 2a57b5aa89e19af0bf91c2561b9345ff8b8fe947 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 23:54:16 +0000 Subject: [PATCH 4/4] ci: apply automated fixes --- packages/zod-openapi/README.md | 13 ++++++------- packages/zod-openapi/src/index.ts | 17 +++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/zod-openapi/README.md b/packages/zod-openapi/README.md index 4e794c0e3..e8d4d53f1 100644 --- a/packages/zod-openapi/README.md +++ b/packages/zod-openapi/README.md @@ -117,18 +117,17 @@ You can start your app just like you would with Hono. For Cloudflare Workers and export default app ``` -> [!IMPORTANT] -> **Security Notice**: Body validation is now strict by default to prevent validation bypass vulnerabilities. +> [!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 | +> | `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) > diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index f87865712..babb1797d 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -642,22 +642,23 @@ export class OpenAPIHono< 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) - + 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 => { + 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(', ') @@ -665,8 +666,8 @@ export class OpenAPIHono< { success: false, error: { - message: `Invalid content-type. Expected one of: ${supportedTypes}` - } + message: `Invalid content-type. Expected one of: ${supportedTypes}`, + }, }, 400 ) @@ -676,7 +677,7 @@ export class OpenAPIHono< // 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