fix(search): literalize FTS5 MATCH tokens to prevent query semantics injection#313
fix(search): literalize FTS5 MATCH tokens to prevent query semantics injection#313Arreboi06 wants to merge 3 commits into
Conversation
Add defense-in-depth sanitization to `buildFtsQuery()` in
`src/core/store/sqlite.ts` so user input cannot alter FTS5 MATCH
semantics.
Pre-tokenization layer (sanitizeFtsInput)
- NFKC normalize input so full-width Unicode variants (e.g. full-width
"AND") collapse to their canonical ASCII form before regex matching.
- Word-boundary strip FTS5 reserved operators AND / OR / NOT / NEAR
(case-insensitive). Word boundaries guarantee that substrings such
as ANDROID, SCANNER, ORACLE, NEARBY survive untouched.
- Strip FTS5 syntax characters: ' " * ( ).
- Strip column-filter abuse including the negation prefix: content:,
message:, session:, actor:, topic:, role:, tag:, user:, host:, file:.
Per-token layer (sanitizeFtsToken)
- NFKC normalize each emitted token.
- Keep only Unicode letters, digits, and underscore ([\p{L}\p{N}_]).
Tokens that lose all such characters are dropped.
- Re-check against FTS5_RESERVED_OPS so jieba-produced tokens (a bare
"OR", for example) cannot bypass the pre-tokenization filter.
- Wrap surviving tokens as FTS5 phrase strings with internal double-
quote characters doubled per FTS5 escape rules.
Interface change
- buildFtsQuery now accepts null / undefined and returns null for empty
input. Downstream searchL1Fts / searchL0Fts already safely no-op
when the MATCH argument is missing, so no caller changes are needed.
Tests
- 30 unit tests in src/core/store/sqlite.test.ts covering every FTS5
reserved word (case + full-width variants), every syntax character,
column-filter abuse including negation, word-boundary-safe substrings
(ANDROID / ORACLE / NEARBY / SCANNER), empty / null / undefined input
handling, jieba injection path simulation, and chained operator
syntax.
- 14 integration tests in src/core/store/sqlite.recall.test.ts using a
real node:sqlite in-memory FTS5 virtual table:
- happy-path MATCH execution
- all malicious inputs execute without throwing
- 6-query recall parity suite (sanitized result set is a SUPERSET of
legacy result set on benign queries)
- 200-query fuzz against randomized adversarial inputs
- Full test suite passes (111 / 111, 6 test files).
Build
- tsdown build for the plugin (dist/index.mjs) succeeds.
- All auxiliary scripts (migrate-sqlite-to-tcvdb, export-tencent-vdb,
read-local-memory) build clean.
Closes TencentCloud#160
Signed-off-by: Arreboi06 <3562419275@qq.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
4f9c5f0 to
b4e3d56
Compare
|
Hi @Arreboi06 👋, |
|
I pushed a follow-up refinement that strengthens the original fix. The first version sanitized known FTS5 operators. The new version makes This means inputs such as Verification: |
|
Following up on the triage note that #178 is the canonical narrow fix for #160: I pushed What is now distinct from #178:
Fresh local verification on the pushed head:
So I agree #178 remains the canonical narrow fix. This PR is now intended as the stricter literal-token variant with additional behavior and coverage, if the maintainers prefer that shape for #160. |







Summary
Fixes #160 by changing
buildFtsQuery()from an operator-sanitizing helper into a literal-token query builder.The important security boundary is: user input is never treated as FTS5 syntax. User text is normalized into plain literal tokens, every emitted token is phrase-quoted, and the only MATCH operators inserted into the expression are generated by our code.
Relationship to #178
I saw the triage note that #178 is the locally verified canonical narrow fix for #160. This PR is now intentionally positioned as the stricter literal-token variant, not just another denylist patch.
The behavior beyond #178 is that reserved-looking words typed by the user are preserved as searchable text instead of being deleted:
That means the fix prevents user-controlled FTS5 query structure while avoiding recall loss for documents that actually contain words such as
AND,OR,NOT, orNEAR.Core design decision
buildFtsQuery()owns the complete MATCH expression structure.Inputs such as boolean operators,
NEAR/5, column filters, quotes, wildcards, and groups can only become searchable text tokens. They cannot become FTS5 query control flow.Why this is stronger than a denylist-only fix
A denylist-only fix removes known dangerous words such as
AND,OR,NOT, andNEAR. That is a valid narrow fix, but it has two trade-offs:This PR avoids both: syntax-looking input is reduced to literal tokens, and every token is phrase-quoted before it reaches FTS5.
Security and recall behavior
alpha" OR "beta"alpha" OR "OR" OR "beta"ORis a quoted literal term, not user-controlled structure.alpha AND NOT beta"alpha" OR "AND" OR "NOT" OR "beta"(alpha) OR (beta)"alpha" OR "OR" OR "beta"content:foo"content" OR "foo"alpha*"alpha"AND"AND"Implementation highlights
sanitizeFtsInput()normalizes raw input and extracts only Unicode literal tokens.sanitizeFtsToken()re-sanitizes every tokenizer output so punctuation-bearing jieba/native/stub tokens cannot reintroduce MATCH syntax.buildFtsQuery()contributes all MATCH structure itself; user text contributes tokens only.Test coverage
The tests cover generated query strings and real FTS5 execution:
AND,OR,NOT,NEAR.content:foo.node:sqliteFTS5 fixture tests proving generated MATCH expressions execute successfully.Verification
Latest pushed head:
Full test suite:
Build:
tsdownprints a Node.js v22.17.0 deprecation warning in this local environment, but the build exits successfully.Files worth reviewing first
src/core/store/sqlite.ts- literal-token query builder implementation.src/core/store/sqlite.test.ts- unit-level security, tokenizer re-sanitization, and recall semantics.src/core/store/sqlite.recall.test.ts- real FTS5 execution, reserved-word literal recall, recall parity, and fuzz coverage.CHANGELOG.md- documents the refined solution and its difference from fix(search): sanitize fts query operators #178.