In my uo-request-generator project, generating a maintenance request calls an external LLM. Before that happens, the application checks its rate limits. One of them allows no more than three admitted attempts per IP address within a rolling 60-second window.1
Counting requests correctly is not enough. The application also needs to determine which address to count them against. If the client can choose that address, the limiter will keep maintaining fresh counters without making a counting error.
The project’s first implementation enabled proxy trust with a boolean environment variable. The PR explicitly documented its limitation: when enabled, the setting trusted headers from any source that could reach the application. A later change replaced it with an explicit list of trusted addresses and networks.2
The connection address and the header address are different inputs
Consider a typical reverse proxy setup:
client → proxy → application
With this kind of HTTP proxying, the proxy establishes the connection to the application. The immediate network peer and the original client therefore have different addresses. In Fastify, request.ip exposes the resolved client address. Its behavior depends on the proxy trust configuration.3
A proxy can use X-Forwarded-For to pass on the client’s address. But the client can send that header too. Its presence does not prove that a trusted infrastructure component supplied the value. Fastify explicitly warns that these metadata can be spoofed.4
Two opposite mistakes are possible here.
Ignoring the proxy can put different clients into a shared quota for the proxy’s address. Unconditionally trusting the supplied header can let the client change the rate-limit key.
The question is not whether to read X-Forwarded-For at all. It is whose information about the original address the application is willing to accept.
trustProxy: true means more than “there is a proxy in front”
In Fastify, trustProxy: true means trusting all proxies. It does not verify that a request actually passed through a particular server. An alternative is an explicit list of IP addresses and CIDR networks.4
The boolean setting alone does not prove that a vulnerability exists. If the application is reachable only through a controlled proxy, and that proxy constructs the headers correctly, the necessary restrictions may be enforced externally.
The problem is that the application configuration does not express those conditions. An exposed port, an additional route, or a change in proxy behavior can invalidate the assumption behind the protection.
For IP rate limiting, the consequence is specific: accepting a forged address charges the request to the wrong counter. The window algorithm can remain entirely correct.
Replace blanket trust with an explicit list
In uo-request-generator, GENERATION_TRUSTED_PROXIES replaced GENERATION_TRUST_PROXY. The new setting accepts literal IPv4 and IPv6 addresses and CIDR networks. After configuration validation, the list is passed to Fastify’s built-in mechanism.5
The relevant configuration looks like this:
const app = Fastify({
trustProxy:
generationRateLimitConfig.trustedProxies.length === 0
? false
: [...generationRateLimitConfig.trustedProxies],
});
This is an excerpt of the application configuration, with unrelated options omitted. There is no need to write a custom X-Forwarded-For parser here.6
The list contains proxies allowed to report a client’s address, not the clients themselves. It must account for the addresses from which connections actually reach the application. A load balancer’s public address is not necessarily the source address of its backend connection.
A broad network also broadens trust. If it includes unrelated services that can connect to the application, those services fall inside the same boundary. Calling a network internal is not enough. What matters is who can send requests from it.
Missing configuration and invalid configuration are different states
Without GENERATION_TRUSTED_PROXIES, the application starts with trustProxy: false. An empty value, an invalid address, a hostname, or the leftover legacy variable prevents startup. Invalid configuration does not silently become permission to trust everyone.5
Behind a proxy, a missing setting can still produce a shared quota for the proxy’s address. The default does not infer the topology. It simply avoids delegating client IP resolution to an unknown source.
A trusted proxy must construct the header correctly
The trusted address list identifies who supplied the information. It does not correct the proxy’s own behavior.
In a simple setup where Nginx directly accepts the client connection, it can overwrite the incoming header:
proxy_set_header X-Forwarded-For $remote_addr;
This illustrates address forwarding, not a complete server configuration. In this setup, the value comes from the client address Nginx sees, rather than preserving the client’s incoming X-Forwarded-For. The proxy_set_header directive explicitly overrides headers in the request sent to the application.7
The example has an important condition: there is no other proxy in front of Nginx, and its client address has not been rewritten using untrusted data. The Nginx Real IP module can change that address and needs its own trusted-source configuration.8
For a chain of proxies, blindly applying the same setting at every hop can discard the original address.
Another common option is $proxy_add_x_forwarded_for. It preserves the incoming X-Forwarded-For and appends $remote_addr. That does not make the entire request history trustworthy. Its beginning can contain a value supplied by the client.7
Suppose the application receives the following in a hypothetical example:
connection address: 192.0.2.10
X-Forwarded-For: 203.0.113.77, 198.51.100.23
All addresses in this example are illustrative, not production infrastructure details. The application trusts proxy 192.0.2.10. The proxy appended the client address it observed, 198.51.100.23. The client supplied 203.0.113.77 itself.
The @fastify/proxy-addr mechanism examines addresses starting with the one closest to the application and returns the closest untrusted address. In this example, that is 198.51.100.23. There is no basis for extending the trusted traversal to 203.0.113.77. Simply reading the first header entry is therefore not a substitute for validating the chain.9
Overwriting at the external boundary and interpreting a proxy chain are different ways to forward the address. The choice depends on the topology. The common requirement is that the client must not control the value the application accepts as the trusted source address of the request.
A trusted proxy list does not restrict network access
The trustProxy setting controls how request metadata are interpreted. It is not a firewall rule.
A source being absent from the list does not mean the application automatically rejects its connection. Its supplied header should not be trusted for IP resolution, but the request can still reach the handler. The project’s tests explicitly cover this case.10
That is why the uo-request-generator documentation states a separate requirement: public clients must not reach the backend while bypassing the proxy. This is a deployment condition, not a property created by an environment variable.1
In practice, two independent properties need checking:
- the application trusts only the intended proxies
- network access to the application matches that setup
One correctly resolved IP address does not prove that the request passed through every required proxy-side check.
Test that a client cannot switch counters
Sending several identical requests and receiving 429 is not enough to test the limit. That checks the counter, but not the integrity of its key.
The repository contains a more specific regression scenario. The connection source stays the same and is not on the trusted proxy list. Only X-Forwarded-For changes between requests. The first three attempts are admitted, and the fourth receives 429. Changing the header must not create a fresh quota.10
There is also a positive case: information from an allowed proxy is processed through Fastify’s built-in mechanism. Without that check, a seemingly protected system could simply ignore all headers and collapse different clients into one address.10
These checks use a test double for the LLM. A separate rate-limit test confirms that a rejected attempt does not call the gateway. Real paid requests are unnecessary for this.10
However, these tests simulate connection addresses and headers inside the application. They do not prove that the real Nginx overwrites the header or that the backend port is unreachable from outside. Those conditions need separate checks in the deployed setup.
A correct IP address still does not identify a user
Even with correct configuration, multiple users can share one public address, for example behind NAT. A limit keyed by that address affects them together. RFC 6269 also discusses blocking problems caused by shared IP addresses.11
That is why IP is only one layer of protection in the project. There are also browser-client limits, CAPTCHA, and a global safeguard before LLM calls. Their presence does not make incorrect address resolution harmless, but it avoids relying on a single network attribute for all protection.1
The main lesson from this change is not that a boolean setting should always become an array.
An IP rate limit starts with the provenance of the IP itself. You need to know who supplied the address, which chain carried it, and which participants can influence it. Only then does it make sense to discuss window size and the number of allowed requests.
uo-request-generatorREADME at the revision discussed here, including the generation rate limits and global safeguard. This describes the application contract, not verification of a real network configuration. ↩︎ ↩︎ ↩︎PR #60: initial rate-limit implementation and PR #69: trusted proxy allowlist. Both changes were merged. ↩︎
Fastify: Request, the
ipandipsproperties. ↩︎Nginx:
ngx_http_proxy_module, theproxy_set_headerdirective and$proxy_add_x_forwarded_forvariable. ↩︎ ↩︎@fastify/proxy-addr: resolving the closest untrusted address. ↩︎Generation rate-limit route tests. Connection addresses, headers, and the LLM are simulated in the tests. ↩︎ ↩︎ ↩︎ ↩︎
