Skip to content

Commit c0c9938

Browse files
committed
fix(store): SQL injection guards, LIKE escaping, BigInt safety, batch null checks
- Validate SearchConfig identifiers (table/column names) against injection - Guard FTS5 MATCH against empty query when all tokens are operators - Validate migration version before PRAGMA interpolation - Replace Number(row.id) with num() for BigInt consistency + add null check - Remove non-null assertions in batch tag/attachment fetches - Add likeEscape() helper and ESCAPE '\' clause across all 7 stores - Add created_at tie-breaker to epics ORDER BY for stable pagination - Add missing idx_attachments_project index
1 parent 6c187c7 commit c0c9938

12 files changed

Lines changed: 62 additions & 30 deletions

File tree

src/store/sqlite/lib/bigint.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export function now(): bigint {
88
return BigInt(Date.now());
99
}
1010

11+
/** Escape LIKE special characters (%, _) for safe use in SQL LIKE patterns */
12+
export function likeEscape(text: string): string {
13+
return text.replace(/[%_]/g, '\\$&');
14+
}
15+
1116
/** Safely parse JSON with a fallback for corrupted data */
1217
export function safeJson<T>(raw: string, fallback: T): T {
1318
try {

src/store/sqlite/lib/entity-helpers.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ export class EntityHelpers {
3535
// Insert new tags
3636
for (const tag of tags) {
3737
this.db.prepare('INSERT OR IGNORE INTO tags (project_id, name) VALUES (?, ?)').run(this.projectId, tag);
38-
const row = this.db.prepare('SELECT id FROM tags WHERE project_id = ? AND name = ?').get(this.projectId, tag) as { id: bigint };
38+
const row = this.db.prepare('SELECT id FROM tags WHERE project_id = ? AND name = ?').get(this.projectId, tag) as { id: bigint } | undefined;
39+
if (!row) throw new Error(`Failed to resolve tag: ${tag}`);
3940
this.db.prepare(`INSERT INTO edges (project_id, from_graph, from_id, to_graph, to_id, kind) VALUES (?, 'tags', ?, ?, ?, 'tagged')`)
40-
.run(this.projectId, Number(row.id), graph, entityId);
41+
.run(this.projectId, num(row.id), graph, entityId);
4142
}
4243
}
4344

@@ -69,7 +70,8 @@ export class EntityHelpers {
6970

7071
for (const r of rows) {
7172
const id = num(r.entity_id);
72-
result.get(id)!.push(r.name);
73+
const arr = result.get(id);
74+
if (arr) arr.push(r.name);
7375
}
7476
return result;
7577
}
@@ -99,7 +101,8 @@ export class EntityHelpers {
99101

100102
for (const r of rows) {
101103
const id = num(r.entity_id as bigint);
102-
result.get(id)!.push(this.toAttachmentMeta(r));
104+
const arr = result.get(id);
105+
if (arr) arr.push(this.toAttachmentMeta(r));
103106
}
104107
return result;
105108
}

src/store/sqlite/lib/migrate.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export function runMigrations(db: Database.Database, migrations: Migration[]): n
2222
if (m.version > current) {
2323
db.transaction(() => {
2424
db.exec(m.sql);
25+
if (!Number.isInteger(m.version) || m.version < 0) {
26+
throw new Error(`Invalid migration version: ${m.version}`);
27+
}
2528
db.pragma(`user_version = ${m.version}`);
2629
})();
2730
applied++;

src/store/sqlite/lib/search.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,29 @@ import { num } from './bigint';
44

55
const RRF_K = 60;
66

7+
/** Validate SQL identifier (table/column name) — alphanumeric + underscore only */
8+
function assertIdentifier(name: string, label: string): void {
9+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
10+
throw new Error(`Invalid ${label}: ${name}`);
11+
}
12+
}
13+
714
/**
815
* Escape user text for FTS5 MATCH — wrap each token in double quotes.
916
* Preserves FTS5 operators (AND, OR, NOT, NEAR) when used explicitly.
17+
* Returns empty string if no valid tokens remain.
1018
*/
1119
function ftsEscape(text: string): string {
1220
const FTS5_OPS = new Set(['AND', 'OR', 'NOT', 'NEAR']);
13-
return text
21+
const tokens = text
1422
.split(/\s+/)
15-
.filter(t => t.length > 0)
23+
.filter(t => t.length > 0);
24+
const escaped = tokens
1625
.map(t => FTS5_OPS.has(t) ? t : `"${t.replace(/"/g, '""')}"`)
1726
.join(' ');
27+
// If all tokens were FTS5 operators, result is unusable — return empty
28+
if (tokens.length > 0 && tokens.every(t => FTS5_OPS.has(t))) return '';
29+
return escaped;
1830
}
1931

2032
/**
@@ -57,17 +69,25 @@ export function hybridSearch(
5769
let ftsRanked: Array<{ id: number; rn: number }> = [];
5870
let vecRanked: Array<{ id: number; rn: number }> = [];
5971

72+
// Validate config identifiers to prevent SQL injection
73+
assertIdentifier(config.ftsTable, 'ftsTable');
74+
assertIdentifier(config.vecTable, 'vecTable');
75+
assertIdentifier(config.parentTable, 'parentTable');
76+
assertIdentifier(config.parentIdColumn, 'parentIdColumn');
77+
6078
const extraJoin = config.extraJoinCondition ?? '';
6179

6280
// FTS5 keyword search
6381
if (mode !== 'vector' && query.text) {
82+
const escaped = ftsEscape(query.text);
83+
if (!escaped) return [];
6484
const rows = db.prepare(`
6585
SELECT p.${config.parentIdColumn} AS id, ROW_NUMBER() OVER (ORDER BY rank) AS rn
6686
FROM ${config.ftsTable} fts
6787
JOIN ${config.parentTable} p ON p.${config.parentIdColumn} = fts.rowid AND p.project_id = ? ${extraJoin}
6888
WHERE ${config.ftsTable} MATCH ?
6989
LIMIT ?
70-
`).all(projectId, ftsEscape(query.text), topK) as Array<{ id: bigint; rn: bigint }>;
90+
`).all(projectId, escaped, topK) as Array<{ id: bigint; rn: bigint }>;
7191

7292
ftsRanked = rows.map(r => ({ id: num(r.id), rn: num(r.rn) }));
7393
}

src/store/sqlite/migrations/v001.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ CREATE TABLE attachments (
5353
added_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
5454
UNIQUE(project_id, graph, entity_id, filename)
5555
);
56+
CREATE INDEX idx_attachments_project ON attachments(project_id);
5657
5758
-- =============================================
5859
-- Unified edges (same-graph + cross-graph)

src/store/sqlite/stores/code.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
PaginationOptions,
99
} from '../../types';
1010
import { MetaHelper } from '../lib/meta';
11-
import { num } from '../lib/bigint';
11+
import { num, likeEscape } from '../lib/bigint';
1212
import { hybridSearch, SearchConfig } from '../lib/search';
1313
import * as path from 'path';
1414

@@ -187,8 +187,8 @@ export class SqliteCodeStore implements CodeStore {
187187
const params: unknown[] = [this.projectId];
188188

189189
if (filter) {
190-
conditions.push('f.file_id LIKE ?');
191-
params.push(`%${filter}%`);
190+
conditions.push("f.file_id LIKE ? ESCAPE '\\'");
191+
params.push(`%${likeEscape(filter)}%`);
192192
}
193193

194194
const where = conditions.join(' AND ');

src/store/sqlite/stores/docs.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
PaginationOptions,
99
} from '../../types';
1010
import { MetaHelper } from '../lib/meta';
11-
import { num, safeJson } from '../lib/bigint';
11+
import { num, safeJson, likeEscape } from '../lib/bigint';
1212
import { hybridSearch, SearchConfig } from '../lib/search';
1313

1414
const GRAPH = 'docs';
@@ -171,8 +171,8 @@ export class SqliteDocsStore implements DocsStore {
171171
const params: unknown[] = [this.projectId];
172172

173173
if (filter) {
174-
conditions.push('(d.file_id LIKE ? OR d.title LIKE ?)');
175-
const like = `%${filter}%`;
174+
conditions.push("(d.file_id LIKE ? ESCAPE '\\' OR d.title LIKE ? ESCAPE '\\')");
175+
const like = `%${likeEscape(filter)}%`;
176176
params.push(like, like);
177177
}
178178

src/store/sqlite/stores/epics.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
import { VersionConflictError } from '../../types';
1717
import { MetaHelper } from '../lib/meta';
1818
import { EntityHelpers } from '../lib/entity-helpers';
19-
import { num, now } from '../lib/bigint';
19+
import { num, now, likeEscape } from '../lib/bigint';
2020
import { hybridSearch, SearchConfig } from '../lib/search';
2121

2222
const GRAPH = 'epics';
@@ -178,7 +178,7 @@ export class SqliteEpicsStore implements EpicsStore {
178178

179179
if (opts?.status) { conditions.push('e.status = ?'); params.push(opts.status); }
180180
if (opts?.priority) { conditions.push('e.priority = ?'); params.push(opts.priority); }
181-
if (opts?.filter) { conditions.push('(e.title LIKE ? OR e.description LIKE ?)'); const like = `%${opts.filter}%`; params.push(like, like); }
181+
if (opts?.filter) { conditions.push("(e.title LIKE ? ESCAPE '\\' OR e.description LIKE ? ESCAPE '\\')"); const like = `%${likeEscape(opts.filter)}%`; params.push(like, like); }
182182
if (opts?.tag) {
183183
conditions.push(`EXISTS (
184184
SELECT 1 FROM edges ed JOIN tags tg ON tg.id = ed.from_id AND tg.project_id = ed.project_id
@@ -189,7 +189,7 @@ export class SqliteEpicsStore implements EpicsStore {
189189
}
190190

191191
const where = conditions.join(' AND ');
192-
const rows = this.db.prepare(`SELECT e.* FROM epics e WHERE ${where} ORDER BY e."order" LIMIT ? OFFSET ?`)
192+
const rows = this.db.prepare(`SELECT e.* FROM epics e WHERE ${where} ORDER BY e."order", e.created_at LIMIT ? OFFSET ?`)
193193
.all(...params, limit, offset) as Array<Record<string, unknown>>;
194194
const total = num((this.db.prepare(`SELECT COUNT(*) AS c FROM epics e WHERE ${where}`).get(...params) as { c: bigint }).c);
195195

src/store/sqlite/stores/files.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
SearchResult,
99
} from '../../types';
1010
import { MetaHelper } from '../lib/meta';
11-
import { num } from '../lib/bigint';
11+
import { num, likeEscape } from '../lib/bigint';
1212

1313
const GRAPH = 'files';
1414

@@ -187,8 +187,8 @@ export class SqliteFilesStore implements FilesStore {
187187
}
188188

189189
if (opts?.filter) {
190-
conditions.push('file_path LIKE ?');
191-
params.push(`%${opts.filter}%`);
190+
conditions.push("file_path LIKE ? ESCAPE '\\'");
191+
params.push(`%${likeEscape(opts.filter)}%`);
192192
}
193193

194194
const where = conditions.join(' AND ');
@@ -215,9 +215,9 @@ export class SqliteFilesStore implements FilesStore {
215215
if (mode === 'keyword' && query.text) {
216216
// Fallback: LIKE-based search on file_path
217217
const rows = this.db.prepare(`
218-
SELECT id FROM files WHERE project_id = ? AND file_path LIKE ? AND kind = 'file'
218+
SELECT id FROM files WHERE project_id = ? AND file_path LIKE ? ESCAPE '\\' AND kind = 'file'
219219
ORDER BY file_path ASC LIMIT ?
220-
`).all(this.projectId, `%${query.text}%`, maxResults) as Array<{ id: bigint }>;
220+
`).all(this.projectId, `%${likeEscape(query.text)}%`, maxResults) as Array<{ id: bigint }>;
221221

222222
return rows
223223
.map((r, i) => ({ id: num(r.id), score: 1 / (60 + i + 1) }))
@@ -248,9 +248,9 @@ export class SqliteFilesStore implements FilesStore {
248248

249249
if (query.text) {
250250
const rows = this.db.prepare(`
251-
SELECT id FROM files WHERE project_id = ? AND file_path LIKE ? AND kind = 'file'
251+
SELECT id FROM files WHERE project_id = ? AND file_path LIKE ? ESCAPE '\\' AND kind = 'file'
252252
ORDER BY file_path ASC LIMIT ?
253-
`).all(this.projectId, `%${query.text}%`, query.topK ?? 50) as Array<{ id: bigint }>;
253+
`).all(this.projectId, `%${likeEscape(query.text)}%`, query.topK ?? 50) as Array<{ id: bigint }>;
254254
rows.forEach((r, i) => likeResults.push({ id: num(r.id), rn: i + 1 }));
255255
}
256256

src/store/sqlite/stores/knowledge.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {
1414
import { VersionConflictError } from '../../types';
1515
import { MetaHelper } from '../lib/meta';
1616
import { EntityHelpers } from '../lib/entity-helpers';
17-
import { num, now } from '../lib/bigint';
17+
import { num, now, likeEscape } from '../lib/bigint';
1818
import { hybridSearch, SearchConfig } from '../lib/search';
1919

2020
const GRAPH = 'knowledge';
@@ -141,8 +141,8 @@ export class SqliteKnowledgeStore implements KnowledgeStore {
141141
const params: unknown[] = [this.projectId];
142142

143143
if (filter) {
144-
conditions.push('(k.title LIKE ? OR k.content LIKE ?)');
145-
const like = `%${filter}%`;
144+
conditions.push("(k.title LIKE ? ESCAPE '\\' OR k.content LIKE ? ESCAPE '\\')");
145+
const like = `%${likeEscape(filter)}%`;
146146
params.push(like, like);
147147
}
148148

0 commit comments

Comments
 (0)