Skip to content

Unauthenticated image proxy at /api/v1/img misses Tailscale CGNAT range in SSRF blocklist and proxies arbitrary Content-Type responses #84

@tg12

Description

@tg12

Summary

The /api/v1/img image proxy endpoint is unauthenticated and its SSRF blocklist does not cover the 100.64.0.0/10 CGNAT range used by Tailscale networks. Additionally, the proxy applies no Content-Type validation on upstream responses, making it a fully open HTTP proxy for any non-private URL — not just images.

Evidence

src/app/api/v1/img/route.ts lines 23–30 — isPrivateHost blocklist:

function isPrivateHost(hostname: string): boolean {
  if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(hostname)) return true;
  if (hostname === '::1' || hostname.startsWith('fe80:') || hostname.startsWith('fc00:')) return true;
  if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') return true;
  if (hostname === 'localhost' || hostname === '0.0.0.0') return true;
  return false;
}

The blocklist omits 100.64.0.0/10 — the CGNAT range allocated by RFC 6598 and used by Tailscale for all Tailscale node addresses. The osiris scanner service at 100.68.100.15:7700 (hardcoded in the companion osiris codebase) falls squarely within this range and would pass the check.

Lines 32–93 — no authentication check, no Content-Type filtering on the response:

export async function GET(req: NextRequest) {
  // no requireAdmin / session check
  ...
  const contentType = res.headers.get('content-type') ?? 'image/jpeg';
  // returns whatever Content-Type the upstream claims — JSON, HTML, text, etc.
  return new NextResponse(buffer, { headers: { 'Content-Type': contentType, ... } });
}

Why this matters

Any anonymous caller can use the server as an outbound HTTP relay. If deployed in a Tailscale mesh network, hosts in the 100.64–127.x.x.x range are reachable and their full HTTP responses are proxied back to the caller. Because upstream Content-Type is reflected verbatim, the endpoint also serves as a content-laundering proxy — returning JSON, HTML or XML responses disguised as image requests.

Attack or failure scenario

  1. Attacker sends GET /api/v1/img?url=http://100.68.100.15:7700/scan?target=... (same Tailscale host as the osiris scanner).
  2. isPrivateHost('100.68.100.15') returns false — not matched by the regex.
  3. Server fetches the internal service, caches the response (up to 500 entries, 1-hour TTL), and returns the full body with the upstream Content-Type.
  4. Attacker receives internal API responses. Cached results persist for 60 minutes and are then served to subsequent callers.

Root cause

The SSRF blocklist was written against the standard RFC 1918 private ranges but missed RFC 6598 (100.64.0.0/10). The endpoint was never designed to be a general HTTP proxy, but the absence of response Content-Type enforcement made it one.

Recommended fix

  1. Add authentication to /api/v1/img — only authenticated users should be able to request proxy fetches.
  2. Extend isPrivateHost to cover 100.64.0.0/10:
    // RFC 6598 Tailscale CGNAT
    if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(hostname)) return true;
  3. Validate the upstream response Content-Type before proxying — reject anything that is not an image MIME type (image/*).
  4. Consider using DNS resolution at request time to guard against DNS rebinding attacks.

Acceptance criteria

  • GET /api/v1/img?url=http://100.68.0.1/... returns 403 Blocked host.
  • GET /api/v1/img?url=http://... without a valid session returns 401.
  • Upstream responses with non-image Content-Type are rejected with 415.

Suggested labels

security, bug

Priority

P1

Severity

High — unauthenticated open HTTP proxy with incomplete SSRF blocklist; misses Tailscale CGNAT range and proxies non-image content.

Confidence

Confirmed — isPrivateHost regex in route.ts:25 does not cover 100.64.0.0/10; no auth check present; no response Content-Type validation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions