fix(ui/backend): block SSRF via AI provider baseUrl (#393)#412
Open
voidborne-d wants to merge 1 commit intoOpenBMB:mainfrom
Open
fix(ui/backend): block SSRF via AI provider baseUrl (#393)#412voidborne-d wants to merge 1 commit intoOpenBMB:mainfrom
voidborne-d wants to merge 1 commit intoOpenBMB:mainfrom
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #393.
The
/api/ai/testand/api/ai/chatendpoints accept abaseUrlfromthe 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:
Eight unvalidated
requests.get/postsites (one per provider × endpoint× stream/non-stream) all interpolate
base_urldirectly into the URL.Approach
Add a single helper
ui/backend/_ai_base_url.py::validate_ai_base_urlthat 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):
http://andhttps://schemes are accepted (rejectsfile://,data:,javascript:, etc.);rejected — this closes the IMDS attack from Security: SSRF via user-controlled baseUrl in AI proxy endpoints #393 (
169.254.169.254,fe80::/10);Ollama / vLLM / LM Studio at
localhostkeep working — these arestandard targets for self-hosted RAG;
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_allowlistto seed aDEFAULT_ALLOWLIST); happy to adjust.Diff scope
ui/backend/_ai_base_url.py— new helper (159 lines, all docstrings + 5small functions). No new dependencies —
socket/ipaddress/urllibare stdlib.
ui/backend/app.py— +9 lines: one import, two 3-line guards in theendpoint 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 notests/package yet;pytest is in
[dependency-groups].devperpyproject.toml).Tests
Coverage:
file://,ftp://,data:,javascript:, scheme-less URL rejected;rejected; IPv6 link-local literal rejected; multicast / unspecified
rejected;
127.0.0.1,::1) and RFC1918 (10/8,172.16/12,192.168/16) allowed by default — Ollama et al. unaffected;rotating DNS replies;
fe80::1rejected;ULTRARAG_AI_BASE_URL_BLOCK_PRIVATE=1rejects loopback + RFC1918 viaDNS, still allows public;
ULTRARAG_AI_BASE_URL_ALLOWLISTaccepts listed hosts case-insensitively,rejects others without consulting DNS at all.
Pre-flight regression check: stashed
_ai_base_url.pyaside, the testfile fails collection — confirming the helper is the load-bearing piece.
Restored, all 32 pass.
Local gates:
I deliberately did not run
ruff formatoverui/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
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.
IPv6Address('::1').is_reservedis
Truebecause::1sits inside the reserved0::/8block, so thepolicy short-circuits on
is_loopback/is_privatebefore checkingis_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.