Follow-up from P0-11 (#337). Claude-doable.
P0-11 made isIPBlocked() real (DB-backed) and wired event logging + the admin block/unblock UI. But nothing calls isIPBlocked on the request path, so blocking an IP in the admin dashboard records the block without actually denying that IP any traffic.
Action
- Call
isIPBlocked in middleware.ts (or a shared guard) early in the request lifecycle; return 403 for active, unexpired blocks.
- Cache the lookup in Redis (short TTL, e.g. 30–60s) so this isn't a per-request DB hit —
@upstash/redis is already wired (P0-3). Invalidate/refresh on blockIP/unblockIP.
- Decide scope: enforce on all routes vs. API/auth only (static assets and the admin unblock route must not be self-blockable).
- Log an
IP_BLOCKED/UNAUTHORIZED_ACCESS security event when a blocked request is denied (loop closes with the dashboards).
Why P1 not P0: the admin tooling and persistence ship in #337; this is the enforcement half. Not a launch blocker on its own, but the block button is misleading until it lands.
Follow-up from P0-11 (#337). Claude-doable.
P0-11 made
isIPBlocked()real (DB-backed) and wired event logging + the admin block/unblock UI. But nothing callsisIPBlockedon the request path, so blocking an IP in the admin dashboard records the block without actually denying that IP any traffic.Action
isIPBlockedinmiddleware.ts(or a shared guard) early in the request lifecycle; return403for active, unexpired blocks.@upstash/redisis already wired (P0-3). Invalidate/refresh onblockIP/unblockIP.IP_BLOCKED/UNAUTHORIZED_ACCESSsecurity event when a blocked request is denied (loop closes with the dashboards).Why P1 not P0: the admin tooling and persistence ship in #337; this is the enforcement half. Not a launch blocker on its own, but the block button is misleading until it lands.