ANT-2026-SXD0NN2P · 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-SXD0NN2P: Link-driven SQL injection via table-editor filter ARRAY bypass

The Supabase Studio Table Editor reads filter values from the ?filter= URL query parameter and passes them unsanitized through formatFilterURLParams → useTableRowsQuery → getTableRowsSql into the pg-meta Query builder. In filterLiteral() (Query.utils.ts:279), any value matching ARRAY[...] is returned raw instead of being escaped with literal(), and is concatenated directly into the WHERE clause. Because useTableRowsQuery auto-fires on page mount and executes via /platform/pg-meta/{ref}/query with the dashboard's elevated service-role DB connection, an attacker-crafted link auto-executes arbitrary multi-statement SQL (role creation, DDL, data exfil) the moment an authenticated victim opens it. The only friction is knowing the project ref and a low-entropy table OID.

Target

Project: supabase/supabase
Location: packages/pg-meta/src/query/Query.utils.ts:279
Discovery: static analysis — not yet dynamically reproduced

Technical Details

filterLiteral() special-cases strings that start with ARRAY[ and end with ], returning them verbatim rather than passing them through pg-format's literal() escaper; applyFilters() then concatenates this raw string into the WHERE clause at Query.utils.ts:197. Since the filter value originates unvalidated from a URL query parameter and the resulting SQL is POSTed to pg-meta's multi-statement query endpoint as the Studio service role, an attacker fully controls SQL executed against the victim's database. The preflight EXPLAIN check swallows errors and cannot block multi-statement payloads, and wrapWithRoleImpersonation is a no-op by default.

Reproduction

  1. Craft URL: /dashboard/project//editor/?filter=id:eq:ARRAY[1]; CREATE ROLE pwn LOGIN SUPERUSER PASSWORD 'x';--] (URL-encoded).
  2. Victim loads page; useTableEditorFiltersSort reads ?filter= verbatim and formatFilterURLParams parses column:op:value without validating value.
  3. SupabaseGrid mounts and useTableRowsQuery auto-fires, calling getTableRowsSql with the attacker value.
  4. filterLiteral() sees ARRAY[ ... ] and returns the raw string, which applyFilters() concatenates into the WHERE clause.
  5. If needed, payload closes the with _base_query as (...) CTE paren, injects statements, and re-opens a matching CTE so trailing ORDER BY/LIMIT remain valid; single-expression subquery injection inside ARRAY[...] also works.
  6. Resulting SQL is POSTed to /platform/pg-meta/{ref}/query via executeSql and runs as the Studio service role against the victim's database.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Suggested Fix

Never interpolate filter values raw: remove the ARRAY[...] passthrough or rebuild the array from individually literal()-escaped elements, and treat all URL-sourced filter values as untrusted regardless of shape.

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-SXD0NN2P.


Reference: ANT-2026-SXD0NN2P
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/Auth/Policies/Policies.tsx b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx
index ec1980181e2bc..aef35dcd52596 100644
--- a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx
+++ b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx
@@ -1,4 +1,3 @@
-import type { PostgresPolicy } from '@supabase/postgres-meta'
 import { useParams } from 'common'
 import { isEmpty } from 'lodash'
 import Link from 'next/link'
@@ -11,6 +10,7 @@ import {
   PolicyTableRow,
   PolicyTableRowProps,
 } from '@/components/interfaces/Auth/Policies/PolicyTableRow'
+import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
 import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
 import { NoSearchResults } from '@/components/ui/NoSearchResults'
 import { useDatabasePolicyDeleteMutation } from '@/data/database-policies/database-policy-delete-mutation'
@@ -25,7 +25,7 @@ interface PoliciesProps {
   isLocked: boolean
   visibleTableIds: Set<number>
   onSelectCreatePolicy: (table: string) => void
-  onSelectEditPolicy: (policy: PostgresPolicy) => void
+  onSelectEditPolicy: (policy: Policy) => void
   onResetSearch?: () => void
 }
 
