ANT-2026-MEXHXGNE · supabase/supabase

sql-injection high

Severity Claude high · Security research firm - · Maintainer -

Discovered by Claude Mythos Preview

REPORT

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

  1. Attacker crafts URL: https://supabase.com/dashboard/project//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

[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

UPSTREAM FIX

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

TIMELINE

Dates from discovery through public reveal.

  1. 2026-04-15 Patch released
  2. 2026-04-16 Reported to tracker
  3. 2026-05-04 Sent to maintainer
  4. 2026-05-04 Maintainer acknowledged
  5. 2026-08-17 Publicly revealed
PROVENANCE

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
}