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:
@@ -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
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user