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' });