Scope first, because it determines severity
I verified the live ingress. The Cloudflare tunnel on edge-nl-01 exposes exactly one path:
ingress:
- hostname: shots.loopover.ai
path: ^/loopover/shot.*$
service: http://localhost:8787
- hostname: shots.loopover.ai
service: http_status:404
- service: http_status:404
Port 8787 is bound to 127.0.0.1 only and no reverse proxy is running. Verified from outside: /metrics → 404, /v1/repos/.../maintainer-packet → 404, /loopover/shot → 400 (reachable, wants params). So the API is not internet-exposed — good posture, and it downgrades most of the auth findings to defense-in-depth.
But the single route that IS public is the most expensive one in the system.
The problem
GET /loopover/shot (src/api/routes.ts ~1354-1359) drives a full puppeteer render — networkidle0, 20s timeout, viewport up to 2560x2560 (src/review/visual/shot.ts ~646-661). It is unauthenticated by design (GitHub camo proxy must fetch it), and its host allowlist admits any *.workers.dev / *.pages.dev host (shot.ts ~147-154) — free for anyone to register.
Its only protection is the rate limiter, which does not work on this deployment. clientIp() reads only cf-connecting-ip, falling back to the literal "unknown-ip":
// src/auth/rate-limit.ts ~243-246
return normalizeIpAddress(c.req.header("cf-connecting-ip")) ?? "unknown-ip";
That is correct on Cloudflare Workers (which overwrites the header) but false behind a tunnel to a Node process: nothing in src/ reads x-forwarded-for/x-real-ip (zero grep hits). So either
- no header ⇒ every anonymous request worldwide shares one bucket —
/loopover/shot is the expensive class at 20 per 300s, so one client denies screenshots for everyone; or
- attacker sets
Cf-Connecting-Ip: and rotates it per request ⇒ fresh bucket every time, total bypass.
Net: unbounded headless-browser work on the review host, which degrades ORB itself. RATE_LIMITER is genuinely wired in self-host (src/server.ts ~777, Redis-backed), so this is the real control failing, not an unused path.
Also worth noting (not remotely reachable today)
The SSRF guard (src/review/content-lane/safe-url.ts) is strong — decimal/hex/octal/short-form IPv4, IPv6-mapped, trailing dots, *.localhost, .local/.internal, CGNAT — and re-applied per intercepted request and after redirects. Residual gap: it is name-based, not resolved-IP-based, so a public hostname whose A record points at 169.254.169.254 would pass. Low value (output is a PNG the attacker controls) but worth closing by pinning DNS to the validated IP.
Fix
- Trusted-proxy-aware client IP: honor
cf-connecting-ip only from trusted CF ranges, else the right-most untrusted X-Forwarded-For hop, else the socket peer. Never let "unknown-ip" become a shared bucket — fall back to per-connection keying.
- Gate the render mode behind a signed, expiring token minted when the review comment is written (the
?key= mode needs no change).
- Drop the blanket
*.workers.dev/*.pages.dev allowlist, or require the signed token for those hosts.
- Pin DNS resolution to the validated IP.
Acceptance
- Repeated
/loopover/shot requests with rotating Cf-Connecting-Ip are throttled.
- Renders are only performed for URLs ORB itself minted a token for.
Scope first, because it determines severity
I verified the live ingress. The Cloudflare tunnel on edge-nl-01 exposes exactly one path:
Port 8787 is bound to
127.0.0.1only and no reverse proxy is running. Verified from outside:/metrics→ 404,/v1/repos/.../maintainer-packet→ 404,/loopover/shot→ 400 (reachable, wants params). So the API is not internet-exposed — good posture, and it downgrades most of the auth findings to defense-in-depth.But the single route that IS public is the most expensive one in the system.
The problem
GET /loopover/shot(src/api/routes.ts~1354-1359) drives a full puppeteer render —networkidle0, 20s timeout, viewport up to 2560x2560 (src/review/visual/shot.ts~646-661). It is unauthenticated by design (GitHub camo proxy must fetch it), and its host allowlist admits any*.workers.dev/*.pages.devhost (shot.ts~147-154) — free for anyone to register.Its only protection is the rate limiter, which does not work on this deployment.
clientIp()reads onlycf-connecting-ip, falling back to the literal"unknown-ip":That is correct on Cloudflare Workers (which overwrites the header) but false behind a tunnel to a Node process: nothing in
src/readsx-forwarded-for/x-real-ip(zero grep hits). So either/loopover/shotis theexpensiveclass at 20 per 300s, so one client denies screenshots for everyone; orCf-Connecting-Ip:and rotates it per request ⇒ fresh bucket every time, total bypass.Net: unbounded headless-browser work on the review host, which degrades ORB itself.
RATE_LIMITERis genuinely wired in self-host (src/server.ts~777, Redis-backed), so this is the real control failing, not an unused path.Also worth noting (not remotely reachable today)
The SSRF guard (
src/review/content-lane/safe-url.ts) is strong — decimal/hex/octal/short-form IPv4, IPv6-mapped, trailing dots,*.localhost,.local/.internal, CGNAT — and re-applied per intercepted request and after redirects. Residual gap: it is name-based, not resolved-IP-based, so a public hostname whose A record points at169.254.169.254would pass. Low value (output is a PNG the attacker controls) but worth closing by pinning DNS to the validated IP.Fix
cf-connecting-iponly from trusted CF ranges, else the right-most untrustedX-Forwarded-Forhop, else the socket peer. Never let"unknown-ip"become a shared bucket — fall back to per-connection keying.?key=mode needs no change).*.workers.dev/*.pages.devallowlist, or require the signed token for those hosts.Acceptance
/loopover/shotrequests with rotatingCf-Connecting-Ipare throttled.