Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/headerproof/detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 []
Expand All @@ -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.",
Expand Down
28 changes: 27 additions & 1 deletion src/headerproof/oob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]]:
Expand Down
45 changes: 42 additions & 3 deletions tests/test_oob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down Expand Up @@ -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"]}
]
Loading