validate custom-provider base_url and gate project-local providers (F1)

A custom provider's base_url is where the full corpus and the user's API key
are sent. A project-local ./.graphify/providers.json travels with a cloned or
shared repo, so loading it silently was a corpus/key exfiltration channel.
Require GRAPHIFY_ALLOW_LOCAL_PROVIDERS=1 to load the project-local file (the
user's own ~/.graphify/providers.json stays trusted), reject non-http(s)
schemes on load and on 'provider add', and warn on plaintext-http egress. We
deliberately do not apply the ingest SSRF guard here so legitimate on-prem
https LLM gateways still work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-06-02 22:16:58 +01:00
co-authored by Claude Opus 4.8
parent 4a200159af
commit e3993e4801
3 changed files with 135 additions and 5 deletions
+4
View File
@@ -2368,6 +2368,10 @@ def main() -> None:
if not base_url or not default_model or not env_key:
print("Error: --base-url, --default-model, and --env-key are required.", file=sys.stderr)
sys.exit(1)
from graphify.llm import provider_base_url_ok
if not provider_base_url_ok(base_url, name):
print(f"Error: refusing to add provider with unsafe base_url {base_url!r}.", file=sys.stderr)
sys.exit(1)
global_path.parent.mkdir(parents=True, exist_ok=True)
existing = {}
if global_path.is_file():
+62 -5
View File
@@ -125,18 +125,75 @@ def _custom_providers_path(global_: bool = True) -> Path:
return Path(".graphify") / "providers.json"
def provider_base_url_ok(base_url: str, name: str, *, warn: bool = True) -> bool:
"""Structural safety check for a custom-provider base_url.
A custom provider receives the full corpus plus the user's API key, so its
base_url is an exfiltration channel. We deliberately do NOT run the ingest
SSRF guard here: that blocks private/internal IPs, which would wrongly reject
legitimate on-prem corporate LLM gateways. Instead we reject non-http(s)
schemes outright and warn loudly when the corpus would leave over plaintext
http to a non-loopback host. The primary control against trusting injected
config is the GRAPHIFY_ALLOW_LOCAL_PROVIDERS gate on project-local files.
"""
from urllib.parse import urlparse
try:
parsed = urlparse(base_url)
except Exception:
if warn:
print(f"[graphify] WARNING: provider {name!r} has an unparseable base_url; ignoring.", file=sys.stderr)
return False
if parsed.scheme not in ("http", "https"):
if warn:
print(
f"[graphify] WARNING: provider {name!r} base_url scheme {parsed.scheme!r} is not "
"http/https; ignoring.",
file=sys.stderr,
)
return False
host = (parsed.hostname or "").lower()
is_loopback = host in ("localhost", "127.0.0.1", "::1") or host.startswith("127.")
if warn and parsed.scheme == "http" and not is_loopback:
print(
f"[graphify] WARNING: provider {name!r} sends your corpus to {host!r} over plaintext "
"http. Use https unless this is a trusted local endpoint.",
file=sys.stderr,
)
return True
def _load_custom_providers() -> dict[str, dict]:
# A project-local ./.graphify/providers.json travels with a cloned or shared
# repo and defines where the corpus + API key are sent, so loading it
# silently is a corpus/key exfiltration vector. Require an explicit opt-in;
# the user's own global ~/.graphify/providers.json stays trusted.
local_path = _custom_providers_path(global_=False)
global_path = _custom_providers_path(global_=True)
allow_local = os.environ.get("GRAPHIFY_ALLOW_LOCAL_PROVIDERS", "").strip().lower() in ("1", "true", "yes")
if local_path.is_file() and not allow_local:
print(
f"[graphify] WARNING: ignoring project-local {local_path} (custom providers control "
"where your corpus and API key are sent). Set GRAPHIFY_ALLOW_LOCAL_PROVIDERS=1 to load it.",
file=sys.stderr,
)
providers: dict[str, dict] = {}
for path in (_custom_providers_path(global_=False), _custom_providers_path(global_=True)):
paths = [local_path, global_path] if allow_local else [global_path]
for path in paths:
if path.is_file():
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
for name, cfg in data.items():
if isinstance(name, str) and isinstance(cfg, dict) and name not in BACKENDS:
if "pricing" not in cfg:
cfg = dict(cfg, pricing={"input": 0.0, "output": 0.0})
providers[name] = cfg
if not (isinstance(name, str) and isinstance(cfg, dict)):
continue
if name in BACKENDS or name in providers:
continue
if not provider_base_url_ok(str(cfg.get("base_url", "")), name):
continue
if "pricing" not in cfg:
cfg = dict(cfg, pricing={"input": 0.0, "output": 0.0})
providers[name] = cfg
except Exception:
pass
return providers
+69
View File
@@ -69,6 +69,75 @@ def test_custom_provider_cannot_shadow_builtin(tmp_path):
assert "claude" not in loaded
def test_project_local_providers_ignored_without_optin(tmp_path, monkeypatch, capsys):
"""A project-local ./.graphify/providers.json is NOT loaded by default (F1).
It travels with a cloned/shared repo and controls where the corpus + API key
are sent, so loading it silently is an exfiltration vector.
"""
local = tmp_path / "local.json"
local.write_text(json.dumps({
"evil": {"base_url": "https://attacker.example/v1", "default_model": "m", "env_key": "K"}
}), encoding="utf-8")
missing_global = tmp_path / "global.json" # does not exist
from graphify import llm
monkeypatch.setattr(llm, "_custom_providers_path",
lambda global_=True: missing_global if global_ else local)
monkeypatch.delenv("GRAPHIFY_ALLOW_LOCAL_PROVIDERS", raising=False)
loaded = llm._load_custom_providers()
assert "evil" not in loaded
assert "ignoring project-local" in capsys.readouterr().err
def test_project_local_providers_loaded_with_optin(tmp_path, monkeypatch):
"""With explicit opt-in the project-local file is honoured (F1)."""
local = tmp_path / "local.json"
local.write_text(json.dumps({
"lab": {"base_url": "https://lab.internal/v1", "default_model": "m", "env_key": "K"}
}), encoding="utf-8")
missing_global = tmp_path / "global.json"
from graphify import llm
monkeypatch.setattr(llm, "_custom_providers_path",
lambda global_=True: missing_global if global_ else local)
monkeypatch.setattr(llm, "BACKENDS", {**llm.BACKENDS})
monkeypatch.setenv("GRAPHIFY_ALLOW_LOCAL_PROVIDERS", "1")
loaded = llm._load_custom_providers()
assert "lab" in loaded
def test_non_http_provider_base_url_rejected(tmp_path, monkeypatch):
"""A provider whose base_url uses a non-http(s) scheme is skipped on load (F1)."""
providers_file = tmp_path / "providers.json"
providers_file.write_text(json.dumps({
"sneaky": {"base_url": "file:///etc/passwd", "default_model": "m", "env_key": "K"}
}), encoding="utf-8")
from graphify import llm
monkeypatch.setattr(llm, "_custom_providers_path",
lambda global_=True: providers_file if global_ else tmp_path / "local.json")
monkeypatch.setattr(llm, "BACKENDS", {**llm.BACKENDS})
loaded = llm._load_custom_providers()
assert "sneaky" not in loaded
def test_provider_base_url_ok_scheme_and_warnings(capsys):
"""provider_base_url_ok rejects bad schemes and warns on plaintext-http egress (F1)."""
from graphify import llm
assert llm.provider_base_url_ok("https://api.example/v1", "ok") is True
assert llm.provider_base_url_ok("http://localhost:11434/v1", "local") is True
assert llm.provider_base_url_ok("file:///etc/passwd", "bad") is False
assert llm.provider_base_url_ok("gopher://x/", "bad2") is False
capsys.readouterr()
# plaintext http to a non-loopback host loads but warns
assert llm.provider_base_url_ok("http://example.com/v1", "plain") is True
assert "plaintext" in capsys.readouterr().err
def test_detect_backend_custom_provider_after_builtins(monkeypatch):
"""Custom providers appear after all built-ins in detect_backend() priority."""
from graphify import llm