From 8dc9d20c7910bef93635b68b02ee87437403b747 Mon Sep 17 00:00:00 2001 From: georgeefaith Date: Fri, 26 Jun 2026 22:51:42 +0000 Subject: [PATCH] feat(api): add IP-based rate limiting for POST /api/readings and GET /api/verify (#491) - POST /api/readings: 10 requests / 60s per IP with Retry-After, X-RateLimit-* headers - GET /api/verify: 30 requests / 60s per IP with same headers - Uses existing lib/rate-limit.ts (checkRateLimit + getClientIp) - Removes dead no-op checkRateLimitByKey stub from readings route - 429 response includes retryAfter in body for client guidance Closes #491 --- apps/web/src/app/api/readings/route.ts | 52 ++++++++++++-------------- apps/web/src/app/api/verify/route.ts | 24 ++++++++++++ 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/apps/web/src/app/api/readings/route.ts b/apps/web/src/app/api/readings/route.ts index 5ff0ec0..620730c 100644 --- a/apps/web/src/app/api/readings/route.ts +++ b/apps/web/src/app/api/readings/route.ts @@ -5,21 +5,17 @@ import { createServiceClient } from '@/lib/supabase' import { computeReadingHash } from '@/lib/crypto' import { kwhToStroops } from '@solarproof/stellar' import { checkRateLimit } from '@/lib/cache' +import { checkRateLimit as checkIpRateLimit, getClientIp } from '@/lib/rate-limit' import { getIdempotentResponse, storeIdempotentResponse } from '@/lib/idempotency' import { logger } from '@/lib/logger' import { requireAuth, isAuthError } from '@/lib/auth' import { enqueue } from '@/lib/queue' const NONCE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours -const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL - -/** Simple per-key rate limiter (no-op when Redis is unavailable). */ -async function checkRateLimitByKey( - _key: string, _limit: number -): Promise<{ allowed: boolean; resetSeconds: number; remaining: number }> { - // Falls back to allow-all; the pubkey-based checkRateLimit handles enforcement - return { allowed: true, resetSeconds: 0, remaining: _limit } -} + +// IP rate limit: 10 POSTs per 60 s per IP +const IP_RATE_LIMIT = 10 +const IP_RATE_WINDOW_MS = 60_000 const QuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), cursor: z.string().optional(), @@ -97,6 +93,25 @@ export async function POST(req: NextRequest) { const correlationId = req.headers.get('x-correlation-id') ?? undefined const log = correlationId ? logger.withCorrelationId(correlationId) : logger + // IP-based rate limit: 10 requests / 60 s per IP (abuse protection) + const ip = getClientIp(req) + const ipRl = checkIpRateLimit(`ip:readings:${ip}`, IP_RATE_LIMIT, IP_RATE_WINDOW_MS) + if (!ipRl.allowed) { + const retryAfter = Math.ceil((ipRl.resetAt - Date.now()) / 1000) + return NextResponse.json( + { error: 'Too many requests. Please try again later.', retryAfter }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfter), + 'X-RateLimit-Limit': String(IP_RATE_LIMIT), + 'X-RateLimit-Remaining': String(ipRl.remaining), + 'X-RateLimit-Reset': String(Math.ceil(ipRl.resetAt / 1000)), + }, + } + ) + } + // Idempotency-Key header check const idempotencyKey = req.headers.get('idempotency-key') if (idempotencyKey) { @@ -115,25 +130,6 @@ export async function POST(req: NextRequest) { } const { meter_id, kwh, timestamp, signature_hex, nonce } = parsed.data - const limit = Number(process.env.READINGS_RATE_LIMIT_PER_MINUTE ?? 60) - // Redis-backed sliding-window rate limit by meter_id - const rateKey = `rate:readings:${meter_id}` - if (UPSTASH_REDIS_REST_URL) { - const rate = await checkRateLimitByKey(rateKey, limit) - if (!rate.allowed) { - return NextResponse.json( - { error: 'Too many requests, please try again later' }, - { - status: 429, - headers: { - 'Retry-After': rate.resetSeconds.toString(), - 'X-RateLimit-Limit': limit.toString(), - 'X-RateLimit-Remaining': rate.remaining.toString(), - }, - } - ) - } - } const db = createServiceClient() diff --git a/apps/web/src/app/api/verify/route.ts b/apps/web/src/app/api/verify/route.ts index 0aa1db2..9ea432d 100644 --- a/apps/web/src/app/api/verify/route.ts +++ b/apps/web/src/app/api/verify/route.ts @@ -4,6 +4,11 @@ import { createAnonClient } from '@/lib/supabase' import { getCachedCert, setCachedCert } from '@/lib/cache' import { stellarExplorerUrl, type NetworkName } from '@solarproof/stellar' import { env } from '@/env' +import { checkRateLimit as checkIpRateLimit, getClientIp } from '@/lib/rate-limit' + +// IP rate limit: 30 GETs per 60 s per IP +const IP_RATE_LIMIT = 30 +const IP_RATE_WINDOW_MS = 60_000 // UUID or 64-char hex hash (reading_hash / tx_hash) const VerifyQuerySchema = z.object({ @@ -18,6 +23,25 @@ const VerifyQuerySchema = z.object({ * Results are cached in Redis for 60 s (TTL defined in cache.ts). */ export async function GET(req: NextRequest) { + // IP-based rate limit: 30 requests / 60 s per IP + const ip = getClientIp(req) + const ipRl = checkIpRateLimit(`ip:verify:${ip}`, IP_RATE_LIMIT, IP_RATE_WINDOW_MS) + if (!ipRl.allowed) { + const retryAfter = Math.ceil((ipRl.resetAt - Date.now()) / 1000) + return NextResponse.json( + { error: 'Too many requests. Please try again later.', retryAfter }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfter), + 'X-RateLimit-Limit': String(IP_RATE_LIMIT), + 'X-RateLimit-Remaining': String(ipRl.remaining), + 'X-RateLimit-Reset': String(Math.ceil(ipRl.resetAt / 1000)), + }, + } + ) + } + const queryParams = Object.fromEntries(req.nextUrl.searchParams.entries()) const parsed = VerifyQuerySchema.safeParse(queryParams) if (!parsed.success) {