Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,26 @@ Set a shell variable for the local base URL:
BASE_URL=http://localhost:3001
```

### Running behind a proxy

By default the backend does not trust `X-Forwarded-For`, so spoofed proxy
headers cannot change the rate-limit key. When the service is deployed behind a
load balancer or reverse proxy that you control, set `TRUST_PROXY` to the number
of trusted proxy hops:

```bash
TRUST_PROXY=1 npm start
```

Truthy values such as `true`, `yes`, and `on` are treated as one trusted hop.
Leave `TRUST_PROXY` unset, `0`, or `false` for direct-to-node deployments.

Rate limiting prefers a recognized `X-API-Key` over the client IP, so two valid
API-key tenants behind the same NAT do not throttle each other. Requests without
a recognized key continue to use Express' trusted client IP. Only enable
`TRUST_PROXY` behind a proxy that strips or overwrites inbound
`X-Forwarded-For`; otherwise clients can choose the address Express sees.

1. Register a billable service.

```bash
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express from "express";
import {
configureTrustProxy,
installPreRouteMiddleware,
installRequestStateMiddleware,
} from "./middleware/index.js";
Expand All @@ -22,6 +23,7 @@ const PORT = process.env.PORT ?? 3001;
function createApp() {
const app = express();

configureTrustProxy(app);
installPreRouteMiddleware(app);

app.use(createAdminRouter());
Expand Down
61 changes: 56 additions & 5 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import express, {
type Application,
type NextFunction,
Expand All @@ -25,6 +25,44 @@ export function installPreRouteMiddleware(app: Application): void {
app.use(requestIdMiddleware);
}

/**
* Converts TRUST_PROXY into Express' hop-count based trust proxy setting.
* Boolean-like truthy values intentionally map to one trusted proxy hop.
*/
export function resolveTrustProxySetting(
raw = process.env.TRUST_PROXY
): false | number {
if (raw === undefined) return false;
const normalized = raw.trim().toLowerCase();
if (
normalized === "" ||
normalized === "false" ||
normalized === "0" ||
normalized === "off" ||
normalized === "no"
) {
return false;
}
if (
normalized === "true" ||
normalized === "1" ||
normalized === "on" ||
normalized === "yes"
) {
return 1;
}
const hopCount = Number(normalized);
if (Number.isInteger(hopCount) && hopCount > 0) {
return hopCount;
}
return false;
}

/** Applies the process trust-proxy setting before request middleware runs. */
export function configureTrustProxy(app: Application): void {
app.set("trust proxy", resolveTrustProxySetting());
}

/**
* Installs middleware that originally ran after admin/config/metrics but before
* the main API routes.
Expand Down Expand Up @@ -120,12 +158,25 @@ function pauseGuardMiddleware(req: Request, res: Response, next: NextFunction):
});
}

/** In-process IP rate limiter matching the original 60/min behavior. */
/**
* Builds a stable rate-limit key from the authenticated API key when present,
* otherwise falling back to Express' trusted client IP.
*/
export function deriveRateLimitKey(req: Request): string {
const apiKey = (req as AgentPayRequest).apiKey;
if (apiKey) {
const digest = createHash("sha256").update(apiKey).digest("hex");
return `api-key:${digest}`;
}
return `ip:${req.ip ?? req.socket.remoteAddress ?? "unknown"}`;
}

/** In-process rate limiter keyed by API key or trusted client IP. */
function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void {
if (process.env.NODE_ENV === "test") return next();
const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
const key = deriveRateLimitKey(req);
const now = Date.now();
const bucket = (rateBuckets.get(ip) ?? []).filter(
const bucket = (rateBuckets.get(key) ?? []).filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
if (bucket.length >= RATE_LIMIT_PER_WINDOW) {
Expand All @@ -138,7 +189,7 @@ function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): v
return;
}
bucket.push(now);
rateBuckets.set(ip, bucket);
rateBuckets.set(key, bucket);
next();
}

Expand Down
94 changes: 94 additions & 0 deletions src/ratelimit-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, it } from "node:test";
import assert from "node:assert";
import request from "supertest";
import { createApp } from "./index.js";
import { apiKeyStore, RATE_LIMIT_PER_WINDOW, rateBuckets } from "./store/state.js";

const originalNodeEnv = process.env.NODE_ENV;
const originalTrustProxy = process.env.TRUST_PROXY;
const originalConsoleLog = console.log;

function restoreEnv(): void {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
if (originalTrustProxy === undefined) {
delete process.env.TRUST_PROXY;
} else {
process.env.TRUST_PROXY = originalTrustProxy;
}
}

void describe("rate-limit key derivation", () => {
beforeEach(() => {
process.env.NODE_ENV = "production";
delete process.env.TRUST_PROXY;
apiKeyStore.clear();
rateBuckets.clear();
console.log = () => undefined;
});

afterEach(() => {
restoreEnv();
apiKeyStore.clear();
rateBuckets.clear();
console.log = originalConsoleLog;
});

void it("does not let spoofed X-Forwarded-For values bypass the limiter when trust proxy is off", async () => {
const app = createApp();

for (let i = 0; i < RATE_LIMIT_PER_WINDOW; i += 1) {
const res = await request(app)
.get("/api/v1/version")
.set("X-Forwarded-For", `198.51.100.${i}`);
assert.strictEqual(res.status, 200);
}

const limited = await request(app)
.get("/api/v1/version")
.set("X-Forwarded-For", "198.51.100.250");

assert.strictEqual(limited.status, 429);
assert.strictEqual(limited.body.error, "rate_limited");
assert.strictEqual(limited.headers["retry-after"], "60");
});

void it("honours the configured trusted proxy hop count for the client IP", async () => {
process.env.TRUST_PROXY = "1";
const app = createApp();

for (let i = 0; i < RATE_LIMIT_PER_WINDOW + 1; i += 1) {
const res = await request(app)
.get("/api/v1/version")
.set("X-Forwarded-For", `203.0.113.${i}`);
assert.strictEqual(res.status, 200);
}
});

void it("keys authenticated callers by API key before falling back to IP", async () => {
const app = createApp();
const firstKey = "apk_first_tenant";
const secondKey = "apk_second_tenant";
apiKeyStore.set(firstKey, { label: "first", createdAt: Date.now() });
apiKeyStore.set(secondKey, { label: "second", createdAt: Date.now() });

for (let i = 0; i < RATE_LIMIT_PER_WINDOW; i += 1) {
const res = await request(app).get("/api/v1/version").set("X-API-Key", firstKey);
assert.strictEqual(res.status, 200);
}

const isolated = await request(app)
.get("/api/v1/version")
.set("X-API-Key", secondKey);
assert.strictEqual(isolated.status, 200);

const limited = await request(app)
.get("/api/v1/version")
.set("X-API-Key", firstKey);
assert.strictEqual(limited.status, 429);
assert.strictEqual(limited.body.error, "rate_limited");
});
});
2 changes: 1 addition & 1 deletion src/store/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const servicesMetadata = new Map<string, ServiceMetadataDto>();
/** Registered webhooks and their event subscriptions. */
export const webhookStore = new Map<string, WebhookRecord>();

/** Rate-limiter windows keyed by source IP. */
/** Rate-limiter windows keyed by authenticated API key digest or trusted IP. */
export const rateBuckets = new Map<string, number[]>();

export const RATE_LIMIT_PER_WINDOW = 60;
Expand Down