Skip to content

fix(agent): resolve the ip whitelist client ip through trusted proxies - #2047

Draft
Tonours wants to merge 2 commits into
mainfrom
fix/prd-1760-xff-whitelist
Draft

Tonours wants to merge 2 commits into
mainfrom
fix/prd-1760-xff-whitelist

Conversation

@Tonours

@Tonours Tonours commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

What changed

The IP whitelist now resolves the client address through a new resolveClientIp(socketIp, forwardedFor) helper (packages/agent/src/routes/security/client-ip.ts), aligned with how Rails computes remote_ip (agent-ruby parity).

  • The helper starts from the socket peer — the only address the caller cannot choose — and walks the proxy chain right to left through X-Forwarded-For while each hop is a trusted proxy (loopback, RFC1918 private, link-local, ULA). The first untrusted hop is the address the whitelist checks. A forged header sent from a public socket is therefore ignored: the caller is judged on its socket address.
  • A hop that is not a valid address ends the walk and is returned as is, so the request is refused (fail closed).
  • IPv4-mapped IPv6 addresses count as their IPv4 form for the trusted-proxy check, in every notation (::ffff:10.0.0.3, ::ffff:a00:3, uppercase included): Node's BlockList matches mapped forms against IPv4 subnets natively (verified on the Node version CI pins), so the helper delegates to it instead of slicing the prefix textually — the hex notation used to stop the walk.
  • The result depends only on req.socket.remoteAddress and the raw header, so it is the same for every mount (standalone, Express, Fastify, Koa, NestJS) whatever the host's trust proxy / app.proxy settings.
  • The loopback internal-caller exemption is unchanged (socket-based, requires no X-Forwarded-For), so in-process calls stay exempt.

Side effect: a legitimate client that sends its own X-Forwarded-For behind an appending proxy used to be refused (the comma list failed the BlockList check); it is now judged on the address the proxy appended.

Behaviour change to note: when the agent sits directly behind a proxy with a public address (or a CGNAT peer), the whitelist now checks that proxy's address. The 403 message names the address it checked.

Security notes

  • The ticket's minimal fix (context.request.ip) was rejected: the agent serves its routes through its own new Koa() with proxy off (framework-mounter.ts), so context.request.ip is always the socket address — every deployment behind a load balancer (Heroku router, ALB, nginx, Cloud Run) would have been locked out.
  • Residual risks, accepted deliberately:
    • a proxy with a public address (or a CGNAT peer such as 100.64/10) directly in front of the agent is now judged on the proxy address, so its users get 403 where they passed before. A trustedProxies option is the natural follow-up if this shows up in the wild (not added now: new public API, no reported need).
    • any peer whose address falls in a trusted range can still forge the header: an attacker on the agent's private network (trusted socket), or a private-address client behind an appending proxy pushing a forged left entry, is judged on the forged value. Same residual risk as Rails remote_ip / agent-ruby; it needs network position plus a stolen token.
    • a misconfigured trusted proxy that forwards a client-sent X-Forwarded-For without appending still passes a forged value — outside the agent's control.

Sibling agents (cross-agent audit, 2026-10-28)

  • agent-python: same defect (reads X-Forwarded-For first, e.g. django_agent/utils/converter.py, flask_agent/utils/requests.py) — tracked as PRD-1839.
  • forest-express: tracked as PRD-1774; forest-express-sequelize and forest-express-mongoose pin forest-express and need a bump once it ships.
  • agent-ruby and forest-rails: not affected, both use Rails' remote_ip (same walk-from-the-right + skip-trusted algorithm as this fix).
  • forestadmin-server (common IP handling): PRD-1728.

Tests

  • New client-ip.test.ts: trusted-proxy walk (single hop, appending proxy with a forged left entry, multi-hop chain, all-trusted chain, mapped forms in dotted/hex/uppercase notation, mapped public socket), invalid hops (malformed, port-bearing, empty slot, unreadable socket), spacing, repeated header.
  • ip-whitelist.test.ts: every context carries an explicit socket peer; the 403 assertions check the exact address; the anti-spoof exemption test asserts the socket address in the message.
  • The reproduction test (public socket 203.0.113.77 + forged X-Forwarded-For: 9.9.9.9 against a 9.9.9.9 rule) was confirmed red against the pre-fix code before the fix landed.
  • yarn workspace @forestadmin/agent test → green; lint → 0 errors; build (tsc strict) → ok.

Council review (2026-10-28)

Round 1 (head 4ec70cada): three independent phase-A passes (code review, maintainability, cross-family) plus the validator arbiter. 13 distinct candidates, 1 confirmed: closing PRD-1760 left the agent-python follow-up tracked by nothing — fixed by opening PRD-1839 and linking it above. The other 12 were rejected as pre-existing behaviour (403 message echoing the hop, port-bearing hops, loopback exemption predating this PR), explicit residual risks documented above, or speculative. Two pragmatic adoptions from rejected-but-convergent candidates: dropping the textual ::ffff: unmapping for native BlockList matching (simpler, and fixes the hex-notation walk edge; verified against the CI-pinned Node) and sharing one ipVersion helper. Findings deliberately left: unifying the loopback exemption with the proxy-trust model and port-stripping hops (both pre-PR behaviour, fail-closed, pinned by the plan), test-structure restructuring around the mocked context.

fixes PRD-1760

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

@linear-code

linear-code Bot commented Oct 28, 2026

Copy link
Copy Markdown

PRD-1760

@qltysh

qltysh Bot commented Oct 28, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (2)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent/src/routes/security/ip-whitelist.ts100.0%
New file Coverage rating: A
packages/agent/src/routes/security/client-ip.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@Tonours
Tonours force-pushed the fix/prd-1760-xff-whitelist branch from b4275d2 to 4ec70ca Compare October 28, 2026 10:57
@Tonours Tonours changed the title fix(agent): judge the IP whitelist on the resolved client IP fix(agent): resolve the ip whitelist client ip through trusted proxies Oct 28, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant