From 2da75f76cd7435d7980f1bbe6b0d89f1640949a4 Mon Sep 17 00:00:00 2001 From: Sonny May Date: Sun, 13 Sep 2026 14:05:52 +0000 Subject: [PATCH] fix: escape ticket_id before interpolating into PostgREST filters in ai.py _fetch_ticket and _fetch_notes built PostgREST filter strings by interpolating the user-supplied ticket_id from POST /ai/suggest directly, without the escape_filter_value() guard that main.py's db_get call sites already use (see #18, #19). A crafted ticket_id could inject extra PostgREST query parameters/filters. Route it through escape_filter_value like the rest of the codebase, and add a regression test. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Xed3eAz7csBLFwejeoRRfX --- backend/ai.py | 8 +++++--- backend/test_ai.py | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 backend/test_ai.py diff --git a/backend/ai.py b/backend/ai.py index d16befa..5bfff01 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -8,7 +8,7 @@ from anthropic import Anthropic from dotenv import load_dotenv -from database import db_get +from database import db_get, escape_filter_value load_dotenv() @@ -28,14 +28,16 @@ def _get_client() -> Anthropic: def _fetch_ticket(ticket_id: str) -> dict[str, Any]: - result = db_get("tickets", f"id=eq.{ticket_id}") + result = db_get("tickets", f"id=eq.{escape_filter_value(ticket_id)}") if not isinstance(result, list) or not result: raise ValueError(f"Ticket {ticket_id} not found") return result[0] def _fetch_notes(ticket_id: str) -> list[dict[str, Any]]: - notes = db_get("ticket_notes", f"ticket_id=eq.{ticket_id}&order=created_at.asc") + notes = db_get( + "ticket_notes", f"ticket_id=eq.{escape_filter_value(ticket_id)}&order=created_at.asc" + ) return notes if isinstance(notes, list) else [] diff --git a/backend/test_ai.py b/backend/test_ai.py new file mode 100644 index 0000000..4b77744 --- /dev/null +++ b/backend/test_ai.py @@ -0,0 +1,27 @@ +"""Unit tests for ai.py -- PostgREST filter injection guard on ticket_id.""" + +import pytest + +import ai + + +@pytest.mark.parametrize( + "fetch", + [ai._fetch_ticket, ai._fetch_notes], + ids=["_fetch_ticket", "_fetch_notes"], +) +def test_fetch_helpers_escape_injected_ticket_id(monkeypatch, fetch): + captured = {} + + def fake_db_get(table, params=""): + captured["params"] = params + return [{"id": "t1"}] + + monkeypatch.setattr(ai, "db_get", fake_db_get) + + malicious_id = "t1&status=eq.Closed" + fetch(malicious_id) + + params = captured["params"] + assert "&status=eq.Closed" not in params + assert "status=eq.Closed" not in params