Skip to content
Merged
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
92 changes: 92 additions & 0 deletions src/middleware/rateLimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TokenBucketRateLimiter,
createTokenBucketRateLimitMiddleware,
InMemoryRateLimiter, // <-- Added missing import
createProxyRateLimitMiddleware,
} from "./rateLimit.js";
import { requireAuth, type AuthenticatedLocals } from "./requireAuth.js";
import { TEST_JWT_SECRET, signTestToken } from "../../tests/helpers/jwt.js";
Expand Down Expand Up @@ -323,3 +324,94 @@ describe("token bucket rate limit middleware", () => {
expect(message).toBe("Too Many Requests");
});
});

describe("proxy rate limit middleware", () => {
const originalSecret = process.env.JWT_SECRET;

function buildProxyApp(capacity = 3, refillRate = 1) {
const app = express();
const rateLimit = createProxyRateLimitMiddleware({
capacity,
refillRate,
});

app.get(
"/proxy",
// Fake auth middleware that simulates gateway proxy auth
(req, res, next) => {
const apiKey = req.headers["x-api-key"];
if (apiKey === "key-1") {
(req as any).apiKeyRecord = { userId: "dev-1" };
} else if (apiKey === "key-2") {
(req as any).apiKeyRecord = { userId: "dev-2" };
}
next();
},
rateLimit,
(_req, res) => {
res.json({ ok: true });
},
);

app.use(errorHandler);
return app;
}

beforeEach(() => {
process.env.JWT_SECRET = TEST_JWT_SECRET;
jest.spyOn(logger, "warn").mockImplementation(() => {});
jest.spyOn(logger, "error").mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
if (originalSecret !== undefined) {
process.env.JWT_SECRET = originalSecret;
} else {
delete process.env.JWT_SECRET;
}
});

test("uses apiKeyRecord.userId for rate limiting when available", async () => {
const app = buildProxyApp(2, 1);

await request(app).get("/proxy").set("x-api-key", "key-1").expect(200);
await request(app).get("/proxy").set("x-api-key", "key-1").expect(200);
const response = await request(app).get("/proxy").set("x-api-key", "key-1");

expect(response.status).toBe(429);
expect(response.headers["retry-after"]).toBe("1");

// key-2 should still be allowed
await request(app).get("/proxy").set("x-api-key", "key-2").expect(200);
});

test("falls back to bearer token if apiKeyRecord is missing", async () => {
const app = buildProxyApp(1, 1);
const token = signTestToken({
userId: "user-fallback",
walletAddress: "GDTEST123STELLAR",
});

await request(app)
.get("/proxy")
.set("Authorization", `Bearer ${token}`)
.expect(200);

const response = await request(app)
.get("/proxy")
.set("Authorization", `Bearer ${token}`);

expect(response.status).toBe(429);
});

test("falls back to IP if no auth is present", async () => {
const app = buildProxyApp(1, 1);

await request(app).get("/proxy").expect(200);
const response = await request(app).get("/proxy");

expect(response.status).toBe(429);
expect(response.body.error.code).toBe("TOO_MANY_REQUESTS");
});
});
48 changes: 48 additions & 0 deletions src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,54 @@ export function createQuotaRateLimitMiddleware(
};
}

/**
* Creates a per-user token-bucket rate limit middleware for the proxy route.
*/
export function createProxyRateLimitMiddleware(
options?: TokenBucketOptions,
limiter?: TokenBucketRateLimiter,
): RequestHandler {
const opts: TokenBucketOptions = options ?? { capacity: 100, refillRate: 10 };
const bucket = limiter ?? new TokenBucketRateLimiter(opts.capacity, opts.refillRate);

return (req: Request, res: Response, next: NextFunction): void => {
let key: string;
const apiKeyRecord = (req as any).apiKeyRecord;

if (apiKeyRecord && apiKeyRecord.userId) {
key = `user:${apiKeyRecord.userId}`;
} else {
const { userId } = resolveRequestUserId(req);
if (userId) {
key = `user:${userId}`;
} else {
const clientIp = getClientIp(req);
key = `ip:${clientIp}`;
}
}

const result = bucket.check(key);

if (!result.allowed) {
const retryAfterMs = result.retryAfterMs ?? 1000;
const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000));
const requestId = getRequestId(req);

logger.warn("[proxyRateLimit] request limit exceeded", {
requestId,
key,
retryAfterMs,
});

res.set("Retry-After", String(retryAfterSeconds));
next(new TooManyRequestsError("Too Many Requests"));
return;
}

next();
};
}

// ─── Fixed-Window Rate Limiter ───────────────────────────────────────────────

/**
Expand Down
34 changes: 19 additions & 15 deletions src/routes/proxyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '../metrics.js';
import { createMapBackedGatewayApiKeyAuthMiddleware } from '../middleware/gatewayApiKeyAuth.js';
import { createConfiguredPerKeyConcurrencyMiddleware } from '../middleware/perKeyConcurrency.js';
import { createProxyRateLimitMiddleware } from '../middleware/rateLimit.js';
import { idempotencyMiddleware } from '../middleware/idempotency.js';
import { buildHopByHopSet } from '../lib/hopByHop.js';
import {
Expand Down Expand Up @@ -122,6 +123,9 @@ export function createProxyRouter(deps: ProxyDeps): Router {
// so that req.apiKeyRecord is populated.
const perKeyConcurrency = deps.perKeyConcurrency ?? createConfiguredPerKeyConcurrencyMiddleware();

// Per-user token-bucket rate limit for the proxy route.
const proxyRateLimit = deps.proxyRateLimit ?? createProxyRateLimitMiddleware();

// Idempotency middleware for POST/PATCH to prevent duplicate downstream calls.
// Caches request→response keyed by Idempotency-Key header, ensuring safe retries.
// See docs/api-proxy-idempotency.md for the contract.
Expand Down Expand Up @@ -170,23 +174,23 @@ export function createProxyRouter(deps: ProxyDeps): Router {

// Use a param of 0 to capture the wildcard path (everything after the slug)
// POST and PATCH routes get idempotency protection; GET/DELETE are naturally safe.
router.post('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, idempotencyForProxy, handleProxy);
router.patch('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, idempotencyForProxy, handleProxy);
router.post('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, idempotencyForProxy, handleProxy);
router.patch('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, idempotencyForProxy, handleProxy);
router.post('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, idempotencyForProxy, handleProxy);
router.patch('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, idempotencyForProxy, handleProxy);
router.post('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, idempotencyForProxy, handleProxy);
router.patch('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, idempotencyForProxy, handleProxy);

// GET, DELETE, and other methods pass through without idempotency caching
router.get('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);
router.delete('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);
router.put('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);
router.options('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);
router.head('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);

router.get('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);
router.delete('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);
router.put('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);
router.options('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);
router.head('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, handleProxy);
router.get('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);
router.delete('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);
router.put('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);
router.options('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);
router.head('/:apiSlugOrId/*', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);

router.get('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);
router.delete('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);
router.put('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);
router.options('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);
router.head('/:apiSlugOrId', drainGuard, authMiddleware, perKeyConcurrency, proxyRateLimit, handleProxy);

async function handleProxy(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
Expand Down
1 change: 1 addition & 0 deletions src/types/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export interface ProxyDeps {
authMiddleware?: RequestHandler;
/** Per-API-key concurrency tracker. Defaults to the shared-semaphore middleware. */
perKeyConcurrency?: RequestHandler;
proxyRateLimit?: RequestHandler;
proxyConfig?: Partial<ProxyConfig>;
circuitBreakerStore?: CircuitBreakerStore;
/**
Expand Down
Loading