From 9b3538f1a674d4374c832f83dea4bb8fa355541d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 6 Jul 2026 11:25:59 -0400 Subject: [PATCH] fix(streaming): reject hostile session SSE cursors (#4812) --- api/run_journal.py | 14 +++++++++- tests/test_issue4812_session_sse_stream.py | 32 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/api/run_journal.py b/api/run_journal.py index 6ecce250..621834c8 100644 --- a/api/run_journal.py +++ b/api/run_journal.py @@ -337,6 +337,17 @@ def read_session_run_events( sid = _validate_id(session_id, "session_id") cursor_run_id, cursor_seq = _parse_run_journal_event_id(after_event_id) raw_cursor = str(after_event_id or "").strip() + if raw_cursor and cursor_run_id is not None: + try: + cursor_run_id = _validate_id(cursor_run_id, "run_id") + except ValueError: + cursor_seq = None + if raw_cursor: + try: + if int(raw_cursor.rsplit(":", 1)[-1]) < 0: + cursor_seq = None + except (TypeError, ValueError): + pass if raw_cursor and (cursor_run_id is None or cursor_seq is None): return { "session_id": sid, @@ -360,7 +371,8 @@ def read_session_run_events( if cursor_entry is None: summary = find_run_summary(cursor_run_id or "", session_dir=session_dir) if cursor_run_id else None status = "cursor_run_missing" - if summary and str(summary.get("session_id") or "") != sid: + summary_sid = str(summary.get("session_id") or "").strip() if summary else "" + if summary_sid and summary_sid != sid: status = "cursor_session_mismatch" return { "session_id": sid, diff --git a/tests/test_issue4812_session_sse_stream.py b/tests/test_issue4812_session_sse_stream.py index ff452afe..9fce97ac 100644 --- a/tests/test_issue4812_session_sse_stream.py +++ b/tests/test_issue4812_session_sse_stream.py @@ -163,6 +163,38 @@ def test_session_journal_distinguishes_missing_and_foreign_cursor(tmp_path): assert foreign["status"] == "cursor_session_mismatch" +def test_session_journal_rejects_hostile_cursor_without_replay(tmp_path, monkeypatch): + append_run_event( + "session_1", + "run_a", + "token", + {"text": "a1"}, + session_dir=tmp_path, + seq=1, + created_at=100.0, + ) + monkeypatch.setattr( + "api.run_journal.find_run_summary", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("invalid cursor should not scan summaries")), + ) + + malformed = read_session_run_events("session_1", after_event_id="bad/run:1", session_dir=tmp_path) + negative = read_session_run_events("session_1", after_event_id="run_a:-5", session_dir=tmp_path) + + assert malformed["status"] == "cursor_invalid" + assert malformed["events"] == [] + assert negative["status"] == "cursor_invalid" + assert negative["events"] == [] + + +def test_session_journal_blank_summary_session_stays_missing(tmp_path, monkeypatch): + monkeypatch.setattr("api.run_journal.find_run_summary", lambda *_args, **_kwargs: {"session_id": ""}) + + replay = read_session_run_events("session_1", after_event_id="run_missing:1", session_dir=tmp_path) + + assert replay["status"] == "cursor_run_missing" + + def test_session_route_prefers_last_event_id_then_query_fallback(monkeypatch): import api.routes as routes