Skip to content

feat: adds Forwarded header to downstream MN requests + custom parsing for Forwarded headers #3871

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/relay/src/lib/clients/mirrorNodeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
path: string,
pathLabel: string,
Expand All @@ -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 };
Expand Down
109 changes: 109 additions & 0 deletions packages/relay/tests/lib/mirrorNodeClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
101 changes: 101 additions & 0 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,107 @@
// 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
*
* 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 {

Check warning on line 82 in packages/server/src/server.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

packages/server/src/server.ts#L82

Method parseForwardedHeader has a cyclomatic complexity of 20 (limit is 8)
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(',');

// Take the first entry (original client)
const firstEntry = entries[0]?.trim();
if (!firstEntry) 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" 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);
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);
}

// 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;
}

// Basic IP format validation (very permissive)
if (!/^[a-fA-F0-9:.]+$/.test(ip)) {
return null;
}

return ip;
} catch (error) {
// If parsing fails, return null to avoid breaking the request
return null;
}
}

// set cors
app.getKoaApp().use(cors());

Expand Down
Loading
Loading