From f408dd453c86aa44548cb77a326e9e0bf9a6f890 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Mon, 4 Aug 2025 18:43:24 +0900 Subject: [PATCH 1/2] feat(request): support `x-forwarded-proto` header for protocol detection --- src/request.ts | 14 ++++++- test/request.test.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/request.ts b/src/request.ts index 61664fb..0e39982 100644 --- a/src/request.ts +++ b/src/request.ts @@ -216,7 +216,19 @@ export const newRequest = ( throw new RequestError('Unsupported scheme') } } else { - scheme = incoming.socket && (incoming.socket as TLSSocket).encrypted ? 'https' : 'http' + const forwardedProto = incoming.headers['x-forwarded-proto'] + const proto = forwardedProto + ? (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto) + .split(',', 1)[0] + .trim() + .toLowerCase() + : null + scheme = + proto === 'https' || proto === 'http' + ? proto + : incoming.socket && (incoming.socket as TLSSocket).encrypted + ? 'https' + : 'http' } const url = new URL(`${scheme}://${host}${incomingUrl}`) diff --git a/test/request.test.ts b/test/request.test.ts index fc5d7cd..8341829 100644 --- a/test/request.test.ts +++ b/test/request.test.ts @@ -3,6 +3,7 @@ import type { ServerHttp2Stream } from 'node:http2' import { Http2ServerRequest } from 'node:http2' import { Socket } from 'node:net' import { Duplex } from 'node:stream' +import type { TLSSocket } from 'node:tls' import { newRequest, Request as LightweightRequest, @@ -259,6 +260,101 @@ describe('Request', () => { }).toThrow(RequestError) }) }) + + describe('x-forwarded-proto header', () => { + it('should use https scheme when x-forwarded-proto is https', async () => { + const req = newRequest( + // @ts-expect-error x-forwarded-proto is not in IncomingHttpHeaders + { + headers: { + host: 'localhost', + 'x-forwarded-proto': 'https', + }, + url: '/foo.txt', + } + ) + expect(req).toBeInstanceOf(GlobalRequest) + expect(req.url).toBe('https://localhost/foo.txt') + }) + + it('should use http scheme when x-forwarded-proto is http', async () => { + const req = newRequest( + // @ts-expect-error x-forwarded-proto is not in IncomingHttpHeaders + { + headers: { + host: 'localhost', + 'x-forwarded-proto': 'http', + }, + url: '/foo.txt', + } + ) + expect(req).toBeInstanceOf(GlobalRequest) + expect(req.url).toBe('http://localhost/foo.txt') + }) + + it('should use first value when x-forwarded-proto has multiple values', async () => { + const req = newRequest( + // @ts-expect-error x-forwarded-proto is not in IncomingHttpHeaders + { + headers: { + host: 'localhost', + 'x-forwarded-proto': 'https,http', + }, + url: '/foo.txt', + } + ) + expect(req).toBeInstanceOf(global.Request) + expect(req.url).toBe('https://localhost/foo.txt') + }) + + it('should handle x-forwarded-proto with spaces around values', async () => { + const req = newRequest( + // @ts-expect-error x-forwarded-proto is not in IncomingHttpHeaders + { + headers: { + host: 'localhost', + 'x-forwarded-proto': ' https , http ', + }, + url: '/foo.txt', + } + ) + expect(req).toBeInstanceOf(GlobalRequest) + expect(req.url).toBe('https://localhost/foo.txt') + }) + + it('should handle array of x-forwarded-proto values', async () => { + const req = newRequest( + // @ts-expect-error x-forwarded-proto is not in IncomingHttpHeaders + { + headers: { + host: 'localhost', + 'x-forwarded-proto': ['https', 'http'], + }, + url: '/foo.txt', + } + ) + expect(req).toBeInstanceOf(GlobalRequest) + expect(req.url).toBe('https://localhost/foo.txt') + }) + + it('should fallback to socket encryption when x-forwarded-proto is invalid', async () => { + const socket = new Socket() as TLSSocket + socket.encrypted = true + const req = newRequest( + // @ts-expect-error x-forwarded-proto is not in IncomingHttpHeaders + { + socket, + headers: { + host: 'localhost', + 'x-forwarded-proto': 'invalid-protocol', + }, + url: '/foo.txt', + } + ) + expect(req).toBeInstanceOf(GlobalRequest) + expect(req.url).toBe('https://localhost/foo.txt') + }) + }) }) describe('GlobalRequest', () => { From 68a21ad3cf6f0fde839187cf933c0336fe902e8b Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Mon, 4 Aug 2025 18:52:30 +0900 Subject: [PATCH 2/2] fix the logic --- src/request.ts | 27 ++++++++++++++------------- test/request.test.ts | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/request.ts b/src/request.ts index 0e39982..dc217df 100644 --- a/src/request.ts +++ b/src/request.ts @@ -216,19 +216,20 @@ export const newRequest = ( throw new RequestError('Unsupported scheme') } } else { - const forwardedProto = incoming.headers['x-forwarded-proto'] - const proto = forwardedProto - ? (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto) - .split(',', 1)[0] - .trim() - .toLowerCase() - : null - scheme = - proto === 'https' || proto === 'http' - ? proto - : incoming.socket && (incoming.socket as TLSSocket).encrypted - ? 'https' - : 'http' + // Check socket encryption first (most trusted) + if (incoming.socket && (incoming.socket as TLSSocket).encrypted) { + scheme = 'https' + } else { + // Check x-forwarded-proto header + const forwardedProto = incoming.headers['x-forwarded-proto'] + const proto = forwardedProto + ? (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto) + .split(',', 1)[0] + .trim() + .toLowerCase() + : null + scheme = proto === 'https' || proto === 'http' ? proto : 'http' + } } const url = new URL(`${scheme}://${host}${incomingUrl}`) diff --git a/test/request.test.ts b/test/request.test.ts index 8341829..5b0319a 100644 --- a/test/request.test.ts +++ b/test/request.test.ts @@ -354,6 +354,24 @@ describe('Request', () => { expect(req).toBeInstanceOf(GlobalRequest) expect(req.url).toBe('https://localhost/foo.txt') }) + + it('should prioritize encrypted socket over x-forwarded-proto header', async () => { + const socket = new Socket() as TLSSocket + socket.encrypted = true + const req = newRequest( + // @ts-expect-error x-forwarded-proto is not in IncomingHttpHeaders + { + socket, + headers: { + host: 'localhost', + 'x-forwarded-proto': 'http', // This should be ignored + }, + url: '/foo.txt', + } + ) + expect(req).toBeInstanceOf(GlobalRequest) + expect(req.url).toBe('https://localhost/foo.txt') // Should be https despite header + }) }) })