fix(security): SSRF sub-resource + fail-closed, webhook 500, trusted rate-limit IP#2
Conversation
…rate-limit IP Post-merge follow-ups from the PR #1 review (3 sub-agents + verification). scan-service SSRF (scanner.ts -> request-guard.ts): - Validate EVERY http(s) request -- main nav, redirect hops, AND sub-resources -- via DNS-resolving checkHostSafety, not just literal-IP hosts. Closes the hostname sub-resource SSRF hole (e.g. an <img src> to a name resolving to a private/metadata IP). - Fail CLOSED: guard errors now abort the request (was req.continue()). - Pass through non-network schemes (data:/blob:). Per-scan host cache. - Extracted to request-guard.ts; 7 offline unit tests. webhook (route.ts): - Return 500 on non-23505 insert failures so Stripe retries; a 200 silently dropped a paid report. Pre-insert lookup + 23505 guard make retries safe. rate-limit (client-ip.ts + all 6 routes): - Derive client IP from x-real-ip / last XFF hop, not the spoofable left-most x-forwarded-for token. Verified: scan-service tsc clean + 59/59 tests; app tsc --noEmit clean. DNS-rebind IP-pinning + coupon max_uses atomicity tracked as follow-ups.
Code Review — PR #2 (Security Hardening)Overall: Significant security improvement. The SSRF guard extraction, fail-closed policy, and trusted IP derivation are all well-executed. This is the most impactful PR in the set. Detailed findings below: 🔴 Bugs / Security Issues1. DNS-rebind TOCTOU is still exploitable (acknowledged but worth emphasizing)
The PR description correctly defers this ("needs
2. const realIp = req.headers.get("x-real-ip")?.trim();
if (realIp) return realIp;
Recommendation: Verify Vercel's behavior. If Vercel doesn't set/overwrite 3. Rate-limit key collision on (Also noted in PR #4 review) — 🟡 Edge Cases4. The
This is functionally correct (both will get the same verdict) but wastes DNS lookups. For correctness it's fine; for performance under load, consider storing the pending Promise in the map so concurrent callers await the same resolution. 5.
if (resolved.length === 0) {
return { valid: false, reason: 'Could not resolve hostname' };
}A temporary DNS failure (e.g. resolver timeout) will block a legitimate site. Combined with the per-host cache in 6. The 🟢 Improvements7. Tests don't cover the
8. Tests don't mock The tests for
Consider adding a test with a mocked 9. Webhook idempotency — the 500 return is correct but the error response body leaks internals return NextResponse.json(
{ error: "Failed to persist report" },
{ status: 500 },
);This is fine — the error message is generic. Good practice. 10. The export async function POST(req: Request) {
const clientIp = getClientIp(req);
SummaryThe security posture is significantly improved by this PR. The SSRF sub-resource hole and the fail-open default were both real vulnerabilities, and the fix is well-structured. The main remaining concern is the |
Summary
Post-merge security follow-ups from the PR #1 review (3 independent sub-agents + orchestrator verification). All fixes verified locally.
scan-service SSRF —
scanner.ts→ newrequest-guard.tscheckHostSafety— closes the hostname sub-resource SSRF hole (only literal IPs were blocked before).abort()instead ofcontinue().data:/blob:; per-scan host cache.webhook idempotency —
app/app/api/webhook/route.ts23505insert failures so Stripe retries. A 200 silently dropped a paid report. The pre-insert lookup +23505guard make retries safe.rate-limit key —
app/lib/client-ip.ts+ 6 routesx-real-ip/ last XFF hop, not the spoofable left-mostx-forwarded-fortoken.Test plan
scan-service:tscclean, 59/59 vitest (7 newrequest-guardtests)app:tsc --noEmitcleanDeliberately deferred (need E2E I can't run blind)
--host-resolver-rules/CDP peer-IP + a rebind harness.max_usesatomicity — atomic RPC + caller rejection; touches the Stripe path.report-statusdroppingemail— UI-coupled.