From b4b069ec514c8514dc70b0410bac23bf712413aa Mon Sep 17 00:00:00 2001 From: Simeon Nakov Date: Mon, 23 Jun 2025 16:16:59 +0300 Subject: [PATCH 1/3] feat: adds forwarded header to downstream MN requests + custom parsing for Forwarded Signed-off-by: Simeon Nakov --- .../relay/src/lib/clients/mirrorNodeClient.ts | 20 +++ .../relay/tests/lib/mirrorNodeClient.spec.ts | 109 ++++++++++++ packages/server/src/server.ts | 53 ++++++ .../tests/integration/proxyHeaders.spec.ts | 163 +++++++++++++++++- 4 files changed, 343 insertions(+), 2 deletions(-) diff --git a/packages/relay/src/lib/clients/mirrorNodeClient.ts b/packages/relay/src/lib/clients/mirrorNodeClient.ts index b476b2c7dd..cd96b595a4 100644 --- a/packages/relay/src/lib/clients/mirrorNodeClient.ts +++ b/packages/relay/src/lib/clients/mirrorNodeClient.ts @@ -323,6 +323,20 @@ export class MirrorNodeClient { return `${baseUrl}${MirrorNodeClient.API_V1_POST_FIX}`; } + /** + * Formats an IP address for RFC 7239 Forwarded header compliance. + * IPv6 addresses are wrapped in brackets as required by the specification. + * @param ip - The IP address to format + * @returns The RFC 7239 compliant formatted IP address + */ + private formatIpForForwardedHeader(ip: string): string { + // IPv6 addresses must be wrapped in brackets per RFC 7239 + if (ip.includes(':') && !ip.startsWith('[')) { + return `[${ip}]`; + } + return ip; + } + private async request( path: string, pathLabel: string, @@ -341,6 +355,12 @@ export class MirrorNodeClient { signal: controller.signal, }; + // Add Forwarded header with client IP if available + if (requestDetails.ipAddress && requestDetails.ipAddress.trim() !== '') { + const formattedClientIp = this.formatIpForForwardedHeader(requestDetails.ipAddress); + axiosRequestConfig.headers!['Forwarded'] = `for="${formattedClientIp}"`; + } + // request specific config for axios-retry if (retries != null) { axiosRequestConfig['axios-retry'] = { retries }; diff --git a/packages/relay/tests/lib/mirrorNodeClient.spec.ts b/packages/relay/tests/lib/mirrorNodeClient.spec.ts index 4facf57a70..14501cad34 100644 --- a/packages/relay/tests/lib/mirrorNodeClient.spec.ts +++ b/packages/relay/tests/lib/mirrorNodeClient.spec.ts @@ -54,6 +54,115 @@ describe('MirrorNodeClient', async function () { await cacheService.clear(requestDetails); }); + describe('Forwarded Header', () => { + const testAccount = '0.0.123'; + const mockAccountResponse = { account: testAccount }; + + it('should add Forwarded header with IPv4 address', async () => { + const ipv4Address = '192.168.1.1'; + const requestDetailsWithIPv4 = new RequestDetails({ + requestId: 'testRequest', + ipAddress: ipv4Address, + }); + + mock.onGet(`accounts/${testAccount}${noTransactions}`).reply(function (config) { + expect(config.headers!['Forwarded']).to.equal(`for="${ipv4Address}"`); + return [200, JSON.stringify(mockAccountResponse)]; + }); + + const result = await mirrorNodeInstance.getAccount(testAccount, requestDetailsWithIPv4); + expect(result).to.exist; + expect(result.account).to.equal(testAccount); + }); + + it('should add Forwarded header with IPv6 address wrapped in brackets', async () => { + const ipv6Address = '2001:db8::1'; + const expectedForwardedValue = `for="[${ipv6Address}]"`; + const requestDetailsWithIPv6 = new RequestDetails({ + requestId: 'testRequest', + ipAddress: ipv6Address, + }); + + mock.onGet(`accounts/${testAccount}${noTransactions}`).reply(function (config) { + expect(config.headers!['Forwarded']).to.equal(expectedForwardedValue); + return [200, JSON.stringify(mockAccountResponse)]; + }); + + const result = await mirrorNodeInstance.getAccount(testAccount, requestDetailsWithIPv6); + expect(result).to.exist; + expect(result.account).to.equal(testAccount); + }); + + it('should not add Forwarded header when IP address is empty', async () => { + const requestDetailsWithoutIP = new RequestDetails({ + requestId: 'testRequest', + ipAddress: '', + }); + + mock.onGet(`accounts/${testAccount}${noTransactions}`).reply(function (config) { + expect(config.headers).to.not.have.property('Forwarded'); + return [200, JSON.stringify(mockAccountResponse)]; + }); + + const result = await mirrorNodeInstance.getAccount(testAccount, requestDetailsWithoutIP); + expect(result).to.exist; + expect(result.account).to.equal(testAccount); + }); + + it('should not add Forwarded header when IP address is null', async () => { + const requestDetailsWithoutIP = new RequestDetails({ + requestId: 'testRequest', + ipAddress: '', + }); + + mock.onGet(`accounts/${testAccount}${noTransactions}`).reply(function (config) { + expect(config.headers).to.not.have.property('Forwarded'); + return [200, JSON.stringify(mockAccountResponse)]; + }); + + const result = await mirrorNodeInstance.getAccount(testAccount, requestDetailsWithoutIP); + expect(result).to.exist; + expect(result!.account).to.equal(testAccount); + }); + + it('should add Forwarded header for POST requests', async () => { + const ipv4Address = '10.0.0.1'; + const requestDetailsWithIP = new RequestDetails({ + requestId: 'testRequest', + ipAddress: ipv4Address, + }); + const mockCallData = { data: 'test' }; + const mockResponse = { result: '0x123' }; + + mock.onPost('contracts/call', mockCallData).reply(function (config) { + expect(config.headers!['Forwarded']).to.equal(`for="${ipv4Address}"`); + return [200, JSON.stringify(mockResponse)]; + }); + + const result = await mirrorNodeInstance.postContractCall(mockCallData, requestDetailsWithIP); + expect(result).to.exist; + expect(result!.result).to.equal(mockResponse.result); + }); + + it('should not modify IPv6 address that already has brackets', async () => { + const ipv6AddressWithBrackets = '[2001:db8::1]'; + const expectedForwardedValue = `for="${ipv6AddressWithBrackets}"`; + const requestDetailsWithIPv6 = new RequestDetails({ + requestId: 'testRequest', + ipAddress: ipv6AddressWithBrackets, + }); + + mock.onGet(`accounts/${testAccount}${noTransactions}`).reply(function (config) { + expect(config.headers!['Forwarded']).to.equal(expectedForwardedValue); + return [200, JSON.stringify(mockAccountResponse)]; + }); + + const result = await mirrorNodeInstance.getAccount(testAccount, requestDetailsWithIPv6); + expect(result).to.exist; + expect(result.account).to.equal(testAccount); + }); + }); + describe('handleError', async () => { const CONTRACT_CALL_ENDPOINT = 'contracts/call'; const nullResponseCodes = [404]; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 6165b7c7bf..45c17209fc 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -49,6 +49,59 @@ const methodResponseHistogram = new Histogram({ // enable proxy support to trust proxy-added headers for client IP detection app.getKoaApp().proxy = true; +// Middleware to parse RFC 7239 Forwarded header and make it compatible with Koa's X-Forwarded-For parsing +app.getKoaApp().use(async (ctx, next) => { + // Only process if X-Forwarded-For doesn't exist but Forwarded does + if (!ctx.request.headers['x-forwarded-for'] && ctx.request.headers['forwarded']) { + const forwardedHeader = ctx.request.headers['forwarded'] as string; + + // Parse the Forwarded header to extract the client IP + // Format: Forwarded: for="192.168.1.1";by="10.0.0.1", for="203.0.113.1";by="10.0.0.2" + const clientIp = parseForwardedHeader(forwardedHeader); + + if (clientIp) { + // Set X-Forwarded-For so Koa can parse it normally + ctx.request.headers['x-forwarded-for'] = clientIp; + } + } + + await next(); +}); + +/** + * Parse RFC 7239 Forwarded header to extract the original client IP + * @param forwardedHeader - The Forwarded header value + * @returns The client IP address or null if not found + */ +function parseForwardedHeader(forwardedHeader: string): string | null { + try { + // Split by comma to handle multiple forwarded entries + const entries = forwardedHeader.split(','); + + // Take the first entry (original client) + const firstEntry = entries[0]?.trim(); + if (!firstEntry) return null; + + // Extract the 'for' parameter value + // Matches: for="192.168.1.1" or for=[2001:db8::1] or for=192.168.1.1 + const forMatch = firstEntry.match(/for=(?:"([^"]+)"|(\[[^\]]+\])|([^;,\s]+))/i); + if (!forMatch) return null; + + // Get the IP from whichever capture group matched + let ip = forMatch[1] || forMatch[2] || forMatch[3]; + + // Remove brackets from IPv6 addresses for X-Forwarded-For compatibility + if (ip?.startsWith('[') && ip.endsWith(']')) { + ip = ip.slice(1, -1); + } + + return ip || null; + } catch (error) { + // If parsing fails, return null to avoid breaking the request + return null; + } +} + // set cors app.getKoaApp().use(cors()); diff --git a/packages/server/tests/integration/proxyHeaders.spec.ts b/packages/server/tests/integration/proxyHeaders.spec.ts index 1f0b883b6d..a6966047f1 100644 --- a/packages/server/tests/integration/proxyHeaders.spec.ts +++ b/packages/server/tests/integration/proxyHeaders.spec.ts @@ -11,10 +11,14 @@ import { ConfigServiceTestHelper } from '../../../config-service/tests/configSer ConfigServiceTestHelper.appendEnvsFromPath(__dirname + '/test.env'); -import { overrideEnvsInMochaDescribe, useInMemoryRedisServer } from '../../../relay/tests/helpers'; +import { + overrideEnvsInMochaDescribe, + useInMemoryRedisServer, + withOverriddenEnvsInMochaTest, +} from '../../../relay/tests/helpers'; import RelayCalls from '../../tests/helpers/constants'; -describe('X-Forwarded-For Header Integration Tests', function () { +describe('Proxy Headers Integration Tests', function () { const logger = pino({ level: 'silent' }); // Use in-memory Redis server for CI compatibility @@ -36,6 +40,12 @@ describe('X-Forwarded-For Header Integration Tests', function () { const TEST_IP_C = '192.168.3.100'; const TEST_IP_D = '192.168.4.100'; const TEST_IP_E = '192.168.5.100'; + const TEST_IP_F = '192.168.6.100'; + const TEST_IP_G = '192.168.7.100'; + const TEST_IP_H = '192.168.8.100'; + const TEST_IP_I = '192.168.9.100'; + const TEST_IP_J = '192.168.10.100'; + const TEST_IPV6 = '2001:db8::1'; const TEST_METHOD = RelayCalls.ETH_ENDPOINTS.ETH_CHAIN_ID; before(function () { @@ -87,6 +97,14 @@ describe('X-Forwarded-For Header Integration Tests', function () { return testClient.post('/', createRequestWithIP(id, '')); } + async function makeRequestWithForwardedHeader(forwardedValue: string, id: string = '1') { + return testClient.post('/', createRequestWithIP(id, ''), { + headers: { + Forwarded: forwardedValue, + }, + }); + } + it('should use X-Forwarded-For header IP for rate limiting when app.proxy is true', async function () { // Make requests up to the rate limit for IP_A using X-Forwarded-For header const responses: AxiosResponse[] = []; @@ -219,4 +237,145 @@ describe('X-Forwarded-For Header Integration Tests', function () { expect(error.response.data.error.code).to.eq(-32605); } }); + + describe('Forwarded Header Tests', function () { + it('should parse RFC 7239 Forwarded header with quoted IP and use for rate limiting', async function () { + // Test with quoted IP format: for="192.168.6.100" + const forwardedHeader = `for="${TEST_IP_F}"`; + + // Make requests up to the rate limit + for (let i = 1; i <= 3; i++) { + const response = await makeRequestWithForwardedHeader(forwardedHeader, `f${i}`); + expect(response.status).to.eq(200); + expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); + } + + // The next request should be rate limited + try { + await makeRequestWithForwardedHeader(forwardedHeader, 'f4'); + expect.fail('Expected rate limit to be exceeded for Forwarded header IP'); + } catch (error: any) { + expect(error.response.status).to.eq(429); + expect(error.response.data.error.code).to.eq(-32605); + } + }); + + it('should parse Forwarded header with unquoted IP', async function () { + // Test with unquoted IP format: for=192.168.7.100 + const forwardedHeader = `for=${TEST_IP_G}`; + + // Make requests up to the rate limit + for (let i = 1; i <= 3; i++) { + const response = await makeRequestWithForwardedHeader(forwardedHeader, `g${i}`); + expect(response.status).to.eq(200); + expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); + } + + // The next request should be rate limited + try { + await makeRequestWithForwardedHeader(forwardedHeader, 'g4'); + expect.fail('Expected rate limit to be exceeded for unquoted Forwarded IP'); + } catch (error: any) { + expect(error.response.status).to.eq(429); + expect(error.response.data.error.code).to.eq(-32605); + } + }); + + it('should parse Forwarded header with IPv6 address in brackets', async function () { + // Test with IPv6 format: for="[2001:db8::1]" + const forwardedHeader = `for="[${TEST_IPV6}]"`; + + // Make requests up to the rate limit + for (let i = 1; i <= 3; i++) { + const response = await makeRequestWithForwardedHeader(forwardedHeader, `ipv6_${i}`); + expect(response.status).to.eq(200); + expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); + } + + // The next request should be rate limited + try { + await makeRequestWithForwardedHeader(forwardedHeader, 'ipv6_4'); + expect.fail('Expected rate limit to be exceeded for IPv6 Forwarded IP'); + } catch (error: any) { + expect(error.response.status).to.eq(429); + expect(error.response.data.error.code).to.eq(-32605); + } + }); + + it('should handle multiple entries in Forwarded header (use first IP)', async function () { + // Test with multiple forwarded entries - should use the first one + const forwardedHeader = `for="${TEST_IP_H}";by="10.0.0.1", for="203.0.113.1";by="10.0.0.2"`; + + // Make requests up to the rate limit + for (let i = 1; i <= 3; i++) { + const response = await makeRequestWithForwardedHeader(forwardedHeader, `h${i}`); + expect(response.status).to.eq(200); + expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); + } + + // The next request should be rate limited based on first IP (TEST_IP_H) + try { + await makeRequestWithForwardedHeader(forwardedHeader, 'h4'); + expect.fail('Expected rate limit to be exceeded for first IP in Forwarded header'); + } catch (error: any) { + expect(error.response.status).to.eq(429); + expect(error.response.data.error.code).to.eq(-32605); + } + }); + + it('should not override X-Forwarded-For when both headers are present', async function () { + // When both X-Forwarded-For and Forwarded are present, X-Forwarded-For should take precedence + const forwardedHeader = `for="${TEST_IP_I}"`; + + // Make requests with both headers - should be rate limited by X-Forwarded-For IP (TEST_IP_J) + for (let i = 1; i <= 3; i++) { + const response = await testClient.post('/', createRequestWithIP(`j${i}`, TEST_IP_J), { + headers: { + 'X-Forwarded-For': TEST_IP_J, + Forwarded: forwardedHeader, + }, + }); + expect(response.status).to.eq(200); + expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); + } + + // Should be rate limited based on X-Forwarded-For IP (TEST_IP_J), not Forwarded IP (TEST_IP_I) + try { + await testClient.post('/', createRequestWithIP('j4', TEST_IP_J), { + headers: { + 'X-Forwarded-For': TEST_IP_J, + Forwarded: forwardedHeader, + }, + }); + expect.fail('Expected rate limit to be exceeded for X-Forwarded-For IP'); + } catch (error: any) { + expect(error.response.status).to.eq(429); + expect(error.response.data.error.code).to.eq(-32605); + } + + // Verify that the Forwarded header IP (TEST_IP_I) is not rate limited + const response = await makeRequestWithForwardedHeader(forwardedHeader, 'i1'); + expect(response.status).to.eq(200); + expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); + }); + + withOverriddenEnvsInMochaTest({ RATE_LIMIT_DISABLED: 'true' }, () => { + it('should handle malformed Forwarded header gracefully', async function () { + // Test with malformed Forwarded header - should fall back to actual client IP + // Rate limiting disabled for this test to avoid conflicts with other tests using actual client IP + const malformedHeaders = [ + 'invalid_format', + 'for=', + 'for=""', + 'proto=https', // No 'for' parameter + ]; + + for (const malformedHeader of malformedHeaders) { + const response = await makeRequestWithForwardedHeader(malformedHeader, '1'); + expect(response.status).to.eq(200); + expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); + } + }); + }); + }); }); From 195d3bb37d4d80f7671539feb314fd9b573a57ff Mon Sep 17 00:00:00 2001 From: Simeon Nakov Date: Tue, 24 Jun 2025 11:36:22 +0300 Subject: [PATCH 2/3] remove potentially problematic regex Signed-off-by: Simeon Nakov --- packages/server/src/server.ts | 63 +++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 45c17209fc..420866f380 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -70,11 +70,22 @@ app.getKoaApp().use(async (ctx, next) => { /** * Parse RFC 7239 Forwarded header to extract the original client IP + * + * This function safely parses the Forwarded header without using regex to avoid + * ReDoS (Regular Expression Denial of Service) vulnerabilities. It includes + * input length limits and basic validation to prevent malicious input from + * causing performance issues. + * * @param forwardedHeader - The Forwarded header value * @returns The client IP address or null if not found */ function parseForwardedHeader(forwardedHeader: string): string | null { try { + // Limit input length to prevent DoS attacks + if (forwardedHeader.length > 1000) { + return null; + } + // Split by comma to handle multiple forwarded entries const entries = forwardedHeader.split(','); @@ -82,20 +93,52 @@ function parseForwardedHeader(forwardedHeader: string): string | null { const firstEntry = entries[0]?.trim(); if (!firstEntry) return null; - // Extract the 'for' parameter value - // Matches: for="192.168.1.1" or for=[2001:db8::1] or for=192.168.1.1 - const forMatch = firstEntry.match(/for=(?:"([^"]+)"|(\[[^\]]+\])|([^;,\s]+))/i); - if (!forMatch) return null; + // Find the 'for=' parameter using safe string parsing + const forIndex = firstEntry.toLowerCase().indexOf('for='); + if (forIndex === -1) return null; + + // Extract the value after 'for=' + const valueStart = forIndex + 4; // Length of 'for=' + if (valueStart >= firstEntry.length) return null; + + let ip: string; + const char = firstEntry[valueStart]; + + if (char === '"') { + // Quoted value: for="192.168.1.1" + const closeQuoteIndex = firstEntry.indexOf('"', valueStart + 1); + if (closeQuoteIndex === -1) return null; + ip = firstEntry.substring(valueStart + 1, closeQuoteIndex); + } else if (char === '[') { + // IPv6 in brackets: for=[2001:db8::1] + const closeBracketIndex = firstEntry.indexOf(']', valueStart + 1); + if (closeBracketIndex === -1) return null; + ip = firstEntry.substring(valueStart + 1, closeBracketIndex); + } else { + // Unquoted value: for=192.168.1.1 + let endIndex = valueStart; + while (endIndex < firstEntry.length) { + const c = firstEntry[endIndex]; + if (c === ';' || c === ',' || c === ' ' || c === '\t') { + break; + } + endIndex++; + } + ip = firstEntry.substring(valueStart, endIndex); + } - // Get the IP from whichever capture group matched - let ip = forMatch[1] || forMatch[2] || forMatch[3]; + // Basic validation: ensure we have a non-empty result + if (!ip || ip.length === 0 || ip.length > 45) { + // Max IPv6 length is 45 chars + return null; + } - // Remove brackets from IPv6 addresses for X-Forwarded-For compatibility - if (ip?.startsWith('[') && ip.endsWith(']')) { - ip = ip.slice(1, -1); + // Basic IP format validation (very permissive) + if (!/^[a-fA-F0-9:.]+$/.test(ip)) { + return null; } - return ip || null; + return ip; } catch (error) { // If parsing fails, return null to avoid breaking the request return null; From d9e138b575f7d8e652eb2d576898562e70e408b4 Mon Sep 17 00:00:00 2001 From: Simeon Nakov Date: Tue, 24 Jun 2025 13:00:37 +0300 Subject: [PATCH 3/3] forgot an edge case Signed-off-by: Simeon Nakov --- packages/server/src/server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 420866f380..02ef236b0c 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -105,10 +105,15 @@ function parseForwardedHeader(forwardedHeader: string): string | null { const char = firstEntry[valueStart]; if (char === '"') { - // Quoted value: for="192.168.1.1" + // Quoted value: for="192.168.1.1" or for="[2001:db8::1]" const closeQuoteIndex = firstEntry.indexOf('"', valueStart + 1); if (closeQuoteIndex === -1) return null; ip = firstEntry.substring(valueStart + 1, closeQuoteIndex); + + // Handle IPv6 in brackets within quotes: for="[2001:db8::1]" + if (ip.startsWith('[') && ip.endsWith(']')) { + ip = ip.substring(1, ip.length - 1); + } } else if (char === '[') { // IPv6 in brackets: for=[2001:db8::1] const closeBracketIndex = firstEntry.indexOf(']', valueStart + 1);