@@ -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 ( / q u e r y c a n n o t b e e m p t y / ) ;
428+
429+ // Test 2: Null query should be rejected
430+ await expect (
431+ c . query ( "select postgres_ai.explain_generic(null)" )
432+ ) . rejects . toThrow ( / q u e r y c a n n o t b e e m p t y / ) ;
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 ( / s e m i c o l o n | m u l t i p l e s t a t e m e n t s / 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 ( / R e s u l t / 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 ( / q u e r y c a n n o t b e e m p t y / ) ;
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 ( / s e m i c o l o n / 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