mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-19 06:07:21 +02:00
fix: rate-limit visitors by real IP regardless of Cloudflare proxy hops (#750)
Since the night of 2026-07-16 production keyed the rate limiter on Cloudflare edge IPs instead of visitor IPs: every visitor routed through the same edge shared one 175-req/15min bucket, causing widespread 429s (confirmed by probing: sequential requests alternated 200/429 across fresh and exhausted buckets). Root cause: 'trust proxy' used a fixed hop count (TRUST_PROXY=1), which silently breaks whenever the proxy chain gains or loses an X-Forwarded-For entry (e.g. a Cloudflare-side change). - TRUST_PROXY now accepts a comma-separated subnet list; the new default 'loopback,uniquelocal,cloudflare' expands Cloudflare's published IP ranges so Express skips trusted proxies no matter how many entries they add. Plain numbers keep the legacy hop-count behavior. - If resolution still stops at a Cloudflare address (visitor missing from X-Forwarded-For entirely), the limiter key falls back to cf-connecting-ip — safe because request.ip can only be a Cloudflare address when the whole chain to it is trusted. - CIDR matching uses Node's built-in net.BlockList (no new dependency). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f2d6d1a64
commit
6a820408a1
5 files changed
+288
-19
No files matched your search
+30
-11
@@ -29,6 +29,7 @@ import {
|
||||
import DailyStatsModel from "../core/model/dailyStats/dailyStats.model";
|
||||
import { getUser } from "./routes/route-utils";
|
||||
import config from "../config";
|
||||
import { resolveTrustProxy, isCloudflareIP } from "./trustProxy";
|
||||
import { createLogger, serializeError } from "../core/logger";
|
||||
|
||||
const logger = createLogger("server");
|
||||
@@ -109,10 +110,14 @@ export default async function start() {
|
||||
})
|
||||
);
|
||||
app.set("etag", "strong");
|
||||
// Trust exactly TRUST_PROXY proxy hops so Express derives request.ip from
|
||||
// the right X-Forwarded-For entry. This is what makes request.ip
|
||||
// trustworthy for rate limiting instead of a client-spoofable header.
|
||||
app.set("trust proxy", config.TRUST_PROXY);
|
||||
// Trust the proxies declared in TRUST_PROXY (subnet list or legacy hop
|
||||
// count) so Express derives request.ip from the right X-Forwarded-For
|
||||
// entry. With the subnet form, Express skips every trusted address no
|
||||
// matter how many entries the proxies add, so request.ip stays the real
|
||||
// visitor even when Cloudflare changes how it builds the header. This is
|
||||
// what makes request.ip trustworthy for rate limiting instead of a
|
||||
// client-spoofable header.
|
||||
app.set("trust proxy", resolveTrustProxy(config.TRUST_PROXY));
|
||||
|
||||
// handle session and connection
|
||||
app.use(initSession());
|
||||
@@ -139,16 +144,30 @@ export default async function start() {
|
||||
_response: express.Response
|
||||
): string {
|
||||
// Use request.ip, which Express resolves from X-Forwarded-For honouring
|
||||
// the configured "trust proxy" hop count. Do NOT key off the
|
||||
// cf-connecting-ip header directly: when the server isn't actually behind
|
||||
// Cloudflare a client can set that header to an arbitrary value per
|
||||
// request and trivially bypass the rate limiter (CWE-290).
|
||||
if (!request.ip && request.socket.remoteAddress) {
|
||||
// the configured "trust proxy" setting. Do NOT key off the
|
||||
// cf-connecting-ip header unconditionally: when the server isn't actually
|
||||
// behind Cloudflare a client can set that header to an arbitrary value
|
||||
// per request and trivially bypass the rate limiter (CWE-290).
|
||||
let ip = request.ip;
|
||||
if (!ip && request.socket.remoteAddress) {
|
||||
logger.warn("request.ip is missing");
|
||||
return request.socket.remoteAddress;
|
||||
ip = request.socket.remoteAddress;
|
||||
}
|
||||
// If resolution stopped at a Cloudflare edge address (X-Forwarded-For no
|
||||
// longer contains the visitor, e.g. after a Cloudflare-side change), fall
|
||||
// back to cf-connecting-ip. This is safe: request.ip can only be a
|
||||
// Cloudflare address when every hop up to it is trusted, i.e. the request
|
||||
// genuinely traversed Cloudflare. A direct client forging the header is
|
||||
// keyed by its own untrusted address instead, so the CWE-290 bypass above
|
||||
// does not apply.
|
||||
if (ip && isCloudflareIP(ip)) {
|
||||
const cfConnectingIP = request.headers["cf-connecting-ip"];
|
||||
if (typeof cfConnectingIP === "string" && cfConnectingIP) {
|
||||
ip = cfConnectingIP.trim();
|
||||
}
|
||||
}
|
||||
// remove port number from IPv4 addresses
|
||||
return (request.ip || "").replace(/:\d+[^:]*$/, "");
|
||||
return (ip || "").replace(/:\d+[^:]*$/, "");
|
||||
}
|
||||
|
||||
const rate = rateLimit({
|
||||
|
||||
Reference in new issue
Block a user