From 51ddd55ba4176bdf3177db98f308e6cee1081af3 Mon Sep 17 00:00:00 2001 From: Sonny May Date: Thu, 10 Sep 2026 18:36:55 +0000 Subject: [PATCH] fix: escape ticket_id before interpolating into PostgREST filters in ai.py _fetch_ticket and _fetch_notes built PostgREST filter strings from raw ticket_id, which reaches them unescaped from the public POST /ai/suggest body. This is the same PostgREST filter injection class fixed in main.py (#18, #19) but the ai module was missed. Apply escape_filter_value() the same way and add regression tests covering both helpers. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Hv295zWJQ6j3qxjCHcgWrX --- backend/ai.py | 9 ++++++--- backend/test_ai.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 backend/test_ai.py diff --git a/backend/ai.py b/backend/ai.py index d16befa..aa72838 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,17 @@ 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..69a34ae --- /dev/null +++ b/backend/test_ai.py @@ -0,0 +1,49 @@ +"""Unit tests for backend/ai.py -- PostgREST filter injection guards. + +Mirrors the id-escaping coverage in test_database.py / test_main.py: ticket +ids that flow into ai.py's Supabase lookups must be escaped the same way +main.py's routes escape them. +""" + +import pytest + +import ai + + +def test_fetch_ticket_escapes_injected_id(monkeypatch): + captured = {} + + def fake_get(table, params=""): + captured["table"] = table + captured["params"] = params + return [{"id": "t1"}] + + monkeypatch.setattr(ai, "db_get", fake_get) + + ai._fetch_ticket("t1&status=eq.Closed") + + assert captured["table"] == "tickets" + assert captured["params"] == "id=eq.t1%26status%3Deq.Closed" + + +def test_fetch_ticket_raises_when_not_found(monkeypatch): + monkeypatch.setattr(ai, "db_get", lambda table, params="": []) + + with pytest.raises(ValueError): + ai._fetch_ticket("missing") + + +def test_fetch_notes_escapes_injected_id(monkeypatch): + captured = {} + + def fake_get(table, params=""): + captured["table"] = table + captured["params"] = params + return [] + + monkeypatch.setattr(ai, "db_get", fake_get) + + ai._fetch_notes("t1&status=eq.Closed") + + assert captured["table"] == "ticket_notes" + assert captured["params"] == "ticket_id=eq.t1%26status%3Deq.Closed&order=created_at.asc"