diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index b5a48e96a..46f3c1039 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -299,6 +299,12 @@ def call_llm(repo: str, number: int, pr: dict[str, Any], diff: str, truncated: b if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified: raise ValueError("URL cannot target internal IP addresses") + if not (api_url.lower().startswith("http://") or api_url.lower().startswith("https://")): + raise ValueError( + "NOEMA_LLM_API_URL must start with http:// or https:// " + "to prevent SSRF vulnerabilities" + ) + prompt = { "role": "user", "content": "\n".join( diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 08ae5f609..a4fb4a05a 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -286,6 +286,25 @@ def fake_getaddrinfo_invalid_ip(host, port, *args, **kwargs): assert noema.call_llm("owner/repo", 1, pr, "diff", True)["decision"] == "approve" +def test_call_llm_rejects_control_character_scheme_evasion(monkeypatch): + """A URL with an embedded tab is normalized by urlparse to an http scheme + with a valid hostname, but its raw form does not start with http:// — the + startswith guard must still reject it to prevent SSRF via control-character + scheme evasion.""" + pr = make_pr() + monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") + monkeypatch.setenv("NOEMA_LLM_API_URL", "http\t://sneaky.example.com/chat") + + import socket + + def raise_gaierror(host, port, *args, **kwargs): + raise socket.gaierror("Name or service not known") + + monkeypatch.setattr(socket, "getaddrinfo", raise_gaierror) + with pytest.raises(ValueError, match="must start with http:// or https://"): + noema.call_llm("owner/repo", 1, pr, "diff", False) + + def test_format_findings_and_submit_review(monkeypatch): findings = noema.format_findings( [