diff --git a/graphify/extract.py b/graphify/extract.py index 7b392d6c5..a56a7809c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5248,8 +5248,11 @@ def _cpp_preprocess(path: Path) -> bytes: if not shutil.which("cpp"): return path.read_bytes() try: + # Pass an absolute path so a corpus file named like "-I/etc/x.F90" cannot + # be parsed by cpp as an option (cpp does not accept a "--" end-of-options + # terminator). An absolute path always begins with "/". result = subprocess.run( - ["cpp", "-w", "-P", "-nostdinc", "-I", "/dev/null", str(path)], + ["cpp", "-w", "-P", "-nostdinc", "-I", "/dev/null", str(path.resolve())], capture_output=True, timeout=30, ) diff --git a/graphify/llm.py b/graphify/llm.py index 1619ad806..f9a0cd48e 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -1251,11 +1251,13 @@ def estimate_cost(backend: str, input_tokens: int, output_tokens: int) -> float: def _validate_ollama_base_url(url: str) -> None: - """Warn (do not raise) if OLLAMA_BASE_URL looks unsafe. + """Warn if OLLAMA_BASE_URL looks unsafe; hard-block link-local/metadata (F3). Sending an entire corpus to a non-loopback http:// endpoint silently leaks - proprietary code; we surface a visible stderr warning instead of failing - closed (some users genuinely run Ollama on a LAN host they trust). + proprietary code, but some users genuinely run Ollama on a LAN host they + trust, so a general non-loopback target only warns. A link-local or cloud + metadata address (169.254.x, metadata.google.*) is never a legitimate Ollama + host and is a classic SSRF target, so we fail closed with a ValueError there. """ try: from urllib.parse import urlparse @@ -1274,6 +1276,14 @@ def _validate_ollama_base_url(url: str) -> None: ) return host = (parsed.hostname or "").lower() + if ( + host.startswith("169.254.") # link-local, includes the 169.254.169.254 metadata IP + or host in ("metadata.google.internal", "metadata.google.com", "0.0.0.0", "::", "[::]") # nosec B104 - blocklist, not a bind + ): + raise ValueError( + f"OLLAMA_BASE_URL points at a link-local/metadata address ({host!r}); refusing to " + "send the corpus there. Set it to a real Ollama host." + ) is_loopback = host in ("localhost", "127.0.0.1", "::1") or host.startswith("127.") if not is_loopback: scheme_note = " (UNENCRYPTED)" if parsed.scheme == "http" else "" diff --git a/tests/test_cpp_preprocess.py b/tests/test_cpp_preprocess.py new file mode 100644 index 000000000..75ca8de5f --- /dev/null +++ b/tests/test_cpp_preprocess.py @@ -0,0 +1,32 @@ +"""The Fortran C-preprocessor path is hardened against argument injection (F5). + +A corpus file is attacker-named; cpp does not accept a "--" end-of-options +terminator, so _cpp_preprocess passes an absolute path which can never be parsed +as a cpp option. +""" +from graphify import extract + + +def test_cpp_preprocess_passes_absolute_path(tmp_path, monkeypatch): + f = tmp_path / "weird.F90" + f.write_text("program x\nend program x\n") + + captured = {} + + def fake_run(argv, **kwargs): + captured["argv"] = argv + + class _Result: + returncode = 0 + stdout = b"preprocessed" + + return _Result() + + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/cpp") + monkeypatch.setattr("subprocess.run", fake_run) + + out = extract._cpp_preprocess(f) + assert out == b"preprocessed" + last_arg = captured["argv"][-1] + assert last_arg.startswith("/"), f"path arg must be absolute, got {last_arg!r}" + assert not last_arg.startswith("-"), "path arg must never look like an option" diff --git a/tests/test_ollama.py b/tests/test_ollama.py index a7af29a64..4991557b4 100644 --- a/tests/test_ollama.py +++ b/tests/test_ollama.py @@ -1,7 +1,29 @@ """Tests for the Ollama backend additions in graphify/llm.py.""" from __future__ import annotations -from graphify.llm import detect_backend, BACKENDS +import pytest + +from graphify.llm import detect_backend, BACKENDS, _validate_ollama_base_url + + +@pytest.mark.parametrize("url", [ + "http://169.254.169.254/v1", + "http://169.254.1.5:11434/v1", + "http://metadata.google.internal/v1", + "http://0.0.0.0:11434/v1", +]) +def test_ollama_blocks_link_local_and_metadata(url): + """Link-local / cloud-metadata Ollama targets fail closed (F3).""" + with pytest.raises(ValueError): + _validate_ollama_base_url(url) + + +def test_ollama_loopback_and_lan_do_not_raise(capsys): + """Loopback is silent; a general LAN host warns but is allowed (F3).""" + _validate_ollama_base_url("http://localhost:11434/v1") + assert capsys.readouterr().err == "" + _validate_ollama_base_url("http://192.168.1.50:11434/v1") # LAN: warn, not raise + assert "non-loopback" in capsys.readouterr().err def test_ollama_in_backends():