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
- Attacker sends
GET /api/v1/img?url=http://100.68.100.15:7700/scan?target=... (same Tailscale host as the osiris scanner).
isPrivateHost('100.68.100.15') returns false — not matched by the regex.
- 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.
- 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
- Add authentication to
/api/v1/img — only authenticated users should be able to request proxy fetches.
- 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;
- Validate the upstream response
Content-Type before proxying — reject anything that is not an image MIME type (image/*).
- Consider using DNS resolution at request time to guard against DNS rebinding attacks.
Acceptance criteria
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.
Summary
The
/api/v1/imgimage proxy endpoint is unauthenticated and its SSRF blocklist does not cover the100.64.0.0/10CGNAT range used by Tailscale networks. Additionally, the proxy applies noContent-Typevalidation 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.tslines 23–30 —isPrivateHostblocklist: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 at100.68.100.15:7700(hardcoded in the companionosiriscodebase) falls squarely within this range and would pass the check.Lines 32–93 — no authentication check, no
Content-Typefiltering on the response: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.xrange are reachable and their full HTTP responses are proxied back to the caller. Because upstreamContent-Typeis 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
GET /api/v1/img?url=http://100.68.100.15:7700/scan?target=...(same Tailscale host as the osiris scanner).isPrivateHost('100.68.100.15')returnsfalse— not matched by the regex.Content-Type.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 responseContent-Typeenforcement made it one.Recommended fix
/api/v1/img— only authenticated users should be able to request proxy fetches.isPrivateHostto cover100.64.0.0/10:Content-Typebefore proxying — reject anything that is not an image MIME type (image/*).Acceptance criteria
GET /api/v1/img?url=http://100.68.0.1/...returns403 Blocked host.GET /api/v1/img?url=http://...without a valid session returns401.Content-Typeare rejected with415.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 —
isPrivateHostregex in route.ts:25 does not cover100.64.0.0/10; no auth check present; no responseContent-Typevalidation.