Skip to content

orb(security): the one internet-facing route is the expensive headless-Chromium render, and its rate limiter is bypassable #9044

Description

@JSONbored

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: /metrics404, /v1/repos/.../maintainer-packet404, /loopover/shot400 (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

  1. 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.
  2. Gate the render mode behind a signed, expiring token minted when the review comment is written (the ?key= mode needs no change).
  3. Drop the blanket *.workers.dev/*.pages.dev allowlist, or require the signed token for those hosts.
  4. 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.

Metadata

Metadata

Assignees

Labels

maintainer-onlyOwner-only work — yields no Gittensor points.orbGittensory Orb related - maintainer self-hosting analytics.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions