From a80e86a898b1de4b626e35acf081929de14ccae6 Mon Sep 17 00:00:00 2001 From: Mark McIntosh Date: Mon, 23 Feb 2026 23:42:13 -0500 Subject: [PATCH 1/2] fix: validate table names against sqlite_master before truncate The database tools truncate handler interpolated user-supplied table names directly into DELETE FROM statements without validation, allowing SQL injection. Now validates each table name against sqlite_master before executing, rejecting any name not present as an actual table. --- packages/core/src/routes/admin-settings.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/core/src/routes/admin-settings.ts b/packages/core/src/routes/admin-settings.ts index 7ebdfb283..c15297012 100644 --- a/packages/core/src/routes/admin-settings.ts +++ b/packages/core/src/routes/admin-settings.ts @@ -433,7 +433,21 @@ adminSettingsRoutes.post('/api/database-tools/truncate', async (c) => { const db = c.env.DB const results = [] + // Validate table names against actual database tables (prevents SQL injection) + const tablesResult = await db.prepare(` + SELECT name FROM sqlite_master + WHERE type='table' + AND name NOT LIKE 'sqlite_%' + `).all() + const validTables = new Set( + (tablesResult.results || []).map((row: any) => row.name) + ) + for (const tableName of tablesToTruncate) { + if (!validTables.has(tableName)) { + results.push({ table: tableName, success: false, error: 'Table not found' }) + continue + } try { await db.prepare(`DELETE FROM ${tableName}`).run() results.push({ table: tableName, success: true }) From 66636f959ac3fb5c6aa830c01641007c342f51db Mon Sep 17 00:00:00 2001 From: Mark McIntosh Date: Mon, 23 Feb 2026 23:51:28 -0500 Subject: [PATCH 2/2] test: add unit tests for sql injection prevention in truncate handler Tests validate that table names are checked against sqlite_master before DELETE execution. Covers valid tables, invalid tables, mixed lists, empty input, and various SQL injection payloads. --- .../core/src/routes/admin-settings.test.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/core/src/routes/admin-settings.test.ts diff --git a/packages/core/src/routes/admin-settings.test.ts b/packages/core/src/routes/admin-settings.test.ts new file mode 100644 index 000000000..6aa487df3 --- /dev/null +++ b/packages/core/src/routes/admin-settings.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Hono } from 'hono' + +// Mock requireAuth to pass through with admin user +vi.mock('../middleware', () => ({ + requireAuth: () => async (c: any, next: any) => { + c.set('user', { userId: 'test-admin', email: 'admin@test.com', role: 'admin', exp: 0, iat: 0 }) + await next() + } +})) + +// Mock dependencies that aren't relevant to the truncate test +vi.mock('../templates/pages/admin-settings.template', () => ({ + renderSettingsPage: () => '' +})) +vi.mock('../services/migrations', () => ({ + MigrationService: vi.fn() +})) +vi.mock('../services/settings', () => ({ + SettingsService: vi.fn() +})) + +import { adminSettingsRoutes } from './admin-settings' + +function createMockDb(validTableNames: string[] = []) { + const runResults = new Map() + const mockRun = vi.fn().mockResolvedValue({ success: true }) + + const mockPrepare = vi.fn().mockImplementation((sql: string) => { + // sqlite_master query returns the valid table list + if (sql.includes('sqlite_master')) { + return { + bind: vi.fn().mockReturnThis(), + all: vi.fn().mockResolvedValue({ + results: validTableNames.map(name => ({ name })) + }), + first: vi.fn().mockResolvedValue(null), + run: vi.fn().mockResolvedValue({ success: true }) + } + } + // DELETE FROM queries + if (sql.startsWith('DELETE FROM')) { + return { + bind: vi.fn().mockReturnThis(), + all: vi.fn().mockResolvedValue({ results: [] }), + first: vi.fn().mockResolvedValue(null), + run: mockRun + } + } + // Default + return { + bind: vi.fn().mockReturnThis(), + all: vi.fn().mockResolvedValue({ results: [] }), + first: vi.fn().mockResolvedValue(null), + run: vi.fn().mockResolvedValue({ success: true }) + } + }) + + return { prepare: mockPrepare, _mockRun: mockRun } +} + +function createTestApp(db: any) { + const app = new Hono() + + app.use('/admin/settings/*', async (c, next) => { + c.env = { DB: db } as any + c.set('appVersion' as any, '2.0.0') + await next() + }) + + app.route('/admin/settings', adminSettingsRoutes) + return app +} + +describe('POST /admin/settings/api/database-tools/truncate', () => { + let mockDb: ReturnType + let app: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should truncate a valid table', async () => { + mockDb = createMockDb(['users', 'content', 'forms']) + app = createTestApp(mockDb) + + const res = await app.request('/admin/settings/api/database-tools/truncate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tables: ['content'] }) + }) + + const json = await res.json() as any + expect(res.status).toBe(200) + expect(json.success).toBe(true) + expect(json.results).toHaveLength(1) + expect(json.results[0]).toEqual({ table: 'content', success: true }) + }) + + it('should reject a table name not in sqlite_master', async () => { + mockDb = createMockDb(['users', 'content', 'forms']) + app = createTestApp(mockDb) + + const res = await app.request('/admin/settings/api/database-tools/truncate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tables: ['nonexistent_table'] }) + }) + + const json = await res.json() as any + expect(res.status).toBe(200) + expect(json.results).toHaveLength(1) + expect(json.results[0]).toEqual({ + table: 'nonexistent_table', + success: false, + error: 'Table not found' + }) + // DELETE should never have been called + expect(mockDb._mockRun).not.toHaveBeenCalled() + }) + + it('should reject SQL injection in table name', async () => { + mockDb = createMockDb(['users', 'content', 'forms']) + app = createTestApp(mockDb) + + const res = await app.request('/admin/settings/api/database-tools/truncate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tables: ['users; DROP TABLE content--'] }) + }) + + const json = await res.json() as any + expect(json.results[0]).toEqual({ + table: 'users; DROP TABLE content--', + success: false, + error: 'Table not found' + }) + expect(mockDb._mockRun).not.toHaveBeenCalled() + }) + + it('should handle mix of valid and invalid table names', async () => { + mockDb = createMockDb(['users', 'content', 'forms']) + app = createTestApp(mockDb) + + const res = await app.request('/admin/settings/api/database-tools/truncate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tables: ['content', 'injected_table', 'forms'] }) + }) + + const json = await res.json() as any + expect(res.status).toBe(200) + expect(json.results).toHaveLength(3) + expect(json.results[0]).toEqual({ table: 'content', success: true }) + expect(json.results[1]).toEqual({ table: 'injected_table', success: false, error: 'Table not found' }) + expect(json.results[2]).toEqual({ table: 'forms', success: true }) + expect(json.message).toBe('Truncated 2 of 3 tables') + }) + + it('should return 400 when no tables specified', async () => { + mockDb = createMockDb([]) + app = createTestApp(mockDb) + + const res = await app.request('/admin/settings/api/database-tools/truncate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tables: [] }) + }) + + const json = await res.json() as any + expect(res.status).toBe(400) + expect(json.error).toBe('No tables specified for truncation') + }) + + it('should reject subquery injection attempts', async () => { + mockDb = createMockDb(['users', 'content']) + app = createTestApp(mockDb) + + const injections = [ + 'users UNION SELECT * FROM content', + "users WHERE 1=1; INSERT INTO users VALUES('hacked'", + 'users; ATTACH DATABASE', + "content' OR '1'='1", + ] + + const res = await app.request('/admin/settings/api/database-tools/truncate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tables: injections }) + }) + + const json = await res.json() as any + for (const result of json.results) { + expect(result.success).toBe(false) + expect(result.error).toBe('Table not found') + } + expect(mockDb._mockRun).not.toHaveBeenCalled() + }) +})