Skip to content

Commit b6915ba

Browse files
fix: handle exclusion failures in session load try block
1 parent f333e93 commit b6915ba

3 files changed

Lines changed: 68 additions & 12 deletions

File tree

api/_session_handlers.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ def resolve_loaded_session(
5959

6060
try:
6161
session = get_cached_session(filepath)
62+
rules = current_app.config.get("EXCLUSION_RULES") or []
63+
if is_session_excluded(rules, session, project_name):
64+
return error_response(
65+
ErrorCode.SESSION_NOT_FOUND,
66+
"Session not found",
67+
404,
68+
)
6269
except SESSION_LOAD_ERRORS:
6370
current_app.logger.exception(parse_log_action, session_id)
6471
return error_response(
@@ -67,14 +74,6 @@ def resolve_loaded_session(
6774
500,
6875
)
6976

70-
rules = current_app.config.get("EXCLUSION_RULES") or []
71-
if is_session_excluded(rules, session, project_name):
72-
return error_response(
73-
ErrorCode.SESSION_NOT_FOUND,
74-
"Session not found",
75-
404,
76-
)
77-
7877
return LoadedSession(session=session, filepath=filepath)
7978

8079

tests/test_error_propagation.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class names from a defensive blocklist.
3232

3333
from flask import Flask
3434

35+
from api.export_api import export_bp
3536
from api.projects import projects_bp
3637
from api.search import _IndexSearchOutcome
3738
from api.sessions import sessions_bp
@@ -94,6 +95,13 @@ def _write_session(tmp_path, project: str, session_id: str, content: str):
9495
return p
9596

9697

98+
def _patch_exclusion_raises(monkeypatch):
99+
def _boom(*_args, **_kwargs):
100+
raise ValueError("internal_exclusion_secret")
101+
102+
monkeypatch.setattr("api._session_handlers.is_session_excluded", _boom)
103+
104+
97105
# ---------------------------------------------------------------------------
98106
# /api/sessions/<project>/<id>
99107
# ---------------------------------------------------------------------------
@@ -122,6 +130,18 @@ def _boom(*args, **kwargs):
122130
assert "internal_secret_field_id" not in json.dumps(body)
123131
_assert_no_class_name_leak(json.dumps(body))
124132

133+
def test_500_when_exclusion_raises_does_not_leak(self, tmp_path, client, monkeypatch):
134+
_write_session(tmp_path, "proj", "abc", '{"type":"user","message":{}}')
135+
_patch_exclusion_raises(monkeypatch)
136+
137+
resp = client.get("/api/sessions/proj/abc")
138+
assert resp.status_code == 500
139+
body = resp.get_json()
140+
assert body.get("error") == "Failed to parse session"
141+
assert body.get("code") == "PARSE_ERROR"
142+
assert "internal_exclusion_secret" not in json.dumps(body)
143+
_assert_no_class_name_leak(json.dumps(body))
144+
125145
def test_404_on_missing_file_keeps_session_id_safe(self, tmp_path, client):
126146
# Session ID is part of the URL so it appears in the 404 message —
127147
# that's fine; what we're guarding is exception-class leakage, which
@@ -163,6 +183,47 @@ def _boom(*args, **kwargs):
163183
assert "/private/path" not in json.dumps(body)
164184
_assert_no_class_name_leak(json.dumps(body))
165185

186+
def test_500_when_exclusion_raises_does_not_leak(self, tmp_path, client, monkeypatch):
187+
_write_session(tmp_path, "proj", "abc", '{"type":"user","message":{}}')
188+
_patch_exclusion_raises(monkeypatch)
189+
190+
resp = client.get("/api/sessions/proj/abc/stats")
191+
assert resp.status_code == 500
192+
body = resp.get_json()
193+
assert body.get("error") == "Failed to parse session"
194+
assert body.get("code") == "PARSE_ERROR"
195+
assert "internal_exclusion_secret" not in json.dumps(body)
196+
_assert_no_class_name_leak(json.dumps(body))
197+
198+
199+
# ---------------------------------------------------------------------------
200+
# /api/export/session
201+
# ---------------------------------------------------------------------------
202+
203+
204+
class TestExportSessionErrorBody:
205+
@pytest.fixture
206+
def export_client(self, tmp_path):
207+
app = Flask(__name__)
208+
app.config["TESTING"] = True
209+
app.config["CLAUDE_PROJECTS_DIR"] = str(tmp_path)
210+
app.register_blueprint(export_bp)
211+
return app.test_client()
212+
213+
def test_500_when_exclusion_raises_does_not_leak(
214+
self, tmp_path, export_client, monkeypatch
215+
):
216+
_write_session(tmp_path, "proj", "abc", '{"type":"user","message":{}}')
217+
_patch_exclusion_raises(monkeypatch)
218+
219+
resp = export_client.get("/api/export/session/proj/abc")
220+
assert resp.status_code == 500
221+
body = resp.get_json()
222+
assert body.get("error") == "Failed to parse session"
223+
assert body.get("code") == "PARSE_ERROR"
224+
assert "internal_exclusion_secret" not in json.dumps(body)
225+
_assert_no_class_name_leak(json.dumps(body))
226+
166227

167228
# ---------------------------------------------------------------------------
168229
# /api/projects (per-session card)

utils/session_errors.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
"""Exception types treated as session load/parse failures."""
22

3-
import json
4-
53
SESSION_LOAD_ERRORS: tuple[type[BaseException], ...] = (
6-
json.JSONDecodeError,
74
KeyError,
85
ValueError,
96
OSError,
10-
FileNotFoundError,
117
)

0 commit comments

Comments
 (0)