diff --git a/src/headerproof/detectors.py b/src/headerproof/detectors.py index b22298c..4e3a97e 100644 --- a/src/headerproof/detectors.py +++ b/src/headerproof/detectors.py @@ -6,6 +6,7 @@ from .constants import CACHEABLE_STATUSES, LIKELY_AUTH_COOKIE, TEXTUAL_CONTENT, UNSAFE_METHODS from .evidence import make_signal from .models import HttpSnapshot +from .oob import accepted_oob_events from .templates import extract_template_evidence, template_matches @@ -395,11 +396,12 @@ def analyze_oob_header_probe( probe: HttpSnapshot, save_body: bool, ) -> list[dict[str, Any]]: - protocols = sorted({str(item.get("protocol", "unknown")) for item in events}) + matched = accepted_oob_events(events, token) + protocols = sorted({item["protocol"] for item in matched}) context = { - "oob_confirmed": bool(events), + "oob_confirmed": bool(matched), "protocols": protocols, - "event_count": len(events), + "event_count": len(matched), } if not template_matches("blind_header_oob_confirmed", context): return [] @@ -408,7 +410,12 @@ def analyze_oob_header_probe( "header-injection", "blind_header_oob_confirmed", "high", "high", "Header probe produced an out-of-band callback", template_evidence( - "blind_header_oob_confirmed", context, probe_header=header_name, oob_token=token, oob_confirmed=True + "blind_header_oob_confirmed", + context, + probe_header=header_name, + oob_token=token, + oob_confirmed=True, + oob_callbacks=matched, ), probe, "Confirm the callback is attributable to the tested request and document the backend interaction.", diff --git a/src/headerproof/oob.py b/src/headerproof/oob.py index dcd008f..611cb9b 100644 --- a/src/headerproof/oob.py +++ b/src/headerproof/oob.py @@ -48,6 +48,32 @@ def callback_host(token: str, domain: str) -> str: return f"{token}.{domain.strip('.')}" +OOB_EVENT_PROTOCOLS = frozenset({"http", "dns"}) + + +def accepted_oob_events(events: object, token: str) -> list[dict[str, str]]: + """Keep callbacks that belong to this probe token. + + An accepted event is an object with string ``token`` exactly equal to the + requested probe token and ``protocol`` of ``http`` or ``dns``. Source + addresses and free-form detail are dropped. + """ + if not isinstance(token, str) or not token or not isinstance(events, list): + return [] + accepted: list[dict[str, str]] = [] + for item in events: + if not isinstance(item, dict): + continue + event_token = item.get("token") + protocol = item.get("protocol") + if not isinstance(event_token, str) or event_token != token: + continue + if not isinstance(protocol, str) or protocol not in OOB_EVENT_PROTOCOLS: + continue + accepted.append({"protocol": protocol, "token": event_token}) + return accepted + + def query_events(api_base: str, token: str, timeout: float = 1.0) -> list[dict[str, Any]]: url = f"{api_base.rstrip('/')}/api/events/{parse.quote(token, safe='')}" try: @@ -56,7 +82,7 @@ def query_events(api_base: str, token: str, timeout: float = 1.0) -> list[dict[s except (OSError, error.URLError, json.JSONDecodeError): return [] events = payload.get("events", []) if isinstance(payload, dict) else [] - return [item for item in events if isinstance(item, dict)] if isinstance(events, list) else [] + return accepted_oob_events(events, token) def wait_for_event(api_base: str, token: str, timeout: float = 0.75) -> list[dict[str, Any]]: diff --git a/tests/test_oob.py b/tests/test_oob.py index 4938d1f..cc8726c 100644 --- a/tests/test_oob.py +++ b/tests/test_oob.py @@ -36,8 +36,7 @@ def test_http_callback_and_event_query(tmp_path: Path) -> None: with request.urlopen(f"{base}/c/abc123", timeout=1) as response: assert response.status == 200 events = query_events(base, "abc123") - assert len(events) == 1 - assert events[0]["protocol"] == "http" + assert events == [{"protocol": "http", "token": "abc123"}] assert (tmp_path / "events.jsonl").exists() finally: server.shutdown() @@ -70,7 +69,10 @@ def test_oob_signal_passes_template_gate() -> None: signals = analyze_oob_header_probe( "X-Forwarded-Host", "deadbeef", - [{"protocol": "dns"}, {"protocol": "http"}], + [ + {"protocol": "dns", "token": "deadbeef", "source": "127.0.0.1"}, + {"protocol": "http", "token": "deadbeef", "detail": "ignored"}, + ], snapshot(), False, ) @@ -79,6 +81,40 @@ def test_oob_signal_passes_template_gate() -> None: assert signals[0]["type"] == "blind_header_oob_confirmed" assert signals[0]["assessment"]["technical_gate"] == "passed" assert signals[0]["assessment"]["state"] == "cross_request_confirmed" + assert signals[0]["evidence"]["oob_confirmed"] is True + assert signals[0]["evidence"]["event_count"] == 2 + assert signals[0]["evidence"]["oob_callbacks"] == [ + {"protocol": "dns", "token": "deadbeef"}, + {"protocol": "http", "token": "deadbeef"}, + ] + + +def test_oob_signal_requires_exact_matching_callback_token() -> None: + cases = [ + [{"token": "wrong-token", "protocol": "http"}], + [{"protocol": "http"}], + [{"token": "deadbeef"}], + [{"token": "deadbeef", "protocol": "unrelated"}], + ["not-an-event", None, {"token": "deadbeef", "protocol": "smtp"}], + ] + for events in cases: + assert analyze_oob_header_probe("X-Forwarded-Host", "deadbeef", events, snapshot(), False) == [] + + mixed = analyze_oob_header_probe( + "X-Forwarded-Host", + "deadbeef", + [ + {"token": "wrong-token", "protocol": "dns"}, + {"token": "deadbeef", "protocol": "http", "source": "10.0.0.8"}, + {"token": "deadbeef", "protocol": "ftp"}, + ], + snapshot(), + False, + ) + assert len(mixed) == 1 + assert mixed[0]["evidence"]["oob_callbacks"] == [{"protocol": "http", "token": "deadbeef"}] + assert mixed[0]["evidence"]["protocols"] == ["http"] + assert "source" not in mixed[0]["evidence"]["oob_callbacks"][0] def test_engine_oob_probe_reaches_callback_and_promotes_finding() -> None: @@ -138,3 +174,6 @@ def log_message(self, _format: str, *args: object) -> None: assert findings[0]["assessment"]["technical_gate"] == "passed" assert findings[0]["assessment"]["state"] == "cross_request_confirmed" assert findings[0]["evidence"]["oob_confirmed"] is True + assert findings[0]["evidence"]["oob_callbacks"] == [ + {"protocol": "http", "token": findings[0]["evidence"]["oob_token"]} + ]