Escape public listing search filters#409
Conversation
Greptile SummaryThis PR closes a query-injection gap where a user-supplied search term containing PostgREST's
Confidence Score: 4/5The change is safe to merge; the escaping logic is correct and consistently applied across all seven pages. The core fix is sound: adding No files require special attention, but Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[User submits search form] --> B[URL search param: ?search=...]
B --> C[Page Server Component reads queryParams.search]
C --> D{search present?}
D -- No --> E[Skip filter]
D -- Yes --> F[escapePostgrestSearchValue]
F --> G["Escape: \\ % * _ , ( ) ."]
G --> H["Build .or() filter string\ntitle.ilike.%safe%,description.ilike.%safe%"]
H --> I[Supabase PostgREST query]
I --> J["PostgreSQL ILIKE\n(wildcards neutralised)"]
J --> K[Filtered listing results]
style F fill:#d4edda,stroke:#28a745
style G fill:#d4edda,stroke:#28a745
Reviews (1): Last reviewed commit: "Escape public listing search filters" | Re-trigger Greptile |
| describe("escapePostgrestSearchValue", () => { | ||
| it("escapes LIKE wildcards and PostgREST filter punctuation", () => { | ||
| expect(escapePostgrestSearchValue("100%_match,(v1.2)")).toBe( | ||
| "100\\%\\_match\\,\\(v1\\.2\\)" | ||
| expect(escapePostgrestSearchValue("100%_match*,(v1.2)")).toBe( | ||
| "100\\%\\_match\\*\\,\\(v1\\.2\\)" | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The test suite has no case for an input that contains a backslash. The function escapes
\ first (it's at the front of the character class [\\%*_,().]), so a double-escape regression — e.g. input \% producing \\\% instead of \\% — would go undetected. Adding a single backslash-containing case closes this gap.
| describe("escapePostgrestSearchValue", () => { | |
| it("escapes LIKE wildcards and PostgREST filter punctuation", () => { | |
| expect(escapePostgrestSearchValue("100%_match,(v1.2)")).toBe( | |
| "100\\%\\_match\\,\\(v1\\.2\\)" | |
| expect(escapePostgrestSearchValue("100%_match*,(v1.2)")).toBe( | |
| "100\\%\\_match\\*\\,\\(v1\\.2\\)" | |
| ); | |
| }); | |
| }); | |
| describe("escapePostgrestSearchValue", () => { | |
| it("escapes LIKE wildcards and PostgREST filter punctuation", () => { | |
| expect(escapePostgrestSearchValue("100%_match*,(v1.2)")).toBe( | |
| "100\\%\\_match\\*\\,\\(v1\\.2\\)" | |
| ); | |
| }); | |
| it("escapes backslash to prevent double-escaping", () => { | |
| expect(escapePostgrestSearchValue("50\\%off")).toBe("50\\\\\\%off"); | |
| }); | |
| }); |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| if (queryParams.search) { | ||
| const safeSearch = escapePostgrestSearchValue(queryParams.search); | ||
| query = query.or( | ||
| `title.ilike.%${queryParams.search}%,description.ilike.%${queryParams.search}%` | ||
| `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%` | ||
| ); | ||
| } |
There was a problem hiding this comment.
Missing search-term length cap on six of seven pages
affiliates/page.tsx slices the search value to 200 characters before escaping (with a comment citing issue #57), but directory, for-hire, gigs, mcp, prompts, and skills pages pass queryParams.search directly without any length limit. A very long search string still produces an oversized PostgREST filter string on those routes. Since this PR is touching all of them, it would be a natural place to apply the same .slice(0, 200) guard consistently.
Fixes #408.
Changes:
Validation: