From 3d3a317d2670470de7d37dc73909d513653632dd Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Tue, 19 May 2026 13:37:26 -0700 Subject: [PATCH 1/3] Harden HTTP proxy header handling Block hop-by-hop response headers from being forwarded through the HTTP proxy and handle invalid x-ms-client-principal payloads without failing the invocation. Co-authored-by: Dobby --- src/http/extractHttpUserFromHeaders.ts | 12 ++++++- src/http/httpProxy.ts | 21 +++++++++++- test/http/extractHttpUserFromHeaders.test.ts | 10 ++++++ test/http/httpProxy.test.ts | 35 ++++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 test/http/httpProxy.test.ts diff --git a/src/http/extractHttpUserFromHeaders.ts b/src/http/extractHttpUserFromHeaders.ts index 616cdac..587c678 100644 --- a/src/http/extractHttpUserFromHeaders.ts +++ b/src/http/extractHttpUserFromHeaders.ts @@ -3,6 +3,7 @@ import { HttpRequestUser } from '@azure/functions'; import { nonNullValue } from '../utils/nonNull'; +import { workerSystemLog } from '../utils/workerSystemLog'; /* grandfathered in. Should fix when possible */ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ @@ -12,7 +13,16 @@ export function extractHttpUserFromHeaders(headers: Headers): HttpRequestUser | const clientPrincipal = headers.get('x-ms-client-principal'); if (clientPrincipal) { - const claimsPrincipalData = JSON.parse(Buffer.from(clientPrincipal, 'base64').toString('utf-8')); + let claimsPrincipalData: any; + try { + claimsPrincipalData = JSON.parse(Buffer.from(clientPrincipal, 'base64').toString('utf-8')); + } catch (err) { + workerSystemLog( + 'warning', + `Failed to parse x-ms-client-principal header: ${err instanceof Error ? err.message : String(err)}` + ); + return null; + } if (claimsPrincipalData['identityProvider']) { user = { diff --git a/src/http/httpProxy.ts b/src/http/httpProxy.ts index b1c683b..ab80e71 100644 --- a/src/http/httpProxy.ts +++ b/src/http/httpProxy.ts @@ -15,6 +15,19 @@ const responses: Record = {}; const minPort = 55000; const maxPort = 55025; +const blockedProxyResponseHeaders = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'trailers', + 'transfer-encoding', + 'upgrade', + 'content-length', +]); + const invocRequestEmitter = new EventEmitter(); export async function waitForProxyRequest(invocationId: string): Promise { @@ -40,7 +53,9 @@ export async function sendProxyResponse(invocationId: string, userRes: HttpRespo const proxyRes = nonNullProp(responses, invocationId); delete responses[invocationId]; for (const [key, val] of userRes.headers.entries()) { - proxyRes.setHeader(key, val); + if (isAllowedProxyResponseHeader(key)) { + proxyRes.setHeader(key, val); + } } proxyRes.setHeader(invocationIdHeader, invocationId); proxyRes.statusCode = userRes.status; @@ -57,6 +72,10 @@ export async function sendProxyResponse(invocationId: string, userRes: HttpRespo proxyRes.end(); } +export function isAllowedProxyResponseHeader(headerName: string): boolean { + return !blockedProxyResponseHeaders.has(headerName.toLowerCase()); +} + function setCookies(userRes: HttpResponse, proxyRes: http.ServerResponse): void { const serializedCookies: string[] = userRes.cookies.map((c) => { let sameSite: true | false | 'lax' | 'strict' | 'none' | undefined; diff --git a/test/http/extractHttpUserFromHeaders.test.ts b/test/http/extractHttpUserFromHeaders.test.ts index 4eff6e6..12c61a1 100644 --- a/test/http/extractHttpUserFromHeaders.test.ts +++ b/test/http/extractHttpUserFromHeaders.test.ts @@ -82,4 +82,14 @@ describe('Extract Http User Claims Principal from Headers', () => { expect(user).to.be.null; }); + + it('Returns null when client principal header is not valid JSON', () => { + const headers: Headers = new Headers({ + 'x-ms-client-principal': Buffer.from('not json').toString('base64'), + }); + + const user: HttpRequestUser | null = extractHttpUserFromHeaders(headers); + + expect(user).to.be.null; + }); }); diff --git a/test/http/httpProxy.test.ts b/test/http/httpProxy.test.ts new file mode 100644 index 0000000..955a0c8 --- /dev/null +++ b/test/http/httpProxy.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import 'mocha'; +import { expect } from 'chai'; +import { isAllowedProxyResponseHeader } from '../../src/http/httpProxy'; + +describe('Http proxy', () => { + it('Blocks hop-by-hop response headers', () => { + const blockedHeaders = [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'content-length', + ]; + + for (const header of blockedHeaders) { + expect(isAllowedProxyResponseHeader(header), header).to.be.false; + expect(isAllowedProxyResponseHeader(header.toUpperCase()), header).to.be.false; + } + }); + + it('Allows end-to-end response headers', () => { + const allowedHeaders = ['content-type', 'cache-control', 'set-cookie', 'x-frame-options']; + + for (const header of allowedHeaders) { + expect(isAllowedProxyResponseHeader(header), header).to.be.true; + } + }); +}); From b7d229784ad7ba9ba479bc595ad93e95b8b6ebab Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Wed, 20 May 2026 00:01:34 -0700 Subject: [PATCH 2/3] Validate parsed client principal shape --- src/http/extractHttpUserFromHeaders.ts | 9 +++++++++ test/http/extractHttpUserFromHeaders.test.ts | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/http/extractHttpUserFromHeaders.ts b/src/http/extractHttpUserFromHeaders.ts index 587c678..cd950bb 100644 --- a/src/http/extractHttpUserFromHeaders.ts +++ b/src/http/extractHttpUserFromHeaders.ts @@ -24,6 +24,15 @@ export function extractHttpUserFromHeaders(headers: Headers): HttpRequestUser | return null; } + if ( + claimsPrincipalData === null || + typeof claimsPrincipalData !== 'object' || + Array.isArray(claimsPrincipalData) + ) { + workerSystemLog('warning', 'Parsed x-ms-client-principal header was not a JSON object.'); + return null; + } + if (claimsPrincipalData['identityProvider']) { user = { type: 'StaticWebApps', diff --git a/test/http/extractHttpUserFromHeaders.test.ts b/test/http/extractHttpUserFromHeaders.test.ts index 12c61a1..24ea741 100644 --- a/test/http/extractHttpUserFromHeaders.test.ts +++ b/test/http/extractHttpUserFromHeaders.test.ts @@ -92,4 +92,24 @@ describe('Extract Http User Claims Principal from Headers', () => { expect(user).to.be.null; }); + + it('Returns null when client principal header parses to null', () => { + const headers: Headers = new Headers({ + 'x-ms-client-principal': Buffer.from(JSON.stringify(null)).toString('base64'), + }); + + const user: HttpRequestUser | null = extractHttpUserFromHeaders(headers); + + expect(user).to.be.null; + }); + + it('Returns null when client principal header parses to a string', () => { + const headers: Headers = new Headers({ + 'x-ms-client-principal': Buffer.from(JSON.stringify('not an object')).toString('base64'), + }); + + const user: HttpRequestUser | null = extractHttpUserFromHeaders(headers); + + expect(user).to.be.null; + }); }); From 5b129fa511032c6890e1e360bc41d92a95ea5bc0 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Fri, 22 May 2026 11:05:31 -0700 Subject: [PATCH 3/3] Preserve Content-Length in HTTP proxy Allow Content-Length through the HTTP proxy while continuing to block hop-by-hop headers. Also strip response headers named by the Connection header so connection-specific fields do not cross the proxy boundary. Co-authored-by: Dobby --- src/http/httpProxy.ts | 21 +++++++++++++++++---- test/http/httpProxy.test.ts | 12 ++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/http/httpProxy.ts b/src/http/httpProxy.ts index ab80e71..9270b08 100644 --- a/src/http/httpProxy.ts +++ b/src/http/httpProxy.ts @@ -25,7 +25,6 @@ const blockedProxyResponseHeaders = new Set([ 'trailers', 'transfer-encoding', 'upgrade', - 'content-length', ]); const invocRequestEmitter = new EventEmitter(); @@ -52,8 +51,9 @@ const invocationIdHeader = 'x-ms-invocation-id'; export async function sendProxyResponse(invocationId: string, userRes: HttpResponse): Promise { const proxyRes = nonNullProp(responses, invocationId); delete responses[invocationId]; + const connectionHeader = userRes.headers.get('connection'); for (const [key, val] of userRes.headers.entries()) { - if (isAllowedProxyResponseHeader(key)) { + if (isAllowedProxyResponseHeader(key, connectionHeader)) { proxyRes.setHeader(key, val); } } @@ -72,8 +72,21 @@ export async function sendProxyResponse(invocationId: string, userRes: HttpRespo proxyRes.end(); } -export function isAllowedProxyResponseHeader(headerName: string): boolean { - return !blockedProxyResponseHeaders.has(headerName.toLowerCase()); +export function isAllowedProxyResponseHeader(headerName: string, connectionHeader?: string | null): boolean { + const normalizedHeaderName = headerName.toLowerCase(); + return ( + !blockedProxyResponseHeaders.has(normalizedHeaderName) && + !getConnectionHeaderNames(connectionHeader).has(normalizedHeaderName) + ); +} + +function getConnectionHeaderNames(connectionHeader?: string | null): Set { + return new Set( + connectionHeader + ?.split(',') + .map((headerName) => headerName.trim().toLowerCase()) + .filter((headerName) => headerName.length > 0) + ); } function setCookies(userRes: HttpResponse, proxyRes: http.ServerResponse): void { diff --git a/test/http/httpProxy.test.ts b/test/http/httpProxy.test.ts index 955a0c8..cb7440d 100644 --- a/test/http/httpProxy.test.ts +++ b/test/http/httpProxy.test.ts @@ -14,9 +14,9 @@ describe('Http proxy', () => { 'proxy-authorization', 'te', 'trailer', + 'trailers', 'transfer-encoding', 'upgrade', - 'content-length', ]; for (const header of blockedHeaders) { @@ -26,10 +26,18 @@ describe('Http proxy', () => { }); it('Allows end-to-end response headers', () => { - const allowedHeaders = ['content-type', 'cache-control', 'set-cookie', 'x-frame-options']; + const allowedHeaders = ['content-type', 'cache-control', 'set-cookie', 'x-frame-options', 'content-length']; for (const header of allowedHeaders) { expect(isAllowedProxyResponseHeader(header), header).to.be.true; } }); + + it('Blocks headers named by the Connection response header', () => { + const connectionHeader = 'keep-alive, x-private-hop, X-Another-Hop'; + + expect(isAllowedProxyResponseHeader('x-private-hop', connectionHeader)).to.be.false; + expect(isAllowedProxyResponseHeader('x-another-hop', connectionHeader)).to.be.false; + expect(isAllowedProxyResponseHeader('content-type', connectionHeader)).to.be.true; + }); });