|  | 
| 1 | 1 | // SPDX-License-Identifier: Apache-2.0 | 
| 2 | 2 | 
 | 
|  | 3 | +import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; | 
| 3 | 4 | import { IPRateLimiterService } from '@hashgraph/json-rpc-relay/src/lib/services'; | 
| 4 | 5 | import { RedisRateLimitStore } from '@hashgraph/json-rpc-relay/src/lib/services/rateLimiterService/RedisRateLimitStore'; | 
| 5 | 6 | import { RequestDetails } from '@hashgraph/json-rpc-relay/src/lib/types/RequestDetails'; | 
|  | 7 | +import Axios, { AxiosInstance, AxiosResponse } from 'axios'; | 
| 6 | 8 | import { expect } from 'chai'; | 
|  | 9 | +import { Server } from 'http'; | 
|  | 10 | +import Koa from 'koa'; | 
| 7 | 11 | import pino from 'pino'; | 
| 8 | 12 | import { Registry } from 'prom-client'; | 
| 9 | 13 | import { createClient, RedisClientType } from 'redis'; | 
| 10 | 14 | 
 | 
|  | 15 | +import { ConfigServiceTestHelper } from '../../../config-service/tests/configServiceTestHelper'; | 
|  | 16 | +import { overrideEnvsInMochaDescribe } from '../../../relay/tests/helpers'; | 
|  | 17 | +import RelayCalls from '../../tests/helpers/constants'; | 
|  | 18 | + | 
| 11 | 19 | describe('@ratelimiter Shared Rate Limiting Acceptance Tests', function () { | 
| 12 | 20 |   this.timeout(30 * 1000); // 30 seconds | 
| 13 | 21 | 
 | 
| @@ -281,4 +289,207 @@ describe('@ratelimiter Shared Rate Limiting Acceptance Tests', function () { | 
| 281 | 289 |       expect(nextRequest).to.be.true; | 
| 282 | 290 |     }); | 
| 283 | 291 |   }); | 
|  | 292 | + | 
|  | 293 | +  describe('X-Forwarded-For Header Integration Tests', function () { | 
|  | 294 | +    // Test with rate limiting enabled and a low limit to make testing easier | 
|  | 295 | +    overrideEnvsInMochaDescribe({ | 
|  | 296 | +      RATE_LIMIT_DISABLED: false, | 
|  | 297 | +      TIER_2_RATE_LIMIT: 3, // Low limit for easy testing | 
|  | 298 | +    }); | 
|  | 299 | + | 
|  | 300 | +    let testServer: Server; | 
|  | 301 | +    let testClient: AxiosInstance; | 
|  | 302 | +    let app: Koa<Koa.DefaultState, Koa.DefaultContext>; | 
|  | 303 | + | 
|  | 304 | +    // Simple static test IPs - each test uses different IP ranges to avoid conflicts | 
|  | 305 | +    const TEST_IP_A = '192.168.1.100'; | 
|  | 306 | +    const TEST_IP_B = '192.168.2.100'; | 
|  | 307 | +    const TEST_IP_C = '192.168.3.100'; | 
|  | 308 | +    const TEST_IP_D = '192.168.4.100'; | 
|  | 309 | +    const TEST_IP_E = '192.168.5.100'; | 
|  | 310 | +    const TEST_METHOD = RelayCalls.ETH_ENDPOINTS.ETH_CHAIN_ID; | 
|  | 311 | + | 
|  | 312 | +    before(function () { | 
|  | 313 | +      ConfigServiceTestHelper.appendEnvsFromPath(__dirname + '/../integration/test.env'); | 
|  | 314 | +      app = require('../../src/server').default; | 
|  | 315 | +      testServer = app.listen(ConfigService.get('E2E_SERVER_PORT')); | 
|  | 316 | +      testClient = createTestClient(); | 
|  | 317 | +    }); | 
|  | 318 | + | 
|  | 319 | +    after(function () { | 
|  | 320 | +      testServer.close((err) => { | 
|  | 321 | +        if (err) { | 
|  | 322 | +          console.error(err); | 
|  | 323 | +        } | 
|  | 324 | +      }); | 
|  | 325 | +    }); | 
|  | 326 | + | 
|  | 327 | +    this.timeout(10000); | 
|  | 328 | + | 
|  | 329 | +    function createTestClient(port = ConfigService.get('E2E_SERVER_PORT')) { | 
|  | 330 | +      return Axios.create({ | 
|  | 331 | +        baseURL: 'http://localhost:' + port, | 
|  | 332 | +        responseType: 'json' as const, | 
|  | 333 | +        headers: { | 
|  | 334 | +          'Content-Type': 'application/json', | 
|  | 335 | +        }, | 
|  | 336 | +        method: 'POST', | 
|  | 337 | +        timeout: 5 * 1000, | 
|  | 338 | +      }); | 
|  | 339 | +    } | 
|  | 340 | + | 
|  | 341 | +    function createRequestWithIP(id: string, ip: string) { | 
|  | 342 | +      return { | 
|  | 343 | +        id: id, | 
|  | 344 | +        jsonrpc: '2.0', | 
|  | 345 | +        method: TEST_METHOD, | 
|  | 346 | +        params: [null], | 
|  | 347 | +      }; | 
|  | 348 | +    } | 
|  | 349 | + | 
|  | 350 | +    async function makeRequestWithForwardedIP(ip: string, id: string = '1') { | 
|  | 351 | +      return testClient.post('/', createRequestWithIP(id, ip), { | 
|  | 352 | +        headers: { | 
|  | 353 | +          'X-Forwarded-For': ip, | 
|  | 354 | +        }, | 
|  | 355 | +      }); | 
|  | 356 | +    } | 
|  | 357 | + | 
|  | 358 | +    async function makeRequestWithoutForwardedIP(id: string = '1') { | 
|  | 359 | +      return testClient.post('/', createRequestWithIP(id, '')); | 
|  | 360 | +    } | 
|  | 361 | + | 
|  | 362 | +    it('should use X-Forwarded-For header IP for rate limiting when app.proxy is true', async function () { | 
|  | 363 | +      // Make requests up to the rate limit for IP_A using X-Forwarded-For header | 
|  | 364 | +      const responses: AxiosResponse[] = []; | 
|  | 365 | + | 
|  | 366 | +      // Make requests within the limit (TIER_2_RATE_LIMIT = 3) | 
|  | 367 | +      for (let i = 1; i <= 3; i++) { | 
|  | 368 | +        const response = await makeRequestWithForwardedIP(TEST_IP_A, i.toString()); | 
|  | 369 | +        responses.push(response); | 
|  | 370 | + | 
|  | 371 | +        expect(response.status).to.eq(200); | 
|  | 372 | +        expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); | 
|  | 373 | +      } | 
|  | 374 | + | 
|  | 375 | +      // The next request should be rate limited for IP_A | 
|  | 376 | +      try { | 
|  | 377 | +        await makeRequestWithForwardedIP(TEST_IP_A, '4'); | 
|  | 378 | +        expect.fail('Expected rate limit to be exceeded'); | 
|  | 379 | +      } catch (error: any) { | 
|  | 380 | +        expect(error.response.status).to.eq(429); | 
|  | 381 | +        expect(error.response.data.error.code).to.eq(-32605); // IP Rate Limit Exceeded | 
|  | 382 | +        expect(error.response.data.error.message).to.include('IP Rate limit exceeded'); | 
|  | 383 | +      } | 
|  | 384 | +    }); | 
|  | 385 | + | 
|  | 386 | +    it('should treat different X-Forwarded-For IPs independently', async function () { | 
|  | 387 | +      // First, exhaust the rate limit for TEST_IP_B | 
|  | 388 | +      for (let i = 1; i <= 3; i++) { | 
|  | 389 | +        await makeRequestWithForwardedIP(TEST_IP_B, `b${i}`); | 
|  | 390 | +      } | 
|  | 391 | + | 
|  | 392 | +      // Verify TEST_IP_B is rate limited | 
|  | 393 | +      try { | 
|  | 394 | +        await makeRequestWithForwardedIP(TEST_IP_B, 'b4'); | 
|  | 395 | +        expect.fail('Expected rate limit to be exceeded for TEST_IP_B'); | 
|  | 396 | +      } catch (error: any) { | 
|  | 397 | +        expect(error.response.status).to.eq(429); | 
|  | 398 | +        expect(error.response.data.error.code).to.eq(-32605); | 
|  | 399 | +      } | 
|  | 400 | + | 
|  | 401 | +      // Now make requests with TEST_IP_C - should not be rate limited | 
|  | 402 | +      for (let i = 1; i <= 3; i++) { | 
|  | 403 | +        const response = await makeRequestWithForwardedIP(TEST_IP_C, `c${i}`); | 
|  | 404 | +        expect(response.status).to.eq(200); | 
|  | 405 | +        expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); | 
|  | 406 | +      } | 
|  | 407 | + | 
|  | 408 | +      // TEST_IP_C should also get rate limited after hitting its own limit | 
|  | 409 | +      try { | 
|  | 410 | +        await makeRequestWithForwardedIP(TEST_IP_C, 'c4'); | 
|  | 411 | +        expect.fail('Expected rate limit to be exceeded for TEST_IP_C'); | 
|  | 412 | +      } catch (error: any) { | 
|  | 413 | +        expect(error.response.status).to.eq(429); | 
|  | 414 | +        expect(error.response.data.error.code).to.eq(-32605); | 
|  | 415 | +      } | 
|  | 416 | +    }); | 
|  | 417 | + | 
|  | 418 | +    it('should use actual client IP when X-Forwarded-For header is not present', async function () { | 
|  | 419 | +      // Make requests without X-Forwarded-For header | 
|  | 420 | +      // These should use the actual client IP and have their own rate limit | 
|  | 421 | +      for (let i = 1; i <= 3; i++) { | 
|  | 422 | +        const response = await makeRequestWithoutForwardedIP(i.toString()); | 
|  | 423 | +        expect(response.status).to.eq(200); | 
|  | 424 | +        expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); | 
|  | 425 | +      } | 
|  | 426 | + | 
|  | 427 | +      // The next request should be rate limited for the actual client IP | 
|  | 428 | +      try { | 
|  | 429 | +        await makeRequestWithoutForwardedIP('4'); | 
|  | 430 | +        expect.fail('Expected rate limit to be exceeded for actual client IP'); | 
|  | 431 | +      } catch (error: any) { | 
|  | 432 | +        expect(error.response.status).to.eq(429); | 
|  | 433 | +        expect(error.response.data.error.code).to.eq(-32605); | 
|  | 434 | +      } | 
|  | 435 | +    }); | 
|  | 436 | + | 
|  | 437 | +    it('should handle multiple IPs in X-Forwarded-For header (use first IP)', async function () { | 
|  | 438 | +      // X-Forwarded-For can contain multiple IPs: "client, proxy1, proxy2" | 
|  | 439 | +      // Koa should use the first IP (leftmost) as the client IP | 
|  | 440 | +      const multipleIPs = `${TEST_IP_D}, 10.0.0.1, 10.0.0.2`; | 
|  | 441 | + | 
|  | 442 | +      // Make requests with multiple IPs in the header | 
|  | 443 | +      for (let i = 1; i <= 3; i++) { | 
|  | 444 | +        const response = await testClient.post('/', createRequestWithIP(i.toString(), TEST_IP_D), { | 
|  | 445 | +          headers: { | 
|  | 446 | +            'X-Forwarded-For': multipleIPs, | 
|  | 447 | +          }, | 
|  | 448 | +        }); | 
|  | 449 | + | 
|  | 450 | +        expect(response.status).to.eq(200); | 
|  | 451 | +        expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); | 
|  | 452 | +      } | 
|  | 453 | + | 
|  | 454 | +      // Should be rate limited based on the first IP (TEST_IP_D) | 
|  | 455 | +      try { | 
|  | 456 | +        await testClient.post('/', createRequestWithIP('4', TEST_IP_D), { | 
|  | 457 | +          headers: { | 
|  | 458 | +            'X-Forwarded-For': multipleIPs, | 
|  | 459 | +          }, | 
|  | 460 | +        }); | 
|  | 461 | +        expect.fail('Expected rate limit to be exceeded for first IP in X-Forwarded-For'); | 
|  | 462 | +      } catch (error: any) { | 
|  | 463 | +        expect(error.response.status).to.eq(429); | 
|  | 464 | +        expect(error.response.data.error.code).to.eq(-32605); | 
|  | 465 | +      } | 
|  | 466 | +    }); | 
|  | 467 | + | 
|  | 468 | +    it('should properly handle X-Forwarded-For header with different request patterns', async function () { | 
|  | 469 | +      // Make requests with X-Forwarded-For header | 
|  | 470 | +      for (let i = 1; i <= 3; i++) { | 
|  | 471 | +        const response = await testClient.post('/', createRequestWithIP(i.toString(), TEST_IP_E), { | 
|  | 472 | +          headers: { | 
|  | 473 | +            'X-Forwarded-For': TEST_IP_E, | 
|  | 474 | +          }, | 
|  | 475 | +        }); | 
|  | 476 | + | 
|  | 477 | +        expect(response.status).to.eq(200); | 
|  | 478 | +        expect(response.data.result).to.be.equal(ConfigService.get('CHAIN_ID')); | 
|  | 479 | +      } | 
|  | 480 | + | 
|  | 481 | +      // Next request should be rate limited | 
|  | 482 | +      try { | 
|  | 483 | +        await testClient.post('/', createRequestWithIP('4', TEST_IP_E), { | 
|  | 484 | +          headers: { | 
|  | 485 | +            'X-Forwarded-For': TEST_IP_E, | 
|  | 486 | +          }, | 
|  | 487 | +        }); | 
|  | 488 | +        expect.fail('Expected rate limit to be exceeded'); | 
|  | 489 | +      } catch (error: any) { | 
|  | 490 | +        expect(error.response.status).to.eq(429); | 
|  | 491 | +        expect(error.response.data.error.code).to.eq(-32605); | 
|  | 492 | +      } | 
|  | 493 | +    }); | 
|  | 494 | +  }); | 
| 284 | 495 | }); | 
0 commit comments