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
29 changes: 29 additions & 0 deletions api/auth/challenge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
31 changes: 29 additions & 2 deletions api/auth/challenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down
80 changes: 76 additions & 4 deletions src/lib/observability/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { authenticated: RateLimitConfig; unauthenticated: RateLimitConfig }> = {
const limits: Record<RateLimitType, { authenticated: RateLimitConfig; unauthenticated: RateLimitConfig }> = {
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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
19 changes: 11 additions & 8 deletions src/test/observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down