Skip to content

Escape public listing search filters#409

Open
jsdavid278-cyber wants to merge 1 commit into
profullstack:masterfrom
jsdavid278-cyber:codex/public-pages-search-escape
Open

Escape public listing search filters#409
jsdavid278-cyber wants to merge 1 commit into
profullstack:masterfrom
jsdavid278-cyber:codex/public-pages-search-escape

Conversation

@jsdavid278-cyber
Copy link
Copy Markdown
Contributor

Fixes #408.

Changes:

  • escape public listing search terms before composing PostgREST .or(...) filters
  • cover directory, skills, prompts, MCP, affiliate, gigs, and for-hire pages
  • extend the shared helper regression test for PostgREST's * wildcard alias

Validation:

  • vitest run src/lib/security/sanitize.test.ts
  • tsc --noEmit

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 5, 2026

Greptile Summary

This PR closes a query-injection gap where a user-supplied search term containing PostgREST's * wildcard alias (or SQL LIKE wildcards % and _) was interpolated directly into .or() filter strings, allowing unintended broad matches. The fix adds * to the existing escapePostgrestSearchValue helper and applies that helper consistently across all seven public listing pages.

  • sanitize.ts: extends the escape regex from /[\\\\%_,().]/g to /[\\\\%*_,().]/g; the backslash is still at the front of the character class, ensuring it is escaped first and preventing double-escaping.
  • sanitize.test.ts: regression test updated to verify *\\* in the single combined test case.
  • Seven page files (affiliates, directory, for-hire, gigs, mcp, prompts, skills): each now calls escapePostgrestSearchValue before building the PostgREST filter string.

Confidence Score: 4/5

The change is safe to merge; the escaping logic is correct and consistently applied across all seven pages.

The core fix is sound: adding * to the escape set closes the wildcard-alias gap, and the character-class ordering ensures backslashes are escaped first. The two observations — a missing backslash test case and inconsistent search-term length limiting across six pages — are quality gaps rather than present defects introduced by this PR.

No files require special attention, but directory, for-hire, gigs, mcp, prompts, and skills pages lack the 200-character search-term cap that affiliates/page.tsx has.

Important Files Changed

Filename Overview
src/lib/security/sanitize.ts Adds * to the escapePostgrestSearchValue regex; logic is correct and backslash is still escaped first to prevent double-escaping.
src/lib/security/sanitize.test.ts Test updated to include * in the input/expected output; backslash escaping is not covered by any test case.
src/app/directory/page.tsx Escaping applied correctly; lacks the 200-char search length cap present in affiliates/page.tsx (pre-existing gap).
src/app/affiliates/page.tsx Escaping added after the existing 200-char slice; consistently applied to both filter columns.
src/app/mcp/page.tsx Escaping applied to all three filter columns (title, description, tagline); missing length cap.
src/app/skills/page.tsx Escaping applied to all three filter columns; missing length cap (same pattern as mcp, prompts).
src/app/prompts/page.tsx Escaping applied correctly to title, description, and tagline filters; missing length cap.
src/app/gigs/[[...tags]]/page.tsx Escaping applied correctly; missing length cap for search term.
src/app/for-hire/[[...tags]]/page.tsx Escaping applied correctly; missing length cap for search term.

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
Loading

Reviews (1): Last reviewed commit: "Escape public listing search filters" | Re-trigger Greptile

Comment on lines 55 to 61
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\\)"
);
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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!

Comment on lines 56 to 61
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}%`
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Public listing pages interpolate raw search filters

1 participant