Skip to content

Commit 577c695

Browse files
alari76claude
andauthored
fix: validate identifiers in workflow query builder to prevent SQL injection (#490)
buildListQuery interpolated the table name, filter column names, and the ORDER BY clause directly into SQL. Current callers only pass hardcoded values, so this is not exploitable today — but the generic signature means any future caller wiring user input into a column or sort parameter would create a direct SQL injection with no compiler warning. Validate table/column names as bare SQL identifiers and require an explicit direction on ORDER BY clauses, throwing on anything else. Filter values remain parameterized as before. Export the helper and add unit tests covering valid queries and rejected injection attempts. Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
1 parent 7e257e0 commit 577c695

2 files changed

Lines changed: 68 additions & 4 deletions

File tree

server/workflow-engine.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ vi.mock('fs', async (importOriginal) => {
3030
}
3131
})
3232

33-
import { WorkflowEngine, WorkflowSkipped, SessionGoneError, initWorkflowEngine, getWorkflowEngine, shutdownWorkflowEngine, cronMatchesDate } from './workflow-engine.js'
33+
import { WorkflowEngine, WorkflowSkipped, SessionGoneError, initWorkflowEngine, getWorkflowEngine, shutdownWorkflowEngine, cronMatchesDate, buildListQuery } from './workflow-engine.js'
3434
import type { StepHandler } from './workflow-engine.js'
3535

3636
describe('WorkflowEngine', () => {
@@ -450,6 +450,42 @@ describe('WorkflowEngine', () => {
450450
})
451451
})
452452

453+
describe('buildListQuery', () => {
454+
it('builds a parameterized query for valid inputs', () => {
455+
const { sql, params } = buildListQuery('workflow_runs', {
456+
filters: [{ column: 'kind', value: 'test' }, { column: 'status', value: 'failed' }],
457+
orderBy: 'created_at DESC',
458+
limit: 10,
459+
offset: 5,
460+
})
461+
expect(sql).toBe('SELECT * FROM workflow_runs WHERE 1=1 AND kind = ? AND status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?')
462+
expect(params).toEqual(['test', 'failed', 10, 5])
463+
})
464+
465+
it('rejects an injection attempt in the table name', () => {
466+
expect(() => buildListQuery('workflow_runs; DROP TABLE workflow_runs', { filters: [] }))
467+
.toThrow(/invalid table name/)
468+
})
469+
470+
it('rejects an injection attempt in a filter column', () => {
471+
expect(() => buildListQuery('workflow_runs', {
472+
filters: [{ column: 'kind = 1 OR 1=1 --', value: 'x' }],
473+
})).toThrow(/invalid filter column/)
474+
})
475+
476+
it('rejects an injection attempt in the orderBy clause', () => {
477+
expect(() => buildListQuery('workflow_runs', {
478+
filters: [],
479+
orderBy: 'created_at; DROP TABLE workflow_runs',
480+
})).toThrow(/invalid orderBy clause/)
481+
})
482+
483+
it('rejects an orderBy without an explicit direction', () => {
484+
expect(() => buildListQuery('workflow_runs', { filters: [], orderBy: 'created_at' }))
485+
.toThrow(/invalid orderBy clause/)
486+
})
487+
})
488+
453489
describe('getRun', () => {
454490
it('returns null for non-existent run', () => {
455491
mockGet.mockReturnValueOnce(undefined)

server/workflow-engine.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,17 +282,45 @@ interface ListQueryOpts {
282282
offset?: number
283283
}
284284

285-
/** Build a parameterized SELECT query from typed filter objects. */
286-
function buildListQuery(table: string, opts: ListQueryOpts): { sql: string; params: unknown[] } {
285+
/**
286+
* A bare SQL identifier: a table or column name with no quoting, spaces, or
287+
* other metacharacters. Filter values are always parameterized, but table and
288+
* column names cannot be — so they are validated against this pattern to ensure
289+
* a future caller passing user-controlled input cannot inject SQL.
290+
*/
291+
const SQL_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i
292+
293+
/** An ORDER BY clause: a single identifier followed by an explicit direction. */
294+
const SQL_ORDER_BY = /^[a-z_][a-z0-9_]* (ASC|DESC)$/i
295+
296+
/**
297+
* Build a parameterized SELECT query from typed filter objects.
298+
*
299+
* Exported for testing. Filter values are parameterized; table/column/orderBy
300+
* are validated as bare SQL identifiers since they cannot be parameterized.
301+
*/
302+
export function buildListQuery(table: string, opts: ListQueryOpts): { sql: string; params: unknown[] } {
303+
if (!SQL_IDENTIFIER.test(table)) {
304+
throw new Error(`buildListQuery: invalid table name '${table}'`)
305+
}
306+
287307
const params: unknown[] = []
288308
let sql = `SELECT * FROM ${table} WHERE 1=1`
289309

290310
for (const f of opts.filters) {
311+
if (!SQL_IDENTIFIER.test(f.column)) {
312+
throw new Error(`buildListQuery: invalid filter column '${f.column}'`)
313+
}
291314
sql += ` AND ${f.column} = ?`
292315
params.push(f.value)
293316
}
294317

295-
if (opts.orderBy) sql += ` ORDER BY ${opts.orderBy}`
318+
if (opts.orderBy) {
319+
if (!SQL_ORDER_BY.test(opts.orderBy)) {
320+
throw new Error(`buildListQuery: invalid orderBy clause '${opts.orderBy}'`)
321+
}
322+
sql += ` ORDER BY ${opts.orderBy}`
323+
}
296324
if (opts.limit) { sql += ` LIMIT ?`; params.push(opts.limit) }
297325
if (opts.offset) { sql += ` OFFSET ?`; params.push(opts.offset) }
298326

0 commit comments

Comments
 (0)