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 1 commit
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
53 changes: 53 additions & 0 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,59 @@
// 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());

Expand Down
Loading
Loading