diff --git a/src/lib/timeline-db.ts b/src/lib/timeline-db.ts index 49b4f78..9e5a932 100644 --- a/src/lib/timeline-db.ts +++ b/src/lib/timeline-db.ts @@ -289,13 +289,43 @@ export async function insertEvents(events: TimelineEvent[], projectDir?: string) } } -function buildWhereFilter(opts: SearchOptions): string | undefined { +/** Escape single quotes for safe use in SQL string literals */ +export function escapeSQL(value: string): string { + return value.replace(/'/g, "''"); +} + +/** Validate that a value matches expected EVENT_TYPES to prevent injection */ +export function isValidEventType(value: string): value is EventType { + return (EVENT_TYPES as readonly string[]).includes(value); +} + +/** Validate ISO-8601 timestamp format */ +export function isValidTimestamp(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(value); +} + +export function buildWhereFilter(opts: SearchOptions): string | undefined { const clauses: string[] = []; - if (opts.project) clauses.push(`project = '${opts.project}'`); - if (opts.branch) clauses.push(`branch = '${opts.branch}'`); - if (opts.type) clauses.push(`type = '${opts.type}'`); - if (opts.since) clauses.push(`timestamp >= '${opts.since}'`); - if (opts.until) clauses.push(`timestamp <= '${opts.until}'`); + if (opts.project) clauses.push(`project = '${escapeSQL(opts.project)}'`); + if (opts.branch) clauses.push(`branch = '${escapeSQL(opts.branch)}'`); + if (opts.type) { + if (!isValidEventType(opts.type)) { + throw new Error(`Invalid event type: ${opts.type}`); + } + clauses.push(`type = '${opts.type}'`); + } + if (opts.since) { + if (!isValidTimestamp(opts.since)) { + throw new Error(`Invalid timestamp for 'since': ${opts.since}`); + } + clauses.push(`timestamp >= '${opts.since}'`); + } + if (opts.until) { + if (!isValidTimestamp(opts.until)) { + throw new Error(`Invalid timestamp for 'until': ${opts.until}`); + } + clauses.push(`timestamp <= '${opts.until}'`); + } return clauses.length > 0 ? clauses.join(" AND ") : undefined; } @@ -358,7 +388,7 @@ export async function searchExact( opts: SearchOptions = {}, ): Promise { const limit = opts.limit || 50; - const likeClauses = [`content LIKE '%${query.replace(/'/g, "''")}%'`]; + const likeClauses = [`content LIKE '%${escapeSQL(query)}%'`]; const where = buildWhereFilter(opts); const fullWhere = where ? `${likeClauses[0]} AND ${where}` : likeClauses[0]; diff --git a/tests/lib/timeline-db.test.ts b/tests/lib/timeline-db.test.ts new file mode 100644 index 0000000..4837b9a --- /dev/null +++ b/tests/lib/timeline-db.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { + escapeSQL, + isValidEventType, + isValidTimestamp, + buildWhereFilter, + EVENT_TYPES, +} from "../../src/lib/timeline-db.js"; + +describe("escapeSQL", () => { + it("escapes single quotes", () => { + expect(escapeSQL("it's")).toBe("it''s"); + }); + + it("escapes multiple single quotes", () => { + expect(escapeSQL("it's a 'test'")).toBe("it''s a ''test''"); + }); + + it("passes through safe strings", () => { + expect(escapeSQL("hello world")).toBe("hello world"); + }); + + it("handles empty string", () => { + expect(escapeSQL("")).toBe(""); + }); +}); + +describe("isValidEventType", () => { + it("accepts all valid event types", () => { + for (const t of EVENT_TYPES) { + expect(isValidEventType(t)).toBe(true); + } + }); + + it("rejects invalid types", () => { + expect(isValidEventType("invalid")).toBe(false); + expect(isValidEventType("' OR 1=1 --")).toBe(false); + expect(isValidEventType("")).toBe(false); + }); +}); + +describe("isValidTimestamp", () => { + it("accepts valid ISO timestamps", () => { + expect(isValidTimestamp("2024-01-15")).toBe(true); + expect(isValidTimestamp("2024-01-15T10:30:00Z")).toBe(true); + expect(isValidTimestamp("2024-01-15T10:30:00.123Z")).toBe(true); + expect(isValidTimestamp("2024-01-15T10:30:00+05:30")).toBe(true); + expect(isValidTimestamp("2024-01-15T10:30:00-08:00")).toBe(true); + }); + + it("rejects invalid timestamps", () => { + expect(isValidTimestamp("not-a-date")).toBe(false); + expect(isValidTimestamp("' OR 1=1 --")).toBe(false); + expect(isValidTimestamp("2024/01/15")).toBe(false); + expect(isValidTimestamp("")).toBe(false); + }); +}); + +describe("buildWhereFilter", () => { + it("returns undefined for empty options", () => { + expect(buildWhereFilter({})).toBeUndefined(); + }); + + it("builds single project filter", () => { + expect(buildWhereFilter({ project: "/my/project" })).toBe( + "project = '/my/project'" + ); + }); + + it("escapes quotes in project path", () => { + expect(buildWhereFilter({ project: "it's/a/path" })).toBe( + "project = 'it''s/a/path'" + ); + }); + + it("escapes quotes in branch name", () => { + expect(buildWhereFilter({ branch: "fix/it's-broken" })).toBe( + "branch = 'fix/it''s-broken'" + ); + }); + + it("combines multiple filters with AND", () => { + const result = buildWhereFilter({ + project: "/proj", + branch: "main", + type: "prompt", + }); + expect(result).toBe( + "project = '/proj' AND branch = 'main' AND type = 'prompt'" + ); + }); + + it("throws on invalid event type", () => { + expect(() => buildWhereFilter({ type: "malicious' OR 1=1 --" as any })).toThrow( + "Invalid event type" + ); + }); + + it("throws on invalid since timestamp", () => { + expect(() => buildWhereFilter({ since: "'; DROP TABLE events; --" })).toThrow( + "Invalid timestamp" + ); + }); + + it("throws on invalid until timestamp", () => { + expect(() => buildWhereFilter({ until: "not-a-date" })).toThrow( + "Invalid timestamp" + ); + }); + + it("accepts valid timestamp range", () => { + const result = buildWhereFilter({ + since: "2024-01-01T00:00:00Z", + until: "2024-12-31T23:59:59Z", + }); + expect(result).toBe( + "timestamp >= '2024-01-01T00:00:00Z' AND timestamp <= '2024-12-31T23:59:59Z'" + ); + }); +});