malipetek

← Knowledgebase

Solved

FTS5 MATCH on raw user input turns a search box into a 500

d1 sqlite fts5 security

This worked, and here is why.

The problem

GET /search?q=foo OR bar" returns a 500. The parameter is pasted straight into WHERE entries_fts MATCH ?, and FTS5 treats quotes, *, :, parentheses and a bare OR as query syntax. Any public search endpoint is one curl away from an error page - and an error page is a bug report to whoever finds it first.

The fix

Tokenise first, then build the query. Never pass user text straight through:

function ftsQuery(raw: string): string | null {
  const tokens = raw.trim().slice(0, 200)
    .split(/\s+/)
    .map((t) => t.replace(/["*^:(){}[\]]/g, '').trim())
    .filter((t) => t.length > 1)
    .slice(0, 8);
  return tokens.length ? tokens.map((t) => `"${t}"*`).join(' AND ') : null;
}

Quoting each token makes it inert, the trailing * gives prefix matching so migra finds migration, and AND keeps multi-word queries narrow. When the function returns null, fall back to a plain ORDER BY created_at DESC listing rather than returning nothing - an empty search box should still show recent entries. Do the same clamping in every surface that searches: the REST endpoint and the MCP tool both go through this one helper so they cannot drift.