Skip to content

Commit 4364c56

Browse files
committed
Merge branch 'claude/fix-explain-generic-sql-injection-boJKG' into 'main'
fix(prepare-db): add SQL injection protection to explain_generic Closes #70 See merge request postgres-ai/postgres_ai!121
2 parents b9e4aeb + ac54d5a commit 4364c56

2 files changed

Lines changed: 129 additions & 7 deletions

File tree

cli/sql/05.helpers.sql

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
-- operations they don't have direct permissions for.
44

55
/*
6-
* pgai_explain_generic
6+
* explain_generic
77
*
88
* Function to get generic explain plans with optional HypoPG index testing.
99
* Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
1010
*
11+
* Security notes:
12+
* - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
13+
* - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
14+
* - Input validation uses a simple heuristic to detect multiple statements
15+
* (Note: may reject valid queries containing semicolons in string literals)
16+
*
1117
* Usage examples:
1218
* -- Basic generic plan
1319
* select postgres_ai.explain_generic('select * from users where id = $1');
@@ -39,6 +45,7 @@ declare
3945
v_hypo_result record;
4046
v_version int;
4147
v_hypopg_available boolean;
48+
v_clean_query text;
4249
begin
4350
-- Check PostgreSQL version (generic_plan requires 16+)
4451
select current_setting('server_version_num')::int into v_version;
@@ -48,6 +55,24 @@ begin
4855
current_setting('server_version');
4956
end if;
5057

58+
-- Input validation: reject empty queries
59+
if query is null or trim(query) = '' then
60+
raise exception 'query cannot be empty';
61+
end if;
62+
63+
-- Input validation: detect multiple statements (defense-in-depth)
64+
-- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
65+
-- Limitation: Queries with semicolons inside string literals will be rejected
66+
v_clean_query := trim(query);
67+
if v_clean_query like '%;%' then
68+
-- Strip trailing semicolon if present (common user convenience)
69+
v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
70+
-- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
71+
if v_clean_query like '%;%' then
72+
raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
73+
end if;
74+
end if;
75+
5176
-- Check if HypoPG extension is available
5277
if hypopg_index is not null then
5378
select exists(
@@ -64,15 +89,14 @@ begin
6489
v_hypo_result.indexname, v_hypo_result.indexrelid;
6590
end if;
6691

67-
-- Build and execute explain query based on format
68-
-- Output is preserved exactly as EXPLAIN returns it
92+
-- Build and execute EXPLAIN query
93+
-- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
6994
begin
7095
if lower(format) = 'json' then
71-
v_explain_query := 'explain (verbose, settings, generic_plan, format json) ' || query;
72-
execute v_explain_query into result;
96+
execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
97+
into result;
7398
else
74-
v_explain_query := 'explain (verbose, settings, generic_plan) ' || query;
75-
for v_line in execute v_explain_query loop
99+
for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
76100
v_lines := array_append(v_lines, v_line."QUERY PLAN");
77101
end loop;
78102
result := array_to_string(v_lines, e'\n');

cli/test/init.integration.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,4 +396,102 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
396396
await pg.cleanup();
397397
}
398398
});
399+
400+
test("explain_generic validates input and prevents SQL injection", async () => {
401+
pg = await createTempPostgres();
402+
403+
try {
404+
// Run init first
405+
{
406+
const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
407+
expect(r.status).toBe(0);
408+
}
409+
410+
const c = new Client({ connectionString: pg.adminUri });
411+
await c.connect();
412+
413+
try {
414+
// Check PostgreSQL version - generic_plan requires 16+
415+
const versionRes = await c.query("show server_version_num");
416+
const version = parseInt(versionRes.rows[0].server_version_num, 10);
417+
418+
if (version < 160000) {
419+
// Skip this test on older PostgreSQL versions
420+
console.log("Skipping explain_generic tests: requires PostgreSQL 16+");
421+
return;
422+
}
423+
424+
// Test 1: Empty query should be rejected
425+
await expect(
426+
c.query("select postgres_ai.explain_generic('')")
427+
).rejects.toThrow(/query cannot be empty/);
428+
429+
// Test 2: Null query should be rejected
430+
await expect(
431+
c.query("select postgres_ai.explain_generic(null)")
432+
).rejects.toThrow(/query cannot be empty/);
433+
434+
// Test 3: Multiple statements (semicolon in middle) should be rejected
435+
await expect(
436+
c.query("select postgres_ai.explain_generic('select 1; select 2')")
437+
).rejects.toThrow(/semicolon|multiple statements/i);
438+
439+
// Test 4: Trailing semicolon should be stripped and work
440+
{
441+
const res = await c.query("select postgres_ai.explain_generic('select 1;') as result");
442+
expect(res.rows[0].result).toBeTruthy();
443+
expect(res.rows[0].result).toMatch(/Result/i);
444+
}
445+
446+
// Test 5: Valid query should work
447+
{
448+
const res = await c.query("select postgres_ai.explain_generic('select $1::int', 'text') as result");
449+
expect(res.rows[0].result).toBeTruthy();
450+
}
451+
452+
// Test 6: JSON format should work
453+
{
454+
const res = await c.query("select postgres_ai.explain_generic('select 1', 'json') as result");
455+
const plan = JSON.parse(res.rows[0].result);
456+
expect(Array.isArray(plan)).toBe(true);
457+
expect(plan[0].Plan).toBeTruthy();
458+
}
459+
460+
// Test 7: Whitespace-only query should be rejected
461+
await expect(
462+
c.query("select postgres_ai.explain_generic(' ')")
463+
).rejects.toThrow(/query cannot be empty/);
464+
465+
// Test 8: Semicolon in string literal is rejected (documented limitation)
466+
// Note: This is a known limitation - the simple heuristic cannot parse SQL strings
467+
await expect(
468+
c.query("select postgres_ai.explain_generic('select ''hello;world''')")
469+
).rejects.toThrow(/semicolon/i);
470+
471+
// Test 9: SQL comments should work (no semicolons)
472+
{
473+
const res = await c.query("select postgres_ai.explain_generic('select 1 -- comment') as result");
474+
expect(res.rows[0].result).toBeTruthy();
475+
}
476+
477+
// Test 10: Escaped quotes should work (no semicolons)
478+
{
479+
const res = await c.query("select postgres_ai.explain_generic('select ''test''''s value''') as result");
480+
expect(res.rows[0].result).toBeTruthy();
481+
}
482+
483+
// Test 11: Case-insensitive format parameter
484+
{
485+
const res = await c.query("select postgres_ai.explain_generic('select 1', 'JSON') as result");
486+
const plan = JSON.parse(res.rows[0].result);
487+
expect(Array.isArray(plan)).toBe(true);
488+
}
489+
490+
} finally {
491+
await c.end();
492+
}
493+
} finally {
494+
await pg.cleanup();
495+
}
496+
});
399497
});

0 commit comments

Comments
 (0)