ANT-2026-MEXHXGNE · supabase/supabase
sql-injection high
Severity Claude high · Security research firm - · Maintainer -
Discovered by Claude Mythos Preview
Anthropic's analysis of this finding, sealed at approval.
ANT-2026-MEXHXGNE: Link-driven SQL injection via schema URL parameter
Supabase Studio reads the ?schema= URL parameter via nuqs parseAsString with no validation and passes it to useIndexesQuery, which auto-fires on component mount. The query builder at indexes.ts:20 interpolates this value raw into WHERE n.nspname = '${schema}' and the resulting string is POSTed to the write-capable /pg-meta/{ref}/query endpoint using the victim's session token. An attacker who is not a project member can send an authenticated victim a crafted /project/<ref>/database/indexes?schema=<payload> link; merely opening it executes attacker-chosen SQL (DROP SCHEMA, CREATE ROLE, secret exfiltration) against the victim's Postgres. The tainted schema is also persisted to localStorage, causing the payload to re-execute on subsequent visits to schema-aware pages.
Target
Project: supabase/supabase
Location: packages/pg-meta/src/sql/studio/database/indexes.ts:20
Discovery: static analysis — not yet dynamically reproduced
Technical Details
The root cause is raw string interpolation of a user-controlled URL parameter into SQL: WHERE n.nspname = '${schema}' uses neither the existing ident() nor literal() escaping helpers (unlike sibling builders such as database/misc.ts:15). Because react-query is enabled as soon as projectRef and schema are defined, the injected SQL executes automatically on page load with no further user interaction, and the backend /query endpoint accepts arbitrary SQL with no read-only restriction.
Reproduction
- Attacker crafts URL: https://supabase.com/dashboard/project//database/indexes?schema=public%27%3B%20DROP%20SCHEMA%20public%20CASCADE%3B--
- Victim (authenticated) opens the link;
useSchemaQueryStatereads?schema=unvalidated Indexes.tsxmounts anduseIndexesQueryauto-fires with the tainted schemagetIndexesSQLconcatenates the payload into raw SQL;executeSqlPOSTs it to/platform/pg-meta/{ref}/querywith the victim's access token- Payload executes in victim's Postgres; tainted schema is persisted to localStorage and re-runs on later visits
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Suggested Fix
Pass all identifiers and literals embedded in generated SQL through the existing ident()/literal() escaping helpers, and validate URL-sourced schema names against the project's actual schema list before using them in any query.
Acknowledgement
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-MEXHXGNE.
Reference: ANT-2026-MEXHXGNE
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
The change that resolved this finding.
diff --git a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx
index 11646d724e7ad..672447796fc41 100644
--- a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx
+++ b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx
@@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
+import { keyword } from '@supabase/pg-meta'
import { PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers'
import type { PostgresTrigger } from '@supabase/postgres-meta'
import { useQueryClient } from '@tanstack/react-query'
@@ -275,7 +276,11 @@ export const EditHookPanel = () => {
projectRef: project?.ref,
connectionString: project?.connectionString,
originalTrigger: selectedHook,
- updatedTrigger: { ...payload, enabled_mode: 'ORIGIN' },
+ updatedTrigger: {
+ ...payload,
+ enabled_mode: 'ORIGIN',
+ events: payload.events.map(keyword),
+ },
})
}
} catch (error) {
diff --git a/apps/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts b/apps/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts
index 64897d5ac329b..738fe4a3061c1 100644
--- a/apps/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts
+++ b/apps/studio/data/database-triggers/database-trigger-update-transaction-mutation.ts
@@ -1,4 +1,4 @@
-import { getDatabaseTriggerUpdateSQL } from '@supabase/pg-meta'
+import { getDatabaseTriggerUpdateSQL, type SafeSqlFragment } from '@supabase/pg-meta'
import { PGTrigger, PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers'
import { PostgresTrigger } from '@supabase/postgres-meta'
import { useMutation, useQueryClient } from '@tanstack/react-query'
@@ -16,7 +16,8 @@ export type DatabaseTriggerUpdateVariables = {
projectRef: string
connectionString?: string | null
originalTrigger: PostgresTrigger
- updatedTrigger: PGTriggerCreate & Pick<PGTrigger, 'enabled_mode'>
+ updatedTrigger: Omit<PGTriggerCreate, 'events'> &
+ Pick<PGTrigger, 'enabled_mode'> & { events: Array<SafeSqlFragment> }
}
export async function updateDatabaseTrigger({
diff --git a/packages/pg-meta/src/sql/studio/advisor/index-advisor.ts b/packages/pg-meta/src/sql/studio/advisor/index-advisor.ts
index 3fe4152292065..8708b11e1d8e5 100644
--- a/packages/pg-meta/src/sql/studio/advisor/index-advisor.ts
+++ b/packages/pg-meta/src/sql/studio/advisor/index-advisor.ts
@@ -1,15 +1,18 @@
+import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
+
/**
* Generates SQL to find top 5 SELECT queries involving a table and run them through index_advisor
*/
-export function getTableIndexAdvisorSql(schema: string, table: string): string {
- const escapedSchema = schema.replace(/'/g, "''")
- const escapedTable = table.replace(/'/g, "''")
+export function getTableIndexAdvisorSql(schema: string, table: string): SafeSqlFragment {
+ // Escape regex metacharacters so schema/table names are matched literally in PostgreSQL regex
+ const regexSchema = schema.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&')
+ const regexTable = table.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&')
- // Escape regex metacharacters so schema/table names are matched literally
- const regexSchema = escapedSchema.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&')
- const regexTable = escapedTable.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&')
+ const tablePattern = literal(`(^|[^a-z0-9_$])${regexSchema}[.]${regexTable}($|[^a-z0-9_$])`)
+ const fromPattern = literal(`(^|[^a-z0-9_$])from[[:space:]]+${regexTable}($|[^a-z0-9_$])`)
+ const joinPattern = literal(`(^|[^a-z0-9_$])join[[:space:]]+${regexTable}($|[^a-z0-9_$])`)
- return /* SQL */ `
+ return safeSql`
-- Get top 5 SELECT queries involving this table and run through index_advisor
set search_path to public, extensions;
@@ -27,9 +30,9 @@ with top_queries as (
-- Filter for queries involving our table. Use regex word boundaries so that e.g.
-- looking for table "orders" does not match queries on "orders_items".
and (
- lower(statements.query) ~ '(^|[^a-z0-9_$])${regexSchema}[.]${regexTable}($|[^a-z0-9_$])'
- or lower(statements.query) ~ '(^|[^a-z0-9_$])from[[:space:]]+${regexTable}($|[^a-z0-9_$])'
- or lower(statements.query) ~ '(^|[^a-z0-9_$])join[[:space:]]+${regexTable}($|[^a-z0-9_$])'
+ lower(statements.query) ~ ${tablePattern}
+ or lower(statements.query) ~ ${fromPattern}
+ or lower(statements.query) ~ ${joinPattern}
)
-- Exclude system queries
and statements.query not like '%pg_catalog%'
@@ -50,6 +53,5 @@ select
from top_queries tq
left join lateral (
select * from index_advisor(tq.query)
-) ia on true;
-`.trim()
+) ia on true;`
}
diff --git a/packages/pg-meta/src/sql/studio/auth/get-index-statuses.ts b/packages/pg-meta/src/sql/studio/auth/get-index-statuses.ts
index 685c1c809fb54..97ad7e83afa78 100644
--- a/packages/pg-meta/src/sql/studio/auth/get-index-statuses.ts
+++ b/packages/pg-meta/src/sql/studio/auth/get-index-statuses.ts
@@ -1,4 +1,4 @@
-import { literal } from '../../../pg-format'
+import { joinSqlFragments, literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const USER_SEARCH_INDEXES = [
'idx_users_email',
@@ -10,11 +10,12 @@ export const USER_SEARCH_INDEXES = [
'users_phone_key',
]
-export const getIndexStatusesSQL = () => {
- return `SELECT c.relname as index_name, i.indisvalid as is_valid, i.indisready as is_ready
+export const getIndexStatusesSQL = (): SafeSqlFragment => {
+ const indexNames = joinSqlFragments(USER_SEARCH_INDEXES.map(literal), ', ')
+ return safeSql`SELECT c.relname as index_name, i.indisvalid as is_valid, i.indisready as is_ready
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'auth'
- AND c.relname IN (${USER_SEARCH_INDEXES.map(literal).join(', ')});`
+ AND c.relname IN (${indexNames});`
}
diff --git a/packages/pg-meta/src/sql/studio/auth/get-index-worker-status.ts b/packages/pg-meta/src/sql/studio/auth/get-index-worker-status.ts
index 8917fa5f849a4..43244c9127ad2 100644
--- a/packages/pg-meta/src/sql/studio/auth/get-index-worker-status.ts
+++ b/packages/pg-meta/src/sql/studio/auth/get-index-worker-status.ts
@@ -1,11 +1,13 @@
+import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
+
// Checks pg_locks to determine if the index worker advisory lock is currently held
const INDEX_WORKER_ADVISORY_LOCK_KEY = 'auth_index_worker'
-export const getIndexWorkerStatusSQL = () => {
- return `SELECT EXISTS (
+export const getIndexWorkerStatusSQL = (): SafeSqlFragment => {
+ return safeSql`SELECT EXISTS (
SELECT 1 FROM pg_locks
WHERE locktype = 'advisory'
- AND (classid::bigint << 32 | objid::bigint) = hashtext('${INDEX_WORKER_ADVISORY_LOCK_KEY}')::bigint
+ AND (classid::bigint << 32 | objid::bigint) = hashtext(${literal(INDEX_WORKER_ADVISORY_LOCK_KEY)})::bigint
) as is_in_progress;`
}
diff --git a/packages/pg-meta/src/sql/studio/auth/get-user.ts b/packages/pg-meta/src/sql/studio/auth/get-user.ts
index cbb03efd0bb2b..9527cce33d5f0 100644
--- a/packages/pg-meta/src/sql/studio/auth/get-user.ts
+++ b/packages/pg-meta/src/sql/studio/auth/get-user.ts
@@ -1,5 +1,7 @@
-export const getUserSQL = (userId: string) => {
- const sql = /* SQL */ `
+import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
+
+export const getUserSQL = (userId: string): SafeSqlFragment => {
+ return safeSql`
select
auth.users.id,
auth.users.email,
@@ -28,8 +30,5 @@ select
) as providers
from
auth.users
-where id = '${userId}';
-`.trim()
-
- return sql
+where id = ${literal(userId)};`
}
diff --git a/packages/pg-meta/src/sql/studio/database/check-tables-anon-authenticated-access.ts b/packages/pg-meta/src/sql/studio/database/check-tables-anon-authenticated-access.ts
index 67b80967b0907..23f4fc1036424 100644
--- a/pac
… (truncated)https://github.com/supabase/supabase/commit/b1531545fbb4247d37e538e8bc1dbf6bf3df3871
Dates from discovery through public reveal.
- 2026-04-15 Patch released
- 2026-04-16 Reported to tracker
- 2026-05-04 Sent to maintainer
- 2026-05-04 Maintainer acknowledged
- 2026-08-17 Publicly revealed
SHA-3-512 hash:
714bebf647e515eb911a0607d39e68f5f25150127bd9e7c5e844c5aabb7497ab89fe247902321044b1f89ee23c0755f545c9aa993fad87d02579ec29f42f366d
Committed 2026-05-17 20:24 PT
Revealed 2026-08-17 17:36 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-MEXHXGNE",
"bug_class": "SQL Injection",
"claude_severity": "high",
"commit_sha": null,
"created_at": "2026-04-16T14:01:52+00:00",
"description": "Supabase Studio reads the `?schema=` URL parameter via nuqs `parseAsString` with no validation and passes it to `useIndexesQuery`, which auto-fires on component mount. The query builder at `indexes.ts:20` interpolates this value raw into `WHERE n.nspname = '${schema}'` and the resulting string is POSTed to the write-capable `/pg-meta/{ref}/query` endpoint using the victim's session token. An attacker who is not a project member can send an authenticated victim a crafted `/project/<ref>/database/indexes?schema=<payload>` link; merely opening it executes attacker-chosen SQL (DROP SCHEMA, CREATE ROLE, secret exfiltration) against the victim's Postgres. The tainted schema is also persisted to localStorage, causing the payload to re-execute on subsequent visits to schema-aware pages.",
"discovered_at": "2026-04-02T00:00:00+00:00",
"location": "packages/pg-meta/src/sql/studio/database/indexes.ts:20",
"poc_sha256": null,
"preimage_version": 1,
"project": "supabase/supabase",
"reproduction": [
"1. Attacker crafts URL: https://supabase.com/dashboard/project/<ref>/database/indexes?schema=public%27%3B%20DROP%20SCHEMA%20public%20CASCADE%3B--",
"2. Victim (authenticated) opens the link; `useSchemaQueryState` reads `?schema=` unvalidated",
"3. `Indexes.tsx` mounts and `useIndexesQuery` auto-fires with the tainted schema",
"4. `getIndexesSQL` concatenates the payload into raw SQL; `executeSql` POSTs it to `/platform/pg-meta/{ref}/query` with the victim's access token",
"5. Payload executes in victim's Postgres; tainted schema is persisted to localStorage and re-runs on later visits"
],
"technical_details": "The root cause is raw string interpolation of a user-controlled URL parameter into SQL: `WHERE n.nspname = '${schema}'` uses neither the existing `ident()` nor `literal()` escaping helpers (unlike sibling builders such as `database/misc.ts:15`). Because react-query is `enabled` as soon as `projectRef` and `schema` are defined, the injected SQL executes automatically on page load with no further user interaction, and the backend `/query` endpoint accepts arbitrary SQL with no read-only restriction.",
"title": "Link-driven SQL injection via schema URL parameter",
"vendor_severity": null
}