Fix FTS5 injection vulnerability with proper escaping and comprehensive tests
Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import Database from 'better-sqlite3';
|
||||
import { SessionSearch } from '../src/services/sqlite/SessionSearch';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const TEST_DB_DIR = '/tmp/claude-mem-test';
|
||||
const TEST_DB_PATH = path.join(TEST_DB_DIR, 'test.db');
|
||||
|
||||
describe('SessionSearch FTS5 Injection Tests', () => {
|
||||
let search: SessionSearch;
|
||||
let db: Database.Database;
|
||||
|
||||
// Setup test database before each test
|
||||
function setupTestDB() {
|
||||
// Clean up any existing test database
|
||||
if (fs.existsSync(TEST_DB_DIR)) {
|
||||
fs.rmSync(TEST_DB_DIR, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(TEST_DB_DIR, { recursive: true });
|
||||
|
||||
// Create database with required schema
|
||||
db = new Database(TEST_DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Create minimal schema needed for search tests
|
||||
// Note: Using claude_session_id to match SessionSearch expectations
|
||||
db.exec(`
|
||||
CREATE TABLE sdk_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT UNIQUE NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
started_at_epoch INTEGER DEFAULT ((unixepoch() * 1000))
|
||||
);
|
||||
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT NOT NULL,
|
||||
prompt_number INTEGER DEFAULT 1,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
narrative TEXT,
|
||||
text TEXT,
|
||||
facts TEXT,
|
||||
concepts TEXT,
|
||||
files_read TEXT,
|
||||
files_modified TEXT,
|
||||
project TEXT,
|
||||
created_at_epoch INTEGER DEFAULT ((unixepoch() * 1000)),
|
||||
FOREIGN KEY (claude_session_id) REFERENCES sdk_sessions(claude_session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE session_summaries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT NOT NULL,
|
||||
prompt_number INTEGER DEFAULT 1,
|
||||
request TEXT,
|
||||
investigated TEXT,
|
||||
learned TEXT,
|
||||
completed TEXT,
|
||||
next_steps TEXT,
|
||||
notes TEXT,
|
||||
files_read TEXT,
|
||||
files_edited TEXT,
|
||||
project TEXT,
|
||||
created_at_epoch INTEGER DEFAULT ((unixepoch() * 1000)),
|
||||
FOREIGN KEY (claude_session_id) REFERENCES sdk_sessions(claude_session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE user_prompts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT NOT NULL,
|
||||
prompt_number INTEGER DEFAULT 1,
|
||||
prompt_text TEXT NOT NULL,
|
||||
created_at_epoch INTEGER DEFAULT ((unixepoch() * 1000)),
|
||||
FOREIGN KEY (claude_session_id) REFERENCES sdk_sessions(claude_session_id)
|
||||
);
|
||||
|
||||
-- Create FTS5 tables manually
|
||||
CREATE VIRTUAL TABLE observations_fts USING fts5(
|
||||
title,
|
||||
subtitle,
|
||||
narrative,
|
||||
text,
|
||||
facts,
|
||||
concepts,
|
||||
content='observations',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE session_summaries_fts USING fts5(
|
||||
request,
|
||||
investigated,
|
||||
learned,
|
||||
completed,
|
||||
next_steps,
|
||||
notes,
|
||||
content='session_summaries',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE user_prompts_fts USING fts5(
|
||||
prompt_text,
|
||||
content='user_prompts',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
-- Create triggers for observations
|
||||
CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER observations_ad AFTER DELETE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER observations_au AFTER UPDATE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
|
||||
END;
|
||||
|
||||
-- Create triggers for session_summaries
|
||||
CREATE TRIGGER session_summaries_ai AFTER INSERT ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER session_summaries_ad AFTER DELETE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER session_summaries_au AFTER UPDATE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END;
|
||||
|
||||
-- Create triggers for user_prompts
|
||||
CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(rowid, prompt_text)
|
||||
VALUES (new.id, new.prompt_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
|
||||
VALUES('delete', old.id, old.prompt_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
|
||||
VALUES('delete', old.id, old.prompt_text);
|
||||
INSERT INTO user_prompts_fts(rowid, prompt_text)
|
||||
VALUES (new.id, new.prompt_text);
|
||||
END;
|
||||
`);
|
||||
|
||||
db.close();
|
||||
|
||||
// Create SessionSearch instance
|
||||
return new SessionSearch(TEST_DB_PATH);
|
||||
}
|
||||
|
||||
function teardownTestDB() {
|
||||
if (search) {
|
||||
search.close();
|
||||
search = null;
|
||||
}
|
||||
if (fs.existsSync(TEST_DB_DIR)) {
|
||||
fs.rmSync(TEST_DB_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('should escape double quotes in search queries', () => {
|
||||
search = setupTestDB();
|
||||
|
||||
// Insert test data
|
||||
const db = new Database(TEST_DB_PATH);
|
||||
db.exec(`
|
||||
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-1', 'test-project');
|
||||
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
|
||||
VALUES ('test-session-1', 1, 'feature', 'Test observation', 'A test "quoted" narrative', 'Some text', '[]', '[]', '[]', '[]', 'test-project');
|
||||
`);
|
||||
db.close();
|
||||
|
||||
// Test query with double quotes - should not cause injection
|
||||
const maliciousQuery = 'test" OR 1=1 --';
|
||||
|
||||
// This should not throw an error and should search safely
|
||||
const results = search.searchObservations(maliciousQuery);
|
||||
|
||||
// With proper escaping, this should return 0 results (no match for the literal string)
|
||||
// Without escaping, it could match everything due to OR 1=1
|
||||
assert.strictEqual(Array.isArray(results), true, 'Should return an array');
|
||||
|
||||
teardownTestDB();
|
||||
});
|
||||
|
||||
test('should handle FTS5 special operators safely', () => {
|
||||
search = setupTestDB();
|
||||
|
||||
// Insert test data
|
||||
const db = new Database(TEST_DB_PATH);
|
||||
db.exec(`
|
||||
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-2', 'test-project');
|
||||
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
|
||||
VALUES ('test-session-2', 1, 'feature', 'Security feature', 'Implements security', 'Authentication system', '[]', '[]', '[]', '[]', 'test-project');
|
||||
`);
|
||||
db.close();
|
||||
|
||||
// Test queries with FTS5 operators that should be escaped
|
||||
const testQueries = [
|
||||
'AND OR NOT', // Boolean operators
|
||||
'(parentheses)', // Grouping
|
||||
'asterisk*', // Wildcard
|
||||
'column:value', // Column filter attempt
|
||||
];
|
||||
|
||||
testQueries.forEach(query => {
|
||||
// Should not throw an error
|
||||
const results = search.searchObservations(query);
|
||||
assert.strictEqual(Array.isArray(results), true, `Should return array for query: ${query}`);
|
||||
});
|
||||
|
||||
teardownTestDB();
|
||||
});
|
||||
|
||||
test('should find exact phrase matches when properly escaped', () => {
|
||||
search = setupTestDB();
|
||||
|
||||
// Insert test data
|
||||
const db = new Database(TEST_DB_PATH);
|
||||
db.exec(`
|
||||
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-3', 'test-project');
|
||||
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
|
||||
VALUES ('test-session-3', 1, 'feature', 'Hello world', 'This is a hello world example', 'Hello world program', '[]', '[]', '[]', '[]', 'test-project');
|
||||
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
|
||||
VALUES ('test-session-3', 2, 'feature', 'Goodbye moon', 'This is something else', 'Different content', '[]', '[]', '[]', '[]', 'test-project');
|
||||
`);
|
||||
db.close();
|
||||
|
||||
// Search for exact phrase
|
||||
const results = search.searchObservations('hello world');
|
||||
|
||||
assert.strictEqual(Array.isArray(results), true, 'Should return an array');
|
||||
assert.ok(results.length > 0, 'Should find at least one result');
|
||||
assert.ok(
|
||||
results.some(r => r.title?.toLowerCase().includes('hello') || r.narrative?.toLowerCase().includes('hello')),
|
||||
'Should find observation with "hello"'
|
||||
);
|
||||
|
||||
teardownTestDB();
|
||||
});
|
||||
|
||||
test('should handle empty and special character queries safely', () => {
|
||||
search = setupTestDB();
|
||||
|
||||
// Insert test data
|
||||
const db = new Database(TEST_DB_PATH);
|
||||
db.exec(`
|
||||
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-4', 'test-project');
|
||||
INSERT INTO observations (claude_session_id, prompt_number, type, title, narrative, text, facts, concepts, files_read, files_modified, project)
|
||||
VALUES ('test-session-4', 1, 'feature', 'Test', 'Test observation', 'Test content', '[]', '[]', '[]', '[]', 'test-project');
|
||||
`);
|
||||
db.close();
|
||||
|
||||
// Test edge cases
|
||||
const edgeCases = [
|
||||
'""', // Empty quoted string
|
||||
' ', // Whitespace only
|
||||
'!!!', // Special characters
|
||||
'@#$%', // More special characters
|
||||
];
|
||||
|
||||
edgeCases.forEach(query => {
|
||||
// Should not throw an error
|
||||
const results = search.searchObservations(query);
|
||||
assert.strictEqual(Array.isArray(results), true, `Should return array for edge case: "${query}"`);
|
||||
});
|
||||
|
||||
teardownTestDB();
|
||||
});
|
||||
|
||||
test('should search session summaries safely', () => {
|
||||
search = setupTestDB();
|
||||
|
||||
// Insert test data
|
||||
const db = new Database(TEST_DB_PATH);
|
||||
db.exec(`
|
||||
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-5', 'test-project');
|
||||
INSERT INTO session_summaries (claude_session_id, prompt_number, request, investigated, learned, completed, next_steps, notes, files_read, files_edited, project)
|
||||
VALUES ('test-session-5', 1, 'Implement feature', 'Looked into options', 'Learned new approach', 'Completed task', 'Next: testing', 'Notes here', '[]', '[]', 'test-project');
|
||||
`);
|
||||
db.close();
|
||||
|
||||
// Test with potential injection
|
||||
const maliciousQuery = 'feature" OR type:*';
|
||||
const results = search.searchSessions(maliciousQuery);
|
||||
|
||||
assert.strictEqual(Array.isArray(results), true, 'Should return an array');
|
||||
|
||||
teardownTestDB();
|
||||
});
|
||||
|
||||
test('should search user prompts safely', () => {
|
||||
search = setupTestDB();
|
||||
|
||||
// Insert test data
|
||||
const db = new Database(TEST_DB_PATH);
|
||||
db.exec(`
|
||||
INSERT INTO sdk_sessions (claude_session_id, project) VALUES ('test-session-6', 'test-project');
|
||||
INSERT INTO user_prompts (claude_session_id, prompt_number, prompt_text)
|
||||
VALUES ('test-session-6', 1, 'Please implement authentication');
|
||||
`);
|
||||
db.close();
|
||||
|
||||
// Test with potential injection
|
||||
const maliciousQuery = 'authentication" AND request:*';
|
||||
const results = search.searchUserPrompts(maliciousQuery);
|
||||
|
||||
assert.strictEqual(Array.isArray(results), true, 'Should return an array');
|
||||
|
||||
teardownTestDB();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user