fix: close two CSRF origin-check security gaps

- Extend origin check to all /api/ requests (not just state-changing methods),
  closing the GET/HEAD/OPTIONS bypass that allowed cross-origin reads
- origin_allowed() now returns False when both Origin and Referer headers
  are absent, preventing script-initiated requests from bypassing the check
- Update AGENTS.md and README.md to document the changes
This commit is contained in:
gramps
2026-06-27 15:20:02 -07:00
parent cc1efa7a21
commit 5986c4ad86
10 changed files with 17 additions and 14 deletions

View File

@@ -22,7 +22,7 @@ def test_guest_read_only_admin_write_blocked(tmp_path: Path):
guest = client.post("/api/auth/guest", headers={"Origin": "http://testserver"})
assert guest.status_code == 200
sid = guest.json()["session_id"]
headers = {"X-Session-ID": sid}
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
read_resp = client.get("/api/memories", headers=headers)
assert read_resp.status_code == 200
@@ -76,5 +76,5 @@ def test_logout_revokes_session(tmp_path: Path):
logout = client.post("/api/auth/logout", headers=headers)
assert logout.status_code == 200
after = client.get("/api/memories", headers={"X-Session-ID": sid})
after = client.get("/api/memories", headers={"X-Session-ID": sid, "Origin": "http://testserver"})
assert after.status_code == 401

View File

@@ -172,7 +172,7 @@ def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
remember_events = parse_sse_payloads(remember_resp.text)
assert any("Remembered" in p.get("token", "") for p in remember_events)
memories_after_add = client.get("/api/memories", headers={"X-Session-ID": sid})
memories_after_add = client.get("/api/memories", headers={"X-Session-ID": sid, "Origin": "http://testserver"})
assert memories_after_add.status_code == 200
assert memories_after_add.json().get("count", 0) >= 1
@@ -188,6 +188,6 @@ def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
forget_events = parse_sse_payloads(forget_resp.text)
assert any("Forgot" in p.get("token", "") for p in forget_events)
memories_after_forget = client.get("/api/memories", headers={"X-Session-ID": sid})
memories_after_forget = client.get("/api/memories", headers={"X-Session-ID": sid, "Origin": "http://testserver"})
assert memories_after_forget.status_code == 200
assert memories_after_forget.json().get("count", 0) == 0

View File

@@ -28,7 +28,7 @@ def test_unhandled_api_exception_returns_friendly_error_with_incident_key(
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
"session_id"
]
headers = {"X-Session-ID": sid}
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
def boom(_topic=None):
raise RuntimeError("super secret db internals")

View File

@@ -40,5 +40,5 @@ def test_middleware_blocks_disallowed_ip(tmp_path: Path, monkeypatch):
def test_middleware_allows_local_ip(tmp_path: Path, monkeypatch):
monkeypatch.setattr(app, "get_client_ip", lambda _req: "192.168.50.109")
with make_client(tmp_path) as client:
resp = client.post("/api/auth/guest")
resp = client.post("/api/auth/guest", headers={"Origin": "http://testserver"})
assert resp.status_code == 200

View File

@@ -28,8 +28,8 @@ def test_stats_rate_limit_hits_429(tmp_path: Path):
app.RATE_WINDOW_SECONDS = 60
try:
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest").json()["session_id"]
headers = {"X-Session-ID": sid}
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
r1 = client.get("/api/stats", headers=headers)
r2 = client.get("/api/stats", headers=headers)
@@ -56,7 +56,7 @@ def test_large_login_payload_rejected_413(tmp_path: Path):
def test_chat_message_length_rejected_413(tmp_path: Path):
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest").json()["session_id"]
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
message = "x" * (config.MAX_CHAT_MESSAGE_CHARS + 1)
resp = client.post(
@@ -69,7 +69,7 @@ def test_chat_message_length_rejected_413(tmp_path: Path):
def test_search_query_length_rejected_413(tmp_path: Path):
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest").json()["session_id"]
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
query = "q" * (config.MAX_SEARCH_QUERY_CHARS + 1)
resp = client.post(

View File

@@ -25,7 +25,7 @@ def test_guest_can_list_skills(tmp_path: Path):
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
"session_id"
]
resp = client.get("/api/skills", headers={"X-Session-ID": sid})
resp = client.get("/api/skills", headers={"X-Session-ID": sid, "Origin": "http://testserver"})
assert resp.status_code == 200
payload = resp.json()
assert payload["count"] >= 1
@@ -50,7 +50,7 @@ def test_admin_can_toggle_skill_enabled_state(tmp_path: Path):
assert disable.status_code == 200
assert disable.json()["skill"]["enabled"] is False
active = client.get("/api/skills/active", headers={"X-Session-ID": sid})
active = client.get("/api/skills/active", headers={"X-Session-ID": sid, "Origin": "http://testserver"})
assert active.status_code == 200
assert all(skill["key"] != "search.web" for skill in active.json()["skills"])