Skip to content

fix(ui/backend): block SSRF via AI provider baseUrl (#393)#412

Open
voidborne-d wants to merge 1 commit intoOpenBMB:mainfrom
voidborne-d:fix/ai-base-url-ssrf-393
Open

fix(ui/backend): block SSRF via AI provider baseUrl (#393)#412
voidborne-d wants to merge 1 commit intoOpenBMB:mainfrom
voidborne-d:fix/ai-base-url-ssrf-393

Conversation

@voidborne-d
Copy link
Copy Markdown

Summary

Closes #393.

The /api/ai/test and /api/ai/chat endpoints accept a baseUrl from
the request body and use it directly to construct outbound HTTP requests,
with no validation of the destination host. As demonstrated in the
issue, this lets any caller make the UltraRAG backend issue requests
to arbitrary internal hosts:

curl -X POST http://<ultrarag-host>/api/ai/test \
  -H "Content-Type: application/json" \
  -d '{"provider":"openai","baseUrl":"http://169.254.169.254/latest","apiKey":"x","model":"m"}'
# server issues: GET http://169.254.169.254/latest/models

Eight unvalidated requests.get/post sites (one per provider × endpoint
× stream/non-stream) all interpolate base_url directly into the URL.

Approach

Add a single helper ui/backend/_ai_base_url.py::validate_ai_base_url
that runs once at the top of each endpoint, before any outbound call.
Both endpoints call it before any of their existing branches.

The reporter suggested a strict provider-domain allowlist. I chose a
slightly different default that fits the self-hosted RAG audience UI/AI
chat is built for, and exposed the strict mode behind an env var.

Default policy (drop-in safe for existing users):

  • only http:// and https:// schemes are accepted (rejects file://,
    data:, javascript:, etc.);
  • link-local, multicast, reserved and unspecified addresses are always
    rejected — this closes the IMDS attack from Security: SSRF via user-controlled baseUrl in AI proxy endpoints #393 (169.254.169.254,
    fe80::/10);
  • loopback and RFC1918 private addresses are allowed by default so that
    Ollama / vLLM / LM Studio at localhost keep working — these are
    standard targets for self-hosted RAG;
  • all A/AAAA records resolved for the hostname are checked, so a
    hostile hostname rotating between a public IP and IMDS still fails.

Strict-mode opt-ins for production:

  • ULTRARAG_AI_BASE_URL_BLOCK_PRIVATE=1 — also reject loopback / private
    / shared / site-local. Operators set this when the host has no
    legitimate sibling AI service.
  • ULTRARAG_AI_BASE_URL_ALLOWLIST=api.openai.com,api.anthropic.com,…
    only listed hostnames pass. This short-circuits before DNS so that an
    attacker can't trigger DNS exfiltration or pin a worker on a slow
    resolver in strict deployments.

If you'd prefer the reporter's exact "strict allowlist by default" shape
instead, that's a one-line change (flip _read_allowlist to seed a
DEFAULT_ALLOWLIST); happy to adjust.

Diff scope

  • ui/backend/_ai_base_url.py — new helper (159 lines, all docstrings + 5
    small functions). No new dependencies — socket/ipaddress/urllib
    are stdlib.
  • ui/backend/app.py — +9 lines: one import, two 3-line guards in the
    endpoint handlers. No reformatting of existing code.
  • tests/test_ai_base_url_validation.py — 32 tests covering the policy.
  • tests/__init__.py — empty (the repo had no tests/ package yet;
    pytest is in [dependency-groups].dev per pyproject.toml).

Tests

$ uv run pytest tests/test_ai_base_url_validation.py -v
============================== 32 passed in 0.01s ==============================

Coverage:

  • empty / whitespace / non-string baseUrl rejected;
  • file://, ftp://, data:, javascript:, scheme-less URL rejected;
  • IPv4 IMDS literal (the issue's exact attack) and other 169.254/16
    rejected; IPv6 link-local literal rejected; multicast / unspecified
    rejected;
  • loopback (127.0.0.1, ::1) and RFC1918 (10/8, 172.16/12,
    192.168/16) allowed by default — Ollama et al. unaffected;
  • public hostname resolving to a public IP allowed;
  • DNS rebinding case: hostname resolving to IMDS rejected;
  • mixed case (one safe + one unsafe address) rejected — defends against
    rotating DNS replies;
  • AAAA-only hostname resolving to fe80::1 rejected;
  • unresolvable hostname rejected;
  • ULTRARAG_AI_BASE_URL_BLOCK_PRIVATE=1 rejects loopback + RFC1918 via
    DNS, still allows public;
  • ULTRARAG_AI_BASE_URL_ALLOWLIST accepts listed hosts case-insensitively,
    rejects others without consulting DNS at all.

Pre-flight regression check: stashed _ai_base_url.py aside, the test
file fails collection — confirming the helper is the load-bearing piece.
Restored, all 32 pass.

Local gates:

$ uv run ruff check ui/backend/_ai_base_url.py ui/backend/app.py tests/test_ai_base_url_validation.py
All checks passed!
$ uv run ruff format --check ui/backend/_ai_base_url.py tests/test_ai_base_url_validation.py
2 files already formatted

I deliberately did not run ruff format over ui/backend/app.py
it would touch ~50 unrelated lines that pre-date this PR. Happy to do
that as a separate commit if preferred.

Notes

  • Implementation choice on private/loopback default: rejecting them by
    default would break every self-hosted RAG user pointing at Ollama or
    a local vLLM. The IMDS class of attack (link-local + cloud-metadata)
    is the part that's actually unsafe regardless of deployment shape, so
    that's the always-deny set.
  • One subtle Python 3.12 detail handled in the predicate: IPv6Address('::1').is_reserved
    is True because ::1 sits inside the reserved 0::/8 block, so the
    policy short-circuits on is_loopback/is_private before checking
    is_reserved. Otherwise legitimate IPv6 loopback would be rejected.

Disclosure

Scoping, code, and tests were drafted with the assistance of Claude
(Anthropic). All changes and reasoning have been reviewed by the
contributor (voidborne-d). Happy to iterate on review feedback.

The /api/ai/test and /api/ai/chat endpoints accepted a baseUrl from
the request body and used it directly to construct outbound HTTP
requests, with no validation of the destination host. As reported in
OpenBMB#393 this lets any caller turn the UltraRAG backend into an SSRF proxy
to cloud instance metadata services (169.254.169.254), arbitrary
internal hosts, and so on.

Add a small validate_ai_base_url() helper used by both endpoints.

Default policy aimed at self-hosted RAG users:
- only http/https schemes are accepted;
- link-local, multicast, reserved and unspecified addresses are
  always rejected (closes the IMDS attack from the issue);
- loopback and RFC1918 private addresses are allowed by default so
  Ollama / vLLM / LM Studio at localhost keep working;
- all A/AAAA records resolved for the hostname are checked, so a
  hostile hostname rotating between a public IP and 169.254.169.254
  is still rejected.

Two opt-in env vars tighten policy for production:
- ULTRARAG_AI_BASE_URL_BLOCK_PRIVATE=1 also rejects loopback / private;
- ULTRARAG_AI_BASE_URL_ALLOWLIST=api.openai.com,api.anthropic.com,...
  enables strict allowlist mode (DNS not consulted for unlisted hosts).

32 unit tests covering scheme, IP literals, DNS resolution, the
DNS-rebinding case, and both env-var modes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: SSRF via user-controlled baseUrl in AI proxy endpoints

1 participant