Skip to content

Commit 528b451

Browse files
committed
fix(cli): address security and bug issues in Supabase mode
- Add null byte check in escapeLiteral() to prevent SQL injection - Add isValidIdentifier() for monitoring user name validation - Add isValidProjectRef() to validate Supabase project reference format - URL-encode projectRef in API URL to prevent path traversal - Fix pooler URL extraction for modern AWS regional URLs (postgres.<ref>@aws-*.pooler.supabase.com) - Remove ineffective ROLLBACK in stateless Supabase API connections - Add existence checks before has_schema_privilege/has_function_privilege - Add comprehensive tests for applyInitPlanViaSupabase and verifyInitSetupViaSupabase - Add README documentation for Supabase mode
1 parent 23eadf4 commit 528b451

File tree

3 files changed

+378
-73
lines changed

3 files changed

+378
-73
lines changed

cli/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,38 @@ To see what SQL would be executed (passwords redacted by default):
9393
npx postgresai prepare-db postgresql://admin@host:5432/dbname --print-sql
9494
```
9595

96+
### Supabase mode
97+
98+
For Supabase projects, you can use the Management API instead of direct PostgreSQL connections. This is useful when direct database access is restricted.
99+
100+
```bash
101+
# Using environment variables
102+
export SUPABASE_ACCESS_TOKEN='your_management_api_token'
103+
export SUPABASE_PROJECT_REF='your_project_ref'
104+
npx postgresai prepare-db --supabase
105+
106+
# Using command-line options
107+
npx postgresai prepare-db --supabase \
108+
--supabase-access-token 'your_token' \
109+
--supabase-project-ref 'your_project_ref'
110+
111+
# Auto-detect project ref from a Supabase database URL
112+
npx postgresai prepare-db postgresql://postgres:password@db.abc123.supabase.co:5432/postgres \
113+
--supabase --supabase-access-token 'your_token'
114+
```
115+
116+
The Supabase access token can be created at https://supabase.com/dashboard/account/tokens.
117+
118+
Options:
119+
- `--supabase` - Enable Supabase Management API mode
120+
- `--supabase-access-token <token>` - Supabase Management API access token (or use `SUPABASE_ACCESS_TOKEN` env var)
121+
- `--supabase-project-ref <ref>` - Supabase project reference (or use `SUPABASE_PROJECT_REF` env var)
122+
123+
Notes:
124+
- The project reference can be auto-detected from Supabase database URLs
125+
- All standard options work with Supabase mode (`--verify`, `--print-sql`, `--skip-optional-permissions`, etc.)
126+
- When using `--verify`, the tool checks if all required setup is in place
127+
96128
### Verify and password reset
97129

98130
Verify that everything is configured as expected (no changes):

cli/lib/supabase.ts

Lines changed: 122 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ type SupabaseApiResponse = {
6565
// The API returns the result directly (array) on success
6666
} | Record<string, unknown>[];
6767

68+
/**
69+
* Validate Supabase project reference format.
70+
* Project refs are typically 20 lowercase alphanumeric characters.
71+
*/
72+
function isValidProjectRef(ref: string): boolean {
73+
// Supabase project refs are alphanumeric, typically 20 chars, lowercase
74+
return /^[a-z0-9]{10,30}$/i.test(ref);
75+
}
76+
6877
/**
6978
* Supabase Management API client for executing SQL queries.
7079
*/
@@ -78,19 +87,24 @@ export class SupabaseClient {
7887
if (!config.accessToken) {
7988
throw new Error("Supabase access token is required");
8089
}
90+
// Validate project ref format to prevent path traversal
91+
if (!isValidProjectRef(config.projectRef)) {
92+
throw new Error(`Invalid Supabase project reference format: "${config.projectRef}". Expected 10-30 alphanumeric characters.`);
93+
}
8194
this.config = config;
8295
}
8396

8497
/**
8598
* Execute a SQL query via the Supabase Management API.
8699
*
87100
* @param sql The SQL query to execute
88-
* @param readOnly Whether this is a read-only query (default: false for DDL/DML)
89-
* @returns Query result with rows and rowCount
101+
* @param readOnly If true, uses read_only flag in API request (default: false for DDL/DML operations)
102+
* @returns Query result with rows and rowCount (rowCount is array length for SELECT queries)
90103
* @throws PgCompatibleError on failure
91104
*/
92105
async query(sql: string, readOnly = false): Promise<SupabaseQueryResult> {
93-
const url = `${SUPABASE_API_BASE}/v1/projects/${this.config.projectRef}/database/query`;
106+
// URL-encode projectRef for safety (validated in constructor, but defense in depth)
107+
const url = `${SUPABASE_API_BASE}/v1/projects/${encodeURIComponent(this.config.projectRef)}/database/query`;
94108

95109
const response = await fetch(url, {
96110
method: "POST",
@@ -356,7 +370,9 @@ export function resolveSupabaseConfig(opts: {
356370
/**
357371
* Extract project reference from a Supabase database URL.
358372
* Supabase database URLs typically look like:
359-
* postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres
373+
* - Direct: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres
374+
* - Pooler (modern): postgresql://postgres.[PROJECT_REF]:[PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres
375+
* - Pooler (legacy): postgresql://postgres:[PASSWORD]@[PROJECT_REF].pooler.supabase.com:6543/postgres
360376
*
361377
* @param dbUrl PostgreSQL connection URL
362378
* @returns Project reference if found, undefined otherwise
@@ -366,15 +382,25 @@ export function extractProjectRefFromUrl(dbUrl: string): string | undefined {
366382
const url = new URL(dbUrl);
367383
const host = url.hostname;
368384

369-
// Match db.<ref>.supabase.co or <ref>.supabase.co patterns
385+
// Match db.<ref>.supabase.co or <ref>.supabase.co patterns (direct connection)
370386
const match = host.match(/^(?:db\.)?([^.]+)\.supabase\.co$/i);
371387
if (match && match[1]) {
372388
return match[1];
373389
}
374390

375-
// Also check for pooler URLs: <project-ref>.pooler.supabase.com
376-
const poolerMatch = host.match(/^([^.]+)\.pooler\.supabase\.com$/i);
377-
if (poolerMatch && poolerMatch[1]) {
391+
// Modern pooler URLs: project ref is in the username as postgres.<ref>
392+
// Example: postgresql://postgres.abcdefghij:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres
393+
if (host.includes("pooler.supabase.com")) {
394+
const username = url.username;
395+
const userMatch = username.match(/^postgres\.([a-z0-9]+)$/i);
396+
if (userMatch && userMatch[1]) {
397+
return userMatch[1];
398+
}
399+
}
400+
401+
// Legacy pooler URLs: <project-ref>.pooler.supabase.com (fallback)
402+
const poolerMatch = host.match(/^([a-z0-9]+)\.pooler\.supabase\.com$/i);
403+
if (poolerMatch && poolerMatch[1] && !poolerMatch[1].startsWith("aws-")) {
378404
return poolerMatch[1];
379405
}
380406

@@ -411,21 +437,11 @@ export async function applyInitPlanViaSupabase(params: {
411437
sql: string;
412438
optional?: boolean;
413439
}): Promise<void> => {
414-
// Supabase API handles transactions automatically for single statements
415-
// For multi-statement SQL, we wrap in a transaction
440+
// Wrap in explicit transaction for atomic execution.
441+
// Note: Supabase API uses pooled connections, so if the transaction fails,
442+
// PostgreSQL automatically rolls it back - no separate ROLLBACK needed.
416443
const wrappedSql = `BEGIN;\n${step.sql}\nCOMMIT;`;
417-
418-
try {
419-
await params.client.query(wrappedSql, false);
420-
} catch (e) {
421-
// On error, attempt rollback (may already be rolled back by Supabase)
422-
try {
423-
await params.client.query("ROLLBACK;", false);
424-
} catch {
425-
// ignore rollback errors
426-
}
427-
throw e;
428-
}
444+
await params.client.query(wrappedSql, false);
429445
};
430446

431447
// Apply non-optional steps first
@@ -498,6 +514,12 @@ export async function applyInitPlanViaSupabase(params: {
498514
/**
499515
* Verify init setup via Supabase Management API.
500516
* Mirrors the behavior of verifyInitSetup() in init.ts but uses Supabase API.
517+
*
518+
* @param params.client - Supabase client for API calls
519+
* @param params.database - Database name to verify
520+
* @param params.monitoringUser - Role name to check permissions for
521+
* @param params.includeOptionalPermissions - Whether to check optional permissions
522+
* @returns Object with ok status and arrays of missing required/optional items
501523
*/
502524
export async function verifyInitSetupViaSupabase(params: {
503525
client: SupabaseClient;
@@ -515,6 +537,11 @@ export async function verifyInitSetupViaSupabase(params: {
515537
const role = params.monitoringUser;
516538
const db = params.database;
517539

540+
// Validate role name to prevent SQL injection
541+
if (!isValidIdentifier(role)) {
542+
throw new Error(`Invalid monitoring user name: "${role}". Must be a valid PostgreSQL identifier (letters, digits, underscores, max 63 chars, starting with letter or underscore).`);
543+
}
544+
518545
// Check if role exists
519546
const roleRes = await params.client.query(
520547
`SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${escapeLiteral(role)}'`,
@@ -554,13 +581,22 @@ export async function verifyInitSetupViaSupabase(params: {
554581
missingRequired.push("SELECT on pg_catalog.pg_index");
555582
}
556583

557-
// Check postgres_ai schema
584+
// Check postgres_ai schema exists and has USAGE privilege
585+
// First check if schema exists to avoid has_schema_privilege throwing error
558586
const schemaExistsRes = await params.client.query(
559-
`SELECT has_schema_privilege('${escapeLiteral(role)}', 'postgres_ai', 'USAGE') as ok`,
587+
"SELECT nspname FROM pg_namespace WHERE nspname = 'postgres_ai'",
560588
true
561589
);
562-
if (!schemaExistsRes.rows?.[0]?.ok) {
563-
missingRequired.push("USAGE on schema postgres_ai");
590+
if (schemaExistsRes.rowCount === 0) {
591+
missingRequired.push("schema postgres_ai exists");
592+
} else {
593+
const schemaPrivRes = await params.client.query(
594+
`SELECT has_schema_privilege('${escapeLiteral(role)}', 'postgres_ai', 'USAGE') as ok`,
595+
true
596+
);
597+
if (!schemaPrivRes.rows?.[0]?.ok) {
598+
missingRequired.push("USAGE on schema postgres_ai");
599+
}
564600
}
565601

566602
// Check pg_statistic view
@@ -613,23 +649,39 @@ export async function verifyInitSetupViaSupabase(params: {
613649
}
614650
}
615651

616-
// Check helper functions
617-
const explainFnRes = await params.client.query(
618-
`SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`,
652+
// Check helper functions - first verify they exist to avoid has_function_privilege errors
653+
const explainFnExistsRes = await params.client.query(
654+
"SELECT oid FROM pg_proc WHERE proname = 'explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
619655
true
620656
);
621-
if (!explainFnRes.rows?.[0]?.ok) {
622-
missingRequired.push(
623-
"EXECUTE on postgres_ai.explain_generic(text, text, text)"
657+
if (explainFnExistsRes.rowCount === 0) {
658+
missingRequired.push("function postgres_ai.explain_generic exists");
659+
} else {
660+
const explainFnRes = await params.client.query(
661+
`SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`,
662+
true
624663
);
664+
if (!explainFnRes.rows?.[0]?.ok) {
665+
missingRequired.push(
666+
"EXECUTE on postgres_ai.explain_generic(text, text, text)"
667+
);
668+
}
625669
}
626670

627-
const tableDescribeFnRes = await params.client.query(
628-
`SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.table_describe(text)', 'EXECUTE') as ok`,
671+
const tableDescribeFnExistsRes = await params.client.query(
672+
"SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
629673
true
630674
);
631-
if (!tableDescribeFnRes.rows?.[0]?.ok) {
632-
missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
675+
if (tableDescribeFnExistsRes.rowCount === 0) {
676+
missingRequired.push("function postgres_ai.table_describe exists");
677+
} else {
678+
const tableDescribeFnRes = await params.client.query(
679+
`SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.table_describe(text)', 'EXECUTE') as ok`,
680+
true
681+
);
682+
if (!tableDescribeFnRes.rows?.[0]?.ok) {
683+
missingRequired.push("EXECUTE on postgres_ai.table_describe(text)");
684+
}
633685
}
634686

635687
// Optional permissions
@@ -642,28 +694,37 @@ export async function verifyInitSetupViaSupabase(params: {
642694
if (extRes.rowCount === 0) {
643695
missingOptional.push("extension rds_tools");
644696
} else {
645-
const fnRes = await params.client.query(
646-
`SELECT has_function_privilege('${escapeLiteral(role)}', 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok`,
647-
true
648-
);
649-
if (!fnRes.rows?.[0]?.ok) {
697+
try {
698+
const fnRes = await params.client.query(
699+
`SELECT has_function_privilege('${escapeLiteral(role)}', 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok`,
700+
true
701+
);
702+
if (!fnRes.rows?.[0]?.ok) {
703+
missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
704+
}
705+
} catch {
650706
missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
651707
}
652708
}
653709

654-
// Self-managed extras
710+
// Self-managed extras (these are hardcoded constants, safe to use directly)
655711
const optionalFns = [
656712
"pg_catalog.pg_stat_file(text)",
657713
"pg_catalog.pg_stat_file(text, boolean)",
658714
"pg_catalog.pg_ls_dir(text)",
659715
"pg_catalog.pg_ls_dir(text, boolean, boolean)",
660716
];
661717
for (const fn of optionalFns) {
662-
const fnRes = await params.client.query(
663-
`SELECT has_function_privilege('${escapeLiteral(role)}', '${fn}', 'EXECUTE') as ok`,
664-
true
665-
);
666-
if (!fnRes.rows?.[0]?.ok) {
718+
try {
719+
const fnRes = await params.client.query(
720+
`SELECT has_function_privilege('${escapeLiteral(role)}', '${fn}', 'EXECUTE') as ok`,
721+
true
722+
);
723+
if (!fnRes.rows?.[0]?.ok) {
724+
missingOptional.push(`EXECUTE on ${fn}`);
725+
}
726+
} catch {
727+
// Function may not exist on this PostgreSQL version
667728
missingOptional.push(`EXECUTE on ${fn}`);
668729
}
669730
}
@@ -676,11 +737,25 @@ export async function verifyInitSetupViaSupabase(params: {
676737
};
677738
}
678739

740+
/**
741+
* Validate that a string is a valid PostgreSQL identifier.
742+
* PostgreSQL identifiers can contain letters, digits, and underscores,
743+
* must start with a letter or underscore, and are max 63 characters.
744+
*/
745+
function isValidIdentifier(name: string): boolean {
746+
return /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(name);
747+
}
748+
679749
/**
680750
* Escape a string literal for use in SQL.
751+
* Handles null bytes and single quotes for safe SQL interpolation.
681752
* Note: This is for dynamic query building where parameterized queries aren't possible.
682753
*/
683754
function escapeLiteral(value: string): string {
755+
// Reject null bytes which can cause string truncation
756+
if (value.includes("\0")) {
757+
throw new Error("SQL literal cannot contain null bytes");
758+
}
684759
// Escape single quotes by doubling them
685760
return value.replace(/'/g, "''");
686761
}

0 commit comments

Comments
 (0)