postgresai 0.16.0-dev.1 → 0.16.0-dev.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -407,12 +407,14 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
407
407
  }
408
408
  }, { timeout: TEST_TIMEOUT });
409
409
 
410
- // 60s timeout for PostgreSQL startup + multiple SQL queries in slow CI
411
- test("explain_generic validates input and prevents SQL injection", async () => {
410
+ // Security regression for #387: explain_generic was a SECURITY DEFINER helper that
411
+ // ran caller-supplied SQL through EXPLAIN. The planner folds IMMUTABLE/STABLE functions
412
+ // at plan time in the definer's (superuser) context, making it an RCE/data-exfil vector.
413
+ // It has been removed; prepare-db must NOT create it.
414
+ test("explain_generic is not created by prepare-db (RCE helper removed, #387)", async () => {
412
415
  pg = await createTempPostgres();
413
416
 
414
417
  try {
415
- // Run init first
416
418
  {
417
419
  const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
418
420
  expect(r.status).toBe(0);
@@ -422,82 +424,10 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
422
424
  await c.connect();
423
425
 
424
426
  try {
425
- // Check PostgreSQL version - generic_plan requires 16+
426
- const versionRes = await c.query("show server_version_num");
427
- const version = parseInt(versionRes.rows[0].server_version_num, 10);
428
-
429
- if (version < 160000) {
430
- // Skip this test on older PostgreSQL versions
431
- console.log("Skipping explain_generic tests: requires PostgreSQL 16+");
432
- return;
433
- }
434
-
435
- // Test 1: Empty query should be rejected
436
- await expect(
437
- c.query("select postgres_ai.explain_generic('')")
438
- ).rejects.toThrow(/query cannot be empty/);
439
-
440
- // Test 2: Null query should be rejected
441
- await expect(
442
- c.query("select postgres_ai.explain_generic(null)")
443
- ).rejects.toThrow(/query cannot be empty/);
444
-
445
- // Test 3: Multiple statements (semicolon in middle) should be rejected
446
- await expect(
447
- c.query("select postgres_ai.explain_generic('select 1; select 2')")
448
- ).rejects.toThrow(/semicolon|multiple statements/i);
449
-
450
- // Test 4: Trailing semicolon should be stripped and work
451
- {
452
- const res = await c.query("select postgres_ai.explain_generic('select 1;') as result");
453
- expect(res.rows[0].result).toBeTruthy();
454
- expect(res.rows[0].result).toMatch(/Result/i);
455
- }
456
-
457
- // Test 5: Valid query should work
458
- {
459
- const res = await c.query("select postgres_ai.explain_generic('select $1::int', 'text') as result");
460
- expect(res.rows[0].result).toBeTruthy();
461
- }
462
-
463
- // Test 6: JSON format should work
464
- {
465
- const res = await c.query("select postgres_ai.explain_generic('select 1', 'json') as result");
466
- const plan = JSON.parse(res.rows[0].result);
467
- expect(Array.isArray(plan)).toBe(true);
468
- expect(plan[0].Plan).toBeTruthy();
469
- }
470
-
471
- // Test 7: Whitespace-only query should be rejected
472
- await expect(
473
- c.query("select postgres_ai.explain_generic(' ')")
474
- ).rejects.toThrow(/query cannot be empty/);
475
-
476
- // Test 8: Semicolon in string literal is rejected (documented limitation)
477
- // Note: This is a known limitation - the simple heuristic cannot parse SQL strings
478
- await expect(
479
- c.query("select postgres_ai.explain_generic('select ''hello;world''')")
480
- ).rejects.toThrow(/semicolon/i);
481
-
482
- // Test 9: SQL comments should work (no semicolons)
483
- {
484
- const res = await c.query("select postgres_ai.explain_generic('select 1 -- comment') as result");
485
- expect(res.rows[0].result).toBeTruthy();
486
- }
487
-
488
- // Test 10: Escaped quotes should work (no semicolons)
489
- {
490
- const res = await c.query("select postgres_ai.explain_generic('select ''test''''s value''') as result");
491
- expect(res.rows[0].result).toBeTruthy();
492
- }
493
-
494
- // Test 11: Case-insensitive format parameter
495
- {
496
- const res = await c.query("select postgres_ai.explain_generic('select 1', 'JSON') as result");
497
- const plan = JSON.parse(res.rows[0].result);
498
- expect(Array.isArray(plan)).toBe(true);
499
- }
500
-
427
+ const res = await c.query(
428
+ "select count(*)::int as n from pg_proc where proname = 'explain_generic' and pronamespace = (select oid from pg_namespace where nspname = 'postgres_ai')"
429
+ );
430
+ expect(res.rows[0].n).toBe(0);
501
431
  } finally {
502
432
  await c.end();
503
433
  }
package/test/init.test.ts CHANGED
@@ -1489,6 +1489,24 @@ describe("checkCurrentUserPermissions", () => {
1489
1489
  ).rejects.toThrow("permission denied for relation pg_roles");
1490
1490
  });
1491
1491
 
1492
+ test("guards optional postgres_ai privilege probes when the schema is absent", async () => {
1493
+ let capturedSql = "";
1494
+ const client = {
1495
+ query: async (sql: string) => {
1496
+ capturedSql = sql;
1497
+ return { rows: [] };
1498
+ },
1499
+ };
1500
+
1501
+ await init.checkCurrentUserPermissions(client as any);
1502
+
1503
+ expect(capturedSql).toContain("to_regnamespace('postgres_ai') is null");
1504
+ expect(capturedSql).toContain("'postgres_ai schema exists' as permission_name");
1505
+ expect(capturedSql).toMatch(
1506
+ /when to_regnamespace\('postgres_ai'\) is null then null\s+when not has_schema_privilege/
1507
+ );
1508
+ });
1509
+
1492
1510
  test("returns all rows for inspection", async () => {
1493
1511
  const rows: init.PermissionCheckRow[] = [
1494
1512
  { permission_name: "connect on database postgres", status: "required", granted: true, fix_command: null },
@@ -1545,6 +1563,23 @@ describe("formatPermissionCheckMessages", () => {
1545
1563
  expect(messages.errors).toHaveLength(0);
1546
1564
  });
1547
1565
 
1566
+ test("explains that a missing postgres_ai schema only degrades F004/F005", () => {
1567
+ const result: init.PreflightPermissionResult = {
1568
+ ok: true,
1569
+ rows: [],
1570
+ missingRequired: [],
1571
+ missingOptional: [
1572
+ { permission_name: "postgres_ai schema exists", status: "optional", granted: false, fix_command: null },
1573
+ ],
1574
+ };
1575
+
1576
+ const messages = init.formatPermissionCheckMessages(result);
1577
+ expect(messages.failed).toBe(false);
1578
+ expect(messages.warnings).toEqual([
1579
+ "Warning: optional: postgres_ai schema not found — F004/F005 (bloat estimates) will be skipped; run prepare-db or create the view manually to enable them.",
1580
+ ]);
1581
+ });
1582
+
1548
1583
  test("returns errors with fix commands for missing required permissions", () => {
1549
1584
  const result: init.PreflightPermissionResult = {
1550
1585
  ok: false,