Skip to content

Commit 5ed041a

Browse files
natanasowsimzzz
andauthored
feat: cherry picked enables proxy support to release/0.69.0 (#3870)
Signed-off-by: Simeon Nakov <simeon.nakov@limechain.tech> Co-authored-by: Simeon Nakov <simeon.nakov@limechain.tech>
1 parent 1d5c444 commit 5ed041a

File tree

3 files changed

+235
-0
lines changed

3 files changed

+235
-0
lines changed

packages/server/src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ const methodResponseHistogram = new Histogram({
4646
buckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 20000, 30000, 40000, 50000, 60000], // ms (milliseconds)
4747
});
4848

49+
// enable proxy support to trust proxy-added headers for client IP detection
50+
app.getKoaApp().proxy = true;
51+
4952
// set cors
5053
app.getKoaApp().use(cors());
5154

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services';
4+
import Axios, { AxiosInstance, AxiosResponse } from 'axios';
5+
import { expect } from 'chai';
6+
import { Server } from 'http';
7+
import Koa from 'koa';
8+
import { pino } from 'pino';
9+
10+
import { ConfigServiceTestHelper } from '../../../config-service/tests/configServiceTestHelper';
11+
12+
ConfigServiceTestHelper.appendEnvsFromPath(__dirname + '/test.env');
13+
14+
import { overrideEnvsInMochaDescribe, useInMemoryRedisServer } from '../../../relay/tests/helpers';
15+
import RelayCalls from '../../tests/helpers/constants';
16+
17+
describe('X-Forwarded-For Header Integration Tests', function () {
18+
const logger = pino({ level: 'silent' });
19+
20+
// Use in-memory Redis server for CI compatibility
21+
useInMemoryRedisServer(logger, 6380);
22+
23+
// Test with rate limiting enabled and a low limit to make testing easier
24+
overrideEnvsInMochaDescribe({
25+
RATE_LIMIT_DISABLED: false,
26+
TIER_2_RATE_LIMIT: 3, // Low limit for easy testing
27+
});
28+
29+
let testServer: Server;
30+
let testClient: AxiosInstance;
31+
let app: Koa<Koa.DefaultState, Koa.DefaultContext>;
32+
33+
// Simple static test IPs - each test uses different IP ranges to avoid conflicts
34+
const TEST_IP_A = '192.168.1.100';
35+
const TEST_IP_B = '192.168.2.100';
36+
const TEST_IP_C = '192.168.3.100';
37+
const TEST_IP_D = '192.168.4.100';
38+
const TEST_IP_E = '192.168.5.100';
39+
const TEST_METHOD = RelayCalls.ETH_ENDPOINTS.ETH_CHAIN_ID;
40+
41+
before(function () {
42+
app = require('../../src/server').default;
43+
testServer = app.listen(ConfigService.get('E2E_SERVER_PORT'));
44+
testClient = createTestClient();
45+
});
46+
47+
after(function () {
48+
testServer.close((err) => {
49+
if (err) {
50+
console.error(err);
51+
}
52+
});
53+
});
54+
55+
this.timeout(10000);
56+
57+
function createTestClient(port = ConfigService.get('E2E_SERVER_PORT')) {
58+
return Axios.create({
59+
baseURL: 'http://localhost:' + port,
60+
responseType: 'json' as const,
61+
headers: {
62+
'Content-Type': 'application/json',
63+
},
64+
method: 'POST',
65+
timeout: 5 * 1000,
66+
});
67+
}
68+
69+
function createRequestWithIP(id: string, ip: string) {
70+
return {
71+
id: id,
72+
jsonrpc: '2.0',
73+
method: TEST_METHOD,
74+
params: [null],
75+
};
76+
}
77+
78+
async function makeRequestWithForwardedIP(ip: string, id: string = '1') {
79+
return testClient.post('/', createRequestWithIP(id, ip), {
80+
headers: {
81+
'X-Forwarded-For': ip,
82+
},
83+
});
84+
}
85+
86+
async function makeRequestWithoutForwardedIP(id: string = '1') {
87+
return testClient.post('/', createRequestWithIP(id, ''));
88+
}
89+
90+
it('should use X-Forwarded-For header IP for rate limiting when app.proxy is true', async function () {
91+
// Make requests up to the rate limit for IP_A using X-Forwarded-For header
92+
const responses: AxiosResponse[] = [];
93+
94+
// Make requests within the limit (TIER_2_RATE_LIMIT = 3)
95+
for (let i = 1; i <= 3; i++) {
96+
const response = await makeRequestWithForwardedIP(TEST_IP_A, i.toString());
97+
responses.push(response);
98+
99+
expect(response.status).to.eq(200);
100+
expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID'));
101+
}
102+
103+
// The next request should be rate limited for IP_A
104+
try {
105+
await makeRequestWithForwardedIP(TEST_IP_A, '4');
106+
expect.fail('Expected rate limit to be exceeded');
107+
} catch (error: any) {
108+
expect(error.response.status).to.eq(429);
109+
expect(error.response.data.error.code).to.eq(-32605); // IP Rate Limit Exceeded
110+
expect(error.response.data.error.message).to.include('IP Rate limit exceeded');
111+
}
112+
});
113+
114+
it('should treat different X-Forwarded-For IPs independently', async function () {
115+
// First, exhaust the rate limit for TEST_IP_B
116+
for (let i = 1; i <= 3; i++) {
117+
await makeRequestWithForwardedIP(TEST_IP_B, `b${i}`);
118+
}
119+
120+
// Verify TEST_IP_B is rate limited
121+
try {
122+
await makeRequestWithForwardedIP(TEST_IP_B, 'b4');
123+
expect.fail('Expected rate limit to be exceeded for TEST_IP_B');
124+
} catch (error: any) {
125+
expect(error.response.status).to.eq(429);
126+
expect(error.response.data.error.code).to.eq(-32605);
127+
}
128+
129+
// Now make requests with TEST_IP_C - should not be rate limited
130+
for (let i = 1; i <= 3; i++) {
131+
const response = await makeRequestWithForwardedIP(TEST_IP_C, `c${i}`);
132+
expect(response.status).to.eq(200);
133+
expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID'));
134+
}
135+
136+
// TEST_IP_C should also get rate limited after hitting its own limit
137+
try {
138+
await makeRequestWithForwardedIP(TEST_IP_C, 'c4');
139+
expect.fail('Expected rate limit to be exceeded for TEST_IP_C');
140+
} catch (error: any) {
141+
expect(error.response.status).to.eq(429);
142+
expect(error.response.data.error.code).to.eq(-32605);
143+
}
144+
});
145+
146+
it('should use actual client IP when X-Forwarded-For header is not present', async function () {
147+
// Make requests without X-Forwarded-For header
148+
// These should use the actual client IP and have their own rate limit
149+
for (let i = 1; i <= 3; i++) {
150+
const response = await makeRequestWithoutForwardedIP(i.toString());
151+
expect(response.status).to.eq(200);
152+
expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID'));
153+
}
154+
155+
// The next request should be rate limited for the actual client IP
156+
try {
157+
await makeRequestWithoutForwardedIP('4');
158+
expect.fail('Expected rate limit to be exceeded for actual client IP');
159+
} catch (error: any) {
160+
expect(error.response.status).to.eq(429);
161+
expect(error.response.data.error.code).to.eq(-32605);
162+
}
163+
});
164+
165+
it('should handle multiple IPs in X-Forwarded-For header (use first IP)', async function () {
166+
// X-Forwarded-For can contain multiple IPs: "client, proxy1, proxy2"
167+
// Koa should use the first IP (leftmost) as the client IP
168+
const multipleIPs = `${TEST_IP_D}, 10.0.0.1, 10.0.0.2`;
169+
170+
// Make requests with multiple IPs in the header
171+
for (let i = 1; i <= 3; i++) {
172+
const response = await testClient.post('/', createRequestWithIP(i.toString(), TEST_IP_D), {
173+
headers: {
174+
'X-Forwarded-For': multipleIPs,
175+
},
176+
});
177+
178+
expect(response.status).to.eq(200);
179+
expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID'));
180+
}
181+
182+
// Should be rate limited based on the first IP (TEST_IP_D)
183+
try {
184+
await testClient.post('/', createRequestWithIP('4', TEST_IP_D), {
185+
headers: {
186+
'X-Forwarded-For': multipleIPs,
187+
},
188+
});
189+
expect.fail('Expected rate limit to be exceeded for first IP in X-Forwarded-For');
190+
} catch (error: any) {
191+
expect(error.response.status).to.eq(429);
192+
expect(error.response.data.error.code).to.eq(-32605);
193+
}
194+
});
195+
196+
it('should properly handle X-Forwarded-For header with different request patterns', async function () {
197+
// Make requests with X-Forwarded-For header
198+
for (let i = 1; i <= 3; i++) {
199+
const response = await testClient.post('/', createRequestWithIP(i.toString(), TEST_IP_E), {
200+
headers: {
201+
'X-Forwarded-For': TEST_IP_E,
202+
},
203+
});
204+
205+
expect(response.status).to.eq(200);
206+
expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID'));
207+
}
208+
209+
// Next request should be rate limited
210+
try {
211+
await testClient.post('/', createRequestWithIP('4', TEST_IP_E), {
212+
headers: {
213+
'X-Forwarded-For': TEST_IP_E,
214+
},
215+
});
216+
expect.fail('Expected rate limit to be exceeded');
217+
} catch (error: any) {
218+
expect(error.response.status).to.eq(429);
219+
expect(error.response.data.error.code).to.eq(-32605);
220+
}
221+
});
222+
});

packages/server/tests/integration/server.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,17 @@ describe('RPC Server', function () {
4040
let populatePreconfiguredSpendingPlansSpy: sinon.SinonSpy;
4141
let app: Koa<Koa.DefaultState, Koa.DefaultContext>;
4242

43+
overrideEnvsInMochaDescribe({
44+
RATE_LIMIT_DISABLED: true,
45+
});
46+
4347
before(function () {
48+
// Set up spy BEFORE requiring the server module to catch the constructor call
4449
populatePreconfiguredSpendingPlansSpy = sinon.spy(Relay.prototype, <any>'populatePreconfiguredSpendingPlans');
50+
51+
// Clear the module cache to ensure a fresh server instance
52+
delete require.cache[require.resolve('../../src/server')];
53+
4554
app = require('../../src/server').default;
4655
testServer = app.listen(ConfigService.get('E2E_SERVER_PORT'));
4756
testClient = BaseTest.createTestClient();
@@ -53,6 +62,7 @@ describe('RPC Server', function () {
5362
});
5463

5564
after(function () {
65+
populatePreconfiguredSpendingPlansSpy.restore();
5666
testServer.close((err) => {
5767
if (err) {
5868
console.error(err);

0 commit comments

Comments
 (0)