@@ -83,13 +83,13 @@ export const Policies = ({
   )
 
   const onSelectEditPolicy = useCallback(
-    (policy: PostgresPolicy) => {
+    (policy: Policy) => {
       onSelectEditPolicyAI(policy)
     },
     [onSelectEditPolicyAI]
   )
 
-  const onSelectDeletePolicy = useCallback((policy: PostgresPolicy) => {
+  const onSelectDeletePolicy = useCallback((policy: Policy) => {
     setSelectedPolicyToDelete(policy)
   }, [])
 
diff --git a/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx b/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx
index de190298c81a9..15d083b5935ab 100644
--- a/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx
+++ b/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx
@@ -1,13 +1,13 @@
-import type { PostgresPolicy } from '@supabase/postgres-meta'
 import type { PropsWithChildren } from 'react'
 import { createContext, useCallback, useContext, useMemo } from 'react'
 
+import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
 import type { ResponseError } from '@/types'
 
 type TableKey = `${string}.${string}`
 
 type PoliciesDataContextValue = {
-  getPoliciesForTable: (schema: string, table: string) => PostgresPolicy[]
+  getPoliciesForTable: (schema: string, table: string) => Array<Policy>
   isPoliciesLoading: boolean
   isPoliciesError: boolean
   policiesError?: ResponseError | Error
@@ -23,7 +23,7 @@ export const usePoliciesData = () => {
 }
 
 type PoliciesDataProviderProps = {
-  policies: PostgresPolicy[]
+  policies: Array<Policy>
   isPoliciesLoading: boolean
   isPoliciesError: boolean
   policiesError?: ResponseError | Error
@@ -39,7 +39,7 @@ export const PoliciesDataProvider = ({
   exposedSchemas,
 }: PropsWithChildren<PoliciesDataProviderProps>) => {
   const policiesByTable = useMemo(() => {
-    const map = new Map<TableKey, PostgresPolicy[]>()
+    const map = new Map<TableKey, Array<Policy>>()
 
     for (const policy of policies) {
       const key = `${policy.schema}.${policy.table}` satisfies TableKey
diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx
index c25aa78bd859b..d37b04f4a0254 100644
--- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx
+++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx
@@ -1,4 +1,3 @@
-import type { PostgresPolicy } from '@supabase/postgres-meta'
 import { PermissionAction } from '@supabase/shared-types/out/constants'
 import { noop } from 'lodash'
 import { Edit, MoreVertical, Trash } from 'lucide-react'
@@ -17,6 +16,7 @@ import {
 } from 'ui'
 
 import { generatePolicyUpdateSQL } from './PolicyTableRow.utils'
+import type { Policy } from './PolicyTableRow.utils'
 import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
 import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
 import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
@@ -26,9 +26,9 @@ import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
 import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
 
 interface PolicyRowProps {
-  policy: PostgresPolicy
-  onSelectEditPolicy: (policy: PostgresPolicy) => void
-  onSelectDeletePolicy: (policy: PostgresPolicy) => void
+  policy: Policy
+  onSelectEditPolicy: (policy: Policy) => void
+  onSelectDeletePolicy: (policy: Policy) => void
   isLocked?: boolean
 }
 
diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts
index 4c58e9ed8638b..d563e081ea36c 100644
--- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts
+++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts
@@ -1,7 +1,13 @@
+import { ident, joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta'
 import { PostgresPolicy } from '@supabase/postgres-meta'
 
 import type { TableApiAccessData } from '@/data/privileges/table-api-access-query'
 
+export type Policy = Omit<PostgresPolicy, 'definition' | 'check'> & {
+  definition: SafeSqlFragment | null
+  check: SafeSqlFragment | null
+}
+
 /**
  * Single classifier for the RLS page's per-table admonition state. Shares the
  * "granted / custom / revoked" grant semantics used by the Data API settings
@@ -61,21 +67,22 @@ export function getTableAdmonitionMessage(status: TableDataApiStatus): string |
   }
 }
 
-export const generatePolicyUpdateSQL = (policy: PostgresPolicy) => {
-  let expression = ''
-  if (policy.definition !== null && policy.definition !== undefined) {
-    expression += `using (${policy.definition})${
-      policy.check === null || policy.check === undefined ? ';' : ''
-    }\n`
+export const generatePolicyUpdateSQL = (policy: Policy): SafeSqlFragment => {
+  const parts: Array<SafeSqlFragment> = []
+
+  if (policy.definition != null) {
+    const semicolon = policy.check == null ? safeSql`;` : safeSql``
+    parts.push(safeSql`using (${policy.definition})${semicolon}`)
   }
-  if (policy.check !== null && policy.check !== undefined) {
-    expression += `with check (${policy.check});\n`
+  if (policy.check != null) {
+    parts.push(safeSql`with check (${policy.check});`)
   }
 
-  return `
-alter policy "${policy.name}" 
-on "${policy.schema}"."${policy.table}"
-to ${policy.roles.join(', ')}
-${expression}
-`.trim()
+  const expression = parts.length > 0 ? joinSqlFragments(parts, '\n') : safeSql``
+
+  return safeSql`
+alter policy ${ident(policy.name)}
+on ${ident(policy.schema)}.${ident(policy.table)}
+to ${joinSqlFragments(policy.roles.map(ident), ', ')}
+${expression}`
 }
diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx
index 63e1327dee73a..05c2d9df9c77d 100644
--- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx
+++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx
@@ -1,4 +1,3 @@
-import type { PostgresPolicy } from '@supabase/postgres-meta'
 import { useParams } from 'common'
 import { noop } from 'lodash'
 import { memo, useMemo } from 'react'
@@ -19,6 +18,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
 import { usePoliciesData } from '../PoliciesDataContext'
 import { PolicyRow } from './PolicyRow'
 import type { PolicyTable } from './PolicyTableRow.types'
+import type { Policy } from './PolicyTableRow.utils'
 import { getTableAdmonitionMessage, getTableDataApiStatus } from './PolicyTableRow.utils'
 import { PolicyTableRowHeader } from '
… (truncated)

https://github.com/supabase/supabase/commit/0433eeb5f5cc6c41b80c85793a3b7be1fc976119

TIMELINE

Dates from discovery through public reveal.

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

SHA-3-512 hash:

c40c474cfcbbbab8f5ae70d58dbbfdaa7a56d0c40867513d7d8ad2c0df7f53661a6d6902da699e7c326a73d7b51aab8bf4cabb73b82b05977e05d63a16e1c67a

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-SXD0NN2P",
  "bug_class": "SQL Injection",
  "claude_severity": "high",
  "commit_sha": null,
  "created_at": "2026-04-16T14:01:51+00:00",
  "description": "The Supabase Studio Table Editor reads filter values from the `?filter=` URL query parameter and passes them unsanitized through formatFilterURLParams → useTableRowsQuery → getTableRowsSql into the pg-meta Query builder. In filterLiteral() (Query.utils.ts:279), any value matching `ARRAY[`...`]` is returned raw instead of being escaped with literal(), and is concatenated directly into the WHERE clause. Because useTableRowsQuery auto-fires on page mount and executes via /platform/pg-meta/{ref}/query with the dashboard's elevated service-role DB connection, an attacker-crafted link auto-executes arbitrary multi-statement SQL (role creation, DDL, data exfil) the moment an authenticated victim opens it. The only friction is knowing the project ref and a low-entropy table OID.",
  "discovered_at": "2026-04-02T00:00:00+00:00",
  "location": "packages/pg-meta/src/query/Query.utils.ts:279",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "supabase/supabase",
  "reproduction": [
    "1. Craft URL: /dashboard/project/<ref>/editor/<tableId>?filter=id:eq:ARRAY[1]; CREATE ROLE pwn LOGIN SUPERUSER PASSWORD 'x';--] (URL-encoded).",
    "2. Victim loads page; useTableEditorFiltersSort reads ?filter= verbatim and formatFilterURLParams parses column:op:value without validating value.",
    "3. SupabaseGrid mounts and useTableRowsQuery auto-fires, calling getTableRowsSql with the attacker value.",
    "4. filterLiteral() sees ARRAY[ ... ] and returns the raw string, which applyFilters() concatenates into the WHERE clause.",
    "5. If needed, payload closes the `with _base_query as (...)` CTE paren, injects statements, and re-opens a matching CTE so trailing ORDER BY/LIMIT remain valid; single-expression subquery injection inside ARRAY[...] also works.",
    "6. Resulting SQL is POSTed to /platform/pg-meta/{ref}/query via executeSql and runs as the Studio service role against the victim's database."
  ],
  "technical_details": "filterLiteral() special-cases strings that start with `ARRAY[` and end with `]`, returning them verbatim rather than passing them through pg-format's literal() escaper; applyFilters() then concatenates this raw string into the WHERE clause at Query.utils.ts:197. Since the filter value originates unvalidated from a URL query parameter and the resulting SQL is POSTed to pg-meta's multi-statement query endpoint as the Studio service role, an attacker fully controls SQL executed against the victim's database. The preflight EXPLAIN check swallows errors and cannot block multi-statement payloads, and wrapWithRoleImpersonation is a no-op by default.",
  "title": "Link-driven SQL injection via table-editor filter ARRAY bypass",
  "vendor_severity": null
}