From bf8608cb77f91c3768311d9977f807ec2f3fd6b9 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 21 Jul 2026 20:06:54 +0900 Subject: [PATCH 1/7] ci: enable reports for type & bundle size check (#5148) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5627ae15c4..cf00861e24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,9 @@ jobs: name: 'Type & Bundle size Check on PR' runs-on: ubuntu-latest if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/perf-measures From e36f57dac0615c1bdd03ff1975b6ca8d3dc5917f Mon Sep 17 00:00:00 2001 From: Jason G <41053218+gianghungtien@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:19:04 +0700 Subject: [PATCH 2/7] fix(aws-lambda): add jwt and lambda authorizer types for API Gateway v2 (#5142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ApiGatewayRequestContextV2["authorizer"]` only declared the `iam` variant, so reading the context of a Lambda (REQUEST) authorizer or a JWT authorizer was a TypeScript error even though API Gateway populates those keys: ctx.env.event.requestContext.authorizer.lambda.userId // Property 'lambda' does not exist on type 'Authorizer'. The payload format 2.0 event documented by AWS carries `authorizer.jwt` with `claims` and `scopes`, and a Lambda authorizer's `context` object is delivered under `authorizer.lambda`. Add both as optional properties, mirroring `@types/aws-lambda`. Types only — no runtime change, and `iam` is untouched, so this is backwards compatible. Closes #3281 --- src/adapter/aws-lambda/handler.test.ts | 61 +++++++++++++++++++++++++- src/adapter/aws-lambda/types.ts | 9 ++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/adapter/aws-lambda/handler.test.ts b/src/adapter/aws-lambda/handler.test.ts index c1a4d4afc9..02743a5048 100644 --- a/src/adapter/aws-lambda/handler.test.ts +++ b/src/adapter/aws-lambda/handler.test.ts @@ -1,13 +1,14 @@ import { setCookie } from '../../helper/cookie' import { Hono } from '../../hono' import { bodyLimit } from '../../middleware/body-limit' -import type { LambdaEvent, LatticeProxyEventV2 } from './handler' +import type { APIGatewayProxyEventV2, LambdaEvent, LatticeProxyEventV2 } from './handler' import { getProcessor, handle, isContentEncodingBinary, defaultIsContentTypeBinary, } from './handler' +import type { ApiGatewayRequestContextV2 } from './types' // Base event objects to reduce duplication const baseV1Event: LambdaEvent = { @@ -572,3 +573,61 @@ describe('handle', () => { expect(result.statusCode).toBe(413) }) }) + +describe('V2 request context authorizer', () => { + const baseV2RequestContext = baseV2Event.requestContext as ApiGatewayRequestContextV2 + + it('Should expose the context of a Lambda (REQUEST) authorizer', async () => { + const app = new Hono<{ Bindings: { event: APIGatewayProxyEventV2 } }>() + app.get('/my/path', (c) => c.json(c.env.event.requestContext.authorizer.lambda)) + const handler = handle(app) + + const event: LambdaEvent = { + ...baseV2Event, + requestContext: { + ...baseV2RequestContext, + http: { ...baseV2RequestContext.http, method: 'GET' }, + authorizer: { lambda: { userId: 'user-123', isAdmin: true } }, + }, + } + + const result = await handler(event) + expect(result.statusCode).toBe(200) + expect(JSON.parse(result.body)).toEqual({ userId: 'user-123', isAdmin: true }) + }) + + it('Should expose the claims and scopes of a JWT authorizer', async () => { + const app = new Hono<{ Bindings: { event: APIGatewayProxyEventV2 } }>() + app.get('/my/path', (c) => c.json(c.env.event.requestContext.authorizer.jwt)) + const handler = handle(app) + + const event: LambdaEvent = { + ...baseV2Event, + requestContext: { + ...baseV2RequestContext, + http: { ...baseV2RequestContext.http, method: 'GET' }, + authorizer: { + jwt: { claims: { sub: 'user-123', email_verified: true }, scopes: ['read'] }, + }, + }, + } + + const result = await handler(event) + expect(result.statusCode).toBe(200) + expect(JSON.parse(result.body)).toEqual({ + claims: { sub: 'user-123', email_verified: true }, + scopes: ['read'], + }) + }) + + it('Should type each authorizer variant as optional', () => { + const authorizer: ApiGatewayRequestContextV2['authorizer'] = {} + expectTypeOf(authorizer.lambda).toEqualTypeOf | null | undefined>() + expectTypeOf(authorizer.jwt).toEqualTypeOf< + | { claims: Record; scopes: string[] | null } + | undefined + >() + // A JWT authorizer reports no scopes as `null`. + expectTypeOf().toMatchTypeOf['scopes']>() + }) +}) diff --git a/src/adapter/aws-lambda/types.ts b/src/adapter/aws-lambda/types.ts index cad49389c8..8983c7f88a 100644 --- a/src/adapter/aws-lambda/types.ts +++ b/src/adapter/aws-lambda/types.ts @@ -114,6 +114,15 @@ interface Authorizer { userArn: string userId: string } + jwt?: { + claims: Record + scopes: string[] | null + } + /** + * The `context` object returned by a Lambda (REQUEST) authorizer. + * It is `null` when the authorizer returns no context. + */ + lambda?: Record | null } export interface ApiGatewayRequestContextV2 { From 44f884321a1d52e98d45a85634da9d5f4751a43a Mon Sep 17 00:00:00 2001 From: Latte Date: Tue, 21 Jul 2026 20:26:53 +0900 Subject: [PATCH 3/7] fix(sse): emit empty id field to reset Last-Event-ID (#5138) --- src/helper/streaming/sse.test.tsx | 11 +++++++++++ src/helper/streaming/sse.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/helper/streaming/sse.test.tsx b/src/helper/streaming/sse.test.tsx index 75b2bd4319..f5052721f8 100644 --- a/src/helper/streaming/sse.test.tsx +++ b/src/helper/streaming/sse.test.tsx @@ -154,6 +154,17 @@ describe('SSE Streaming helper', () => { expect(decodedValue).toContain('retry: 0\n\n') }) + it('Should emit an empty id to reset Last-Event-ID', async () => { + const res = streamSSE(c, async (stream) => { + await stream.writeSSE({ + data: 'reset', + id: '', + }) + }) + + expect(await res.text()).toBe('data: reset\nid: \n\n') + }) + it('Check stream Response if error occurred', async () => { const onError = vi.fn() const res = streamSSE( diff --git a/src/helper/streaming/sse.ts b/src/helper/streaming/sse.ts index 2b161912fd..be658ec616 100644 --- a/src/helper/streaming/sse.ts +++ b/src/helper/streaming/sse.ts @@ -35,7 +35,7 @@ export class SSEStreamingApi extends StreamingApi { [ message.event && `event: ${message.event}`, dataLines, - message.id && `id: ${message.id}`, + message.id !== undefined && `id: ${message.id}`, message.retry !== undefined && `retry: ${message.retry}`, ] .filter(Boolean) From a88c89dac6e230cbb5f1bf627522d4914b81dffd Mon Sep 17 00:00:00 2001 From: nikachello Date: Fri, 24 Jul 2026 05:45:42 +0400 Subject: [PATCH 4/7] test(cloudflare-workers): add coverage for onClose, onError, send, and close in cloudflare-workers websocket adapter (#5145) --- .../cloudflare-workers/websocket.test.ts | 105 +++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/src/adapter/cloudflare-workers/websocket.test.ts b/src/adapter/cloudflare-workers/websocket.test.ts index e07a612dbf..39c3ec4609 100644 --- a/src/adapter/cloudflare-workers/websocket.test.ts +++ b/src/adapter/cloudflare-workers/websocket.test.ts @@ -54,6 +54,109 @@ describe('upgradeWebSocket middleware', () => { ), next ) - expect(next).toBeCalled() + expect(next).toHaveBeenCalled() + }) + + const closePromise = new Promise((resolve) => + app.get( + '/ws-close', + upgradeWebSocket(() => ({ + onClose(evt, ws) { + resolve(true) + }, + })) + ) + ) + + it('Should call onClose when close event fires', async () => { + await app.request('/ws-close', { + headers: { + Upgrade: 'websocket', + }, + }) + + server.dispatchEvent(new Event('close')) + + expect(await closePromise).toBe(true) + }) + + const error = new Promise((resolve) => { + app.get( + '/ws-error', + upgradeWebSocket(() => ({ + onError(evt, ws) { + resolve(true) + }, + })) + ) + }) + + it('Should call onError when error event fires', async () => { + await app.request('/ws-error', { + headers: { + Upgrade: 'websocket', + }, + }) + + server.dispatchEvent(new Event('error')) + expect(await error).toBe(true) + }) + + const sendWsRef: Promise = new Promise((resolve) => + app.get( + '/ws-send', + upgradeWebSocket(() => ({ + onMessage(evt, ws) { + resolve(ws) + }, + })) + ) + ) + + it('Should call server.send when ws.send is called', async () => { + // @ts-expect-error adding a mock method for the test + server.send = vi.fn() + + await app.request('/ws-send', { + headers: { + Upgrade: 'websocket', + }, + }) + server.dispatchEvent(new MessageEvent('message', { data: 'trigger' })) + + const ws = await sendWsRef + ws.send('hello') + + // @ts-expect-error mock method + expect(server.send).toHaveBeenCalledWith('hello') + }) + + const closeWsRef: Promise = new Promise((resolve) => + app.get( + '/ws-close-call', + upgradeWebSocket(() => ({ + onMessage(evt, ws) { + resolve(ws) + }, + })) + ) + ) + + it('Should call server.close when ws.close is called', async () => { + // @ts-expect-error adding a mock method for the test + server.close = vi.fn() + + await app.request('/ws-close-call', { + headers: { + Upgrade: 'websocket', + }, + }) + server.dispatchEvent(new MessageEvent('message', { data: 'trigger' })) + + const ws = await closeWsRef + ws.close(1000, 'done') + + // @ts-expect-error mock method + expect(server.close).toHaveBeenCalledWith(1000, 'done') }) }) From c85aead088659b98b8d05a1187a07d064e12ffe6 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Fri, 24 Jul 2026 10:57:31 +0900 Subject: [PATCH 5/7] fix: use `Object.create(null)` when parsing query, headers, and params (#5161) --- src/request.test.ts | 6 ++++++ src/request.ts | 2 +- src/utils/accept.test.ts | 6 ++++++ src/utils/accept.ts | 2 +- src/utils/url.test.ts | 9 +++++++++ src/utils/url.ts | 2 +- 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/request.test.ts b/src/request.test.ts index 8b894cba4e..7315d94f67 100644 --- a/src/request.test.ts +++ b/src/request.test.ts @@ -236,6 +236,12 @@ describe('headers', () => { expect(req.header('Content-Type')).toBe('application/json') expect(req.header('ApiKey')).toBe('abc') }) + + test('req.header() is not affected by a `__proto__` header name', () => { + const req = new HonoRequest(new Request('http://localhost', { headers: { __proto__: 'evil' } })) + const headers = req.header() + expect(Object.getPrototypeOf(headers)).toBeNull() + }) }) const text = '{"foo":"bar"}' diff --git a/src/request.ts b/src/request.ts index c3cfc32ba1..8a3c20cc52 100644 --- a/src/request.ts +++ b/src/request.ts @@ -190,7 +190,7 @@ export class HonoRequest

{ return this.raw.headers.get(name) ?? undefined } - const headerData: Record = {} + const headerData: Record = Object.create(null) this.raw.headers.forEach((value, key) => { headerData[key] = value }) diff --git a/src/utils/accept.test.ts b/src/utils/accept.test.ts index 145a6705fe..8b2aae3a0c 100644 --- a/src/utils/accept.test.ts +++ b/src/utils/accept.test.ts @@ -70,6 +70,12 @@ describe('parseAccept Comprehensive Tests', () => { expect(result[0].params.key).toBe('2') expect(result[0].params.KEY).toBe('3') }) + + test('treats `__proto__` as a normal parameter without changing the prototype', () => { + const result = parseAccept('text/html;__proto__=x') + expect(Object.getPrototypeOf(result[0].params)).toBeNull() + expect(result[0].params['__proto__']).toBe('x') + }) }) describe('Media Type Edge Cases', () => { diff --git a/src/utils/accept.ts b/src/utils/accept.ts index 87f98e35db..0d7a9f880f 100644 --- a/src/utils/accept.ts +++ b/src/utils/accept.ts @@ -162,7 +162,7 @@ const getNextAcceptValue = ( ): [number, Accept | undefined] => { const accept: Accept = { type: '', - params: {}, + params: Object.create(null), q: 1, } startIndex = consumeWhitespace(acceptHeader, startIndex) diff --git a/src/utils/url.test.ts b/src/utils/url.test.ts index c30213c079..7274d5db73 100644 --- a/src/utils/url.test.ts +++ b/src/utils/url.test.ts @@ -359,5 +359,14 @@ describe('url', () => { toString: [''], }) }) + + it('should treat `__proto__` as a normal key without changing the prototype', () => { + const params = getQueryParams('http://example.com/?__proto__=a&__proto__=b') as Record< + string, + string[] + > + expect(Object.getPrototypeOf(params)).toBeNull() + expect(params['__proto__']).toEqual(['a', 'b']) + }) }) }) diff --git a/src/utils/url.ts b/src/utils/url.ts index b4941663ca..65698d2ea9 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -252,7 +252,7 @@ const _getQueryParam = ( // fallback to default routine } - const results: Record | Record = {} + const results: Record | Record = Object.create(null) encoded ??= /[%+]/.test(url) let keyIndex = url.indexOf('?', 8) From 402eb3abe561914f41ee0f8e37f1d7f211f1ee51 Mon Sep 17 00:00:00 2001 From: Latte Date: Fri, 24 Jul 2026 17:48:48 +0900 Subject: [PATCH 6/7] fix(secure-headers): keep CSP callbacks scoped to their header (#5147) * test(secure-headers): cover combined CSP callbacks * fix(secure-headers): isolate CSP callbacks by header * update the tests --------- Co-authored-by: Yusuke Wada --- src/middleware/secure-headers/index.test.ts | 75 +++++++++++++++++++ .../secure-headers/secure-headers.ts | 18 +++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/middleware/secure-headers/index.test.ts b/src/middleware/secure-headers/index.test.ts index b7cded3a8c..e4dc4c8fbe 100644 --- a/src/middleware/secure-headers/index.test.ts +++ b/src/middleware/secure-headers/index.test.ts @@ -481,6 +481,81 @@ describe('Secure Headers Middleware', () => { }) }) + describe('CSP with combined modes', () => { + it('keeps the enforced policy when report-only uses a nonce', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + contentSecurityPolicy: { + defaultSrc: ["'self'"], + }, + contentSecurityPolicyReportOnly: { + scriptSrc: ["'self'", NONCE], + }, + }) + ) + app.all('*', (c) => c.text('test')) + + const res = await app.request('/test') + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Security-Policy')).toBe("default-src 'self'") + expect(res.headers.get('Content-Security-Policy-Report-Only')).toMatch( + /^script-src 'self' 'nonce-[a-zA-Z0-9+/]+=*'$/ + ) + }) + + it('keeps the report-only policy when the enforced policy uses a nonce', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + contentSecurityPolicy: { + scriptSrc: ["'self'", NONCE], + }, + contentSecurityPolicyReportOnly: { + defaultSrc: ["'self'"], + }, + }) + ) + app.all('*', (c) => c.text('test')) + + const res = await app.request('/test') + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Security-Policy')).toMatch( + /^script-src 'self' 'nonce-[a-zA-Z0-9+/]+=*'$/ + ) + expect(res.headers.get('Content-Security-Policy-Report-Only')).toBe("default-src 'self'") + }) + + it('supports nonces in both policies', async () => { + const app = new Hono() + app.use( + '/test', + secureHeaders({ + contentSecurityPolicy: { + scriptSrc: ["'self'", NONCE], + }, + contentSecurityPolicyReportOnly: { + styleSrc: ["'self'", NONCE], + }, + }) + ) + app.all('*', (c) => c.text('test')) + + const res = await app.request('/test') + const csp = res.headers.get('Content-Security-Policy') + const reportOnly = res.headers.get('Content-Security-Policy-Report-Only') + const nonce = csp?.match(/'nonce-([^']+)'/)?.[1] + + expect(res.status).toBe(200) + expect(nonce).toBeTruthy() + expect(reportOnly).toContain(`'nonce-${nonce}'`) + }) + }) + // OUR NEW REPORT-URI TESTS describe('CSP report-uri directive', () => { it('should set report-uri with single endpoint', async () => { diff --git a/src/middleware/secure-headers/secure-headers.ts b/src/middleware/secure-headers/secure-headers.ts index cb81a0fdf3..8e7eb4e624 100644 --- a/src/middleware/secure-headers/secure-headers.ts +++ b/src/middleware/secure-headers/secure-headers.ts @@ -182,7 +182,10 @@ export const secureHeaders = (customOptions?: SecureHeadersOptions): MiddlewareH const callbacks: SecureHeadersCallback[] = [] if (options.contentSecurityPolicy) { - const [callback, value] = getCSPDirectives(options.contentSecurityPolicy) + const [callback, value] = getCSPDirectives( + options.contentSecurityPolicy, + 'Content-Security-Policy' + ) if (callback) { callbacks.push(callback) } @@ -190,7 +193,10 @@ export const secureHeaders = (customOptions?: SecureHeadersOptions): MiddlewareH } if (options.contentSecurityPolicyReportOnly) { - const [callback, value] = getCSPDirectives(options.contentSecurityPolicyReportOnly) + const [callback, value] = getCSPDirectives( + options.contentSecurityPolicyReportOnly, + 'Content-Security-Policy-Report-Only' + ) if (callback) { callbacks.push(callback) } @@ -238,7 +244,8 @@ function getFilteredHeaders(options: SecureHeadersOptions): [string, string][] { } function getCSPDirectives( - contentSecurityPolicy: ContentSecurityPolicyOptions + contentSecurityPolicy: ContentSecurityPolicyOptions, + headerName: 'Content-Security-Policy' | 'Content-Security-Policy-Report-Only' ): [SecureHeadersCallback | undefined, string | string[]] { const callbacks: ((ctx: Context, values: string[]) => void)[] = [] const resultValues: string[] = [] @@ -270,10 +277,7 @@ function getCSPDirectives( : [ (ctx, headersToSet) => headersToSet.map((values) => { - if ( - values[0] === 'Content-Security-Policy' || - values[0] === 'Content-Security-Policy-Report-Only' - ) { + if (values[0] === headerName) { const clone = values[1].slice() as unknown as string[] callbacks.forEach((cb) => { cb(ctx, clone) From 26d8e42bfff7eb78fc323a01e1d02d63b9dbcc72 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Fri, 24 Jul 2026 17:54:29 +0900 Subject: [PATCH 7/7] 4.12.32 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 95df31ac58..847317bae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hono", - "version": "4.12.31", + "version": "4.12.32", "description": "Web framework built on Web Standards", "main": "dist/cjs/index.js", "type": "module",