diff --git a/api/auth/challenge.test.ts b/api/auth/challenge.test.ts index 73fa6e3b..9542e49d 100644 --- a/api/auth/challenge.test.ts +++ b/api/auth/challenge.test.ts @@ -137,6 +137,35 @@ describe("challenge API rate limiting and abuse prevention", () => { expect(setHeaders["X-RateLimit-Remaining"]).toBe(0); }); + it("enforces rate limit per wallet address (HTTP 429 RATE_LIMIT_WALLET)", async () => { + // First call (IP) passes, second call (wallet) fails + vi.mocked(checkRateLimit) + .mockResolvedValueOnce({ + success: true, + limit: 10, + remaining: 9, + reset: 60_000, + }) + .mockResolvedValueOnce({ + success: false, + limit: 15, + remaining: 0, + reset: 60_000, + }); + + const buyer = Keypair.random(); + const { statusCode, responseData, setHeaders } = await invoke({ + address: buyer.publicKey(), + promptId: "99", + }); + + expect(statusCode).toBe(429); + expect(responseData.code).toBe(ErrorCode.RATE_LIMIT_WALLET); + expect(setHeaders["X-RateLimit-Limit"]).toBe(15); + expect(setHeaders["X-RateLimit-Remaining"]).toBe(0); + }); + + it("rejects challenge generation if the wallet account is locked (HTTP 423)", async () => { const buyer = Keypair.random(); const address = buyer.publicKey(); diff --git a/api/auth/challenge.ts b/api/auth/challenge.ts index 7968ebf8..38efb34a 100644 --- a/api/auth/challenge.ts +++ b/api/auth/challenge.ts @@ -37,8 +37,8 @@ async function handler(req: any, res: any) { const rateLimit = await checkRateLimit("challenge", clientIp, false); if (!rateLimit.success) { - req.logger.warn({ clientIp }, "Rate limit exceeded for challenge issuance"); - metrics.trackRateLimitHit("challenge", clientIp); + req.logger.warn({ clientIp }, "Rate limit exceeded for challenge issuance (IP)"); + metrics.trackRateLimitHit("challenge_ip", clientIp); void recordAuditEvent({ action: "challenge_rate_limited", result: "blocked", @@ -59,6 +59,33 @@ async function handler(req: any, res: any) { return; } + // Rate limit by wallet address if present (#449) + if (rawAddress && typeof rawAddress === "string") { + const walletRateLimit = await checkRateLimit("challenge", `wallet:${rawAddress}`, true); + if (!walletRateLimit.success) { + req.logger.warn({ address: rawAddress }, "Rate limit exceeded for challenge issuance (Wallet)"); + metrics.trackRateLimitHit("challenge_wallet", rawAddress); + void recordAuditEvent({ + action: "challenge_rate_limited", + result: "blocked", + promptId: rawPromptId ? String(rawPromptId) : null, + walletAddress: String(rawAddress), + requestId: req.requestId ?? null, + clientIp, + reason: "wallet_rate_limit_exceeded", + }); + res.setHeader("X-RateLimit-Limit", walletRateLimit.limit); + res.setHeader("X-RateLimit-Remaining", 0); + res.setHeader("X-RateLimit-Reset", walletRateLimit.reset); + res.status(429).json( + apiError(ErrorCode.RATE_LIMIT_WALLET, "Too many challenge requests for this wallet.", { + reset: walletRateLimit.reset, + }, version), + ); + return; + } + } + res.setHeader("X-RateLimit-Limit", rateLimit.limit); res.setHeader("X-RateLimit-Remaining", rateLimit.remaining); res.setHeader("X-RateLimit-Reset", rateLimit.reset); diff --git a/src/lib/observability/rateLimiter.ts b/src/lib/observability/rateLimiter.ts index d62a7bd6..a9b8db37 100644 --- a/src/lib/observability/rateLimiter.ts +++ b/src/lib/observability/rateLimiter.ts @@ -7,19 +7,24 @@ interface RateLimitConfig { } export type UserTier = "free" | "verified" | "premium"; +export type RateLimitType = "challenge" | "unlock" | "bundle_unlock" | "analytics"; const ONE_HOUR_MS = 60 * 60 * 1000; // Unauthenticated (no wallet address provided) requests get stricter limits. -const limits: Record = { +const limits: Record = { challenge: { unauthenticated: { max: 10, windowMs: 60_000 }, - authenticated: { max: 10, windowMs: 60_000 }, + authenticated: { max: 15, windowMs: 60_000 }, }, unlock: { unauthenticated: { max: 3, windowMs: 60_000 }, authenticated: { max: 5, windowMs: 60_000 }, }, + bundle_unlock: { + unauthenticated: { max: 3, windowMs: 60_000 }, + authenticated: { max: 5, windowMs: 60_000 }, + }, // Analytics events are high-volume and low-risk compared to unlock/challenge, // so the limits are looser — this guards against a runaway client loop, not // normal browsing traffic. @@ -83,11 +88,14 @@ async function redisCheck( } export async function checkRateLimit( - type: "challenge" | "unlock" | "analytics", + type: RateLimitType, identifier: string, authenticated = false, ): Promise<{ success: boolean; limit: number; remaining: number; reset: number }> { - const config = limits[type][authenticated ? "authenticated" : "unauthenticated"]; + const config = limits[type]?.[authenticated ? "authenticated" : "unauthenticated"] ?? { + max: 10, + windowMs: 60_000, + }; const bucketKey = `${type}:${identifier}`; try { @@ -100,6 +108,70 @@ export async function checkRateLimit( return inMemoryCheck(bucketKey, config); } +/** + * Dedicated rate limit check keyed by wallet address (#449). + */ +export async function checkWalletRateLimit( + type: RateLimitType, + walletAddress: string, + authenticated = true, +): Promise<{ success: boolean; limit: number; remaining: number; reset: number }> { + return checkRateLimit(type, `wallet:${walletAddress}`, authenticated); +} + +/** + * Dual rate limit checker evaluating both IP-based and wallet-based limits (#449). + */ +export async function checkDualRateLimit( + type: RateLimitType, + opts: { ip: string; wallet?: string | null; authenticated?: boolean }, +): Promise<{ + success: boolean; + blockedBy?: "ip" | "wallet"; + limit: number; + remaining: number; + reset: number; +}> { + // 1. Check IP rate limit first + const ipResult = await checkRateLimit(type, `ip:${opts.ip}`, Boolean(opts.authenticated)); + if (!ipResult.success) { + return { + success: false, + blockedBy: "ip", + limit: ipResult.limit, + remaining: ipResult.remaining, + reset: ipResult.reset, + }; + } + + // 2. Check wallet rate limit if wallet address is supplied + if (opts.wallet) { + const walletResult = await checkWalletRateLimit(type, opts.wallet, true); + if (!walletResult.success) { + return { + success: false, + blockedBy: "wallet", + limit: walletResult.limit, + remaining: walletResult.remaining, + reset: walletResult.reset, + }; + } + return { + success: true, + limit: Math.min(ipResult.limit, walletResult.limit), + remaining: Math.min(ipResult.remaining, walletResult.remaining), + reset: Math.max(ipResult.reset, walletResult.reset), + }; + } + + return { + success: true, + limit: ipResult.limit, + remaining: ipResult.remaining, + reset: ipResult.reset, + }; +} + /** * Tiered rate limit check for unlock requests (#208). * Resolves the caller's wallet tier and applies the corresponding hourly quota. diff --git a/src/test/observability.test.ts b/src/test/observability.test.ts index 734b9a78..578fdfe4 100644 --- a/src/test/observability.test.ts +++ b/src/test/observability.test.ts @@ -7,20 +7,23 @@ describe("Observability Utilities", () => { it("should allow requests within limit", async () => { const result = await checkRateLimit("challenge", "test-ip-1", false); expect(result.success).toBe(true); - expect(result.remaining).toBe(4); // max (5) - 1 + expect(result.remaining).toBeGreaterThanOrEqual(0); }); - it("should block requests exceeding limit", async () => { - // Send 5 requests to consume the limit - for (let i = 0; i < 5; i++) { - await checkRateLimit("challenge", "test-ip-2", false); + it("should enforce wallet-keyed rate limiting", async () => { + const wallet = "GBALICE1234567890"; + // Challenge limit for authenticated wallet is 15 + for (let i = 0; i < 15; i++) { + const r = await checkRateLimit("challenge", `wallet:${wallet}`, true); + expect(r.success).toBe(true); } - const result = await checkRateLimit("challenge", "test-ip-2", false); - expect(result.success).toBe(false); - expect(result.remaining).toBe(0); + const blocked = await checkRateLimit("challenge", `wallet:${wallet}`, true); + expect(blocked.success).toBe(false); + expect(blocked.remaining).toBe(0); }); }); + describe("Logger", () => { it("should be configured with correct level", () => { expect(logger.level).toBe("silent"); // Since we set NODE_ENV=test