From 8fba6be213c69ce9f98026c538a53417d6368433 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:37:21 +0800 Subject: [PATCH] fix: use json_each() to prevent SQL injection in getItemsByTag (#243) Replace LIKE pattern matching with SQLite json_each() for proper JSON array searching. The previous approach interpolated the tag parameter into a LIKE pattern, allowing SQL wildcards (%, _) and double-quotes to alter query semantics and leak data. Co-Authored-By: Claude Opus 4.6 --- server/db.js | 10 +++++----- test/db.test.js | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/server/db.js b/server/db.js index 7acc983..aba5682 100644 --- a/server/db.js +++ b/server/db.js @@ -624,15 +624,15 @@ function initDb(dataDir) { } function getItemsByTag({ app_id, tag }) { - // SQLite JSON: tags is stored as '["bug","ui"]', search with LIKE + // Use json_each() for proper JSON array searching (avoids LIKE injection) if (app_id) { return db.prepare( - `SELECT * FROM items WHERE app_id = ? AND tags LIKE ? ORDER BY created_at DESC` - ).all(app_id, `%"${tag}"%`); + `SELECT i.* FROM items i, json_each(i.tags) t WHERE i.app_id = ? AND t.value = ? ORDER BY i.created_at DESC` + ).all(app_id, tag); } return db.prepare( - `SELECT * FROM items WHERE tags LIKE ? ORDER BY created_at DESC` - ).all(`%"${tag}"%`); + `SELECT i.* FROM items i, json_each(i.tags) t WHERE t.value = ? ORDER BY i.created_at DESC` + ).all(tag); } function getDistinctUrls(app_id = 'default') { diff --git a/test/db.test.js b/test/db.test.js index cb633f4..ad03977 100644 --- a/test/db.test.js +++ b/test/db.test.js @@ -433,6 +433,25 @@ describe('DB — V2 queries', () => { assert.equal(items.length, 1); }); + it('getItemsByTag is not vulnerable to LIKE wildcards', () => { + dbApi.createItem({ + doc: 'd', created_by: 'A', + tags: ['bug', 'ui'], + }); + dbApi.createItem({ + doc: 'd', created_by: 'B', + tags: ['feature'], + }); + + // '%' should not match all items (was vulnerable with LIKE pattern) + const wildcard = dbApi.getItemsByTag({ tag: '%' }); + assert.equal(wildcard.length, 0); + + // Partial match should not work — must be exact + const partial = dbApi.getItemsByTag({ tag: 'bu' }); + assert.equal(partial.length, 0); + }); + it('getDistinctUrls returns unique source_urls with counts', () => { dbApi.createItem({ doc: 'd', created_by: 'A', source_url: 'https://a.com' }); dbApi.createItem({ doc: 'd', created_by: 'B', source_url: 'https://a.com' });