ANT-2026-66X46XSC · supabase/supabase

other low

Severity Claude low · Security research firm - · Maintainer -

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis of this finding, sealed at approval.

ANT-2026-66X46XSC: X-Content-Type-Options header set to invalid value

The Next.js headers configuration for the Supabase Studio dashboard sets X-Content-Type-Options to 'no-sniff'. Per the Fetch Standard the only valid value is 'nosniff' (no hyphen), so browsers ignore the header entirely and the intended MIME-sniffing protection is never applied. In self-hosted mode the CSP is minimal (only frame-ancestors), so this removes one of the few content-type confusion defenses. This is a defense-in-depth gap rather than a directly exploitable flaw, but it would allow MIME-sniffing-based XSS if a separate content-reflection vector were found.

Target

Project: supabase/supabase
Location: apps/studio/next.config.ts:520
Discovery: static analysis — not yet dynamically reproduced

Technical Details

The header value contains a typo: 'no-sniff' instead of 'nosniff'. Because the Fetch Standard recognizes only the exact token 'nosniff', any other value is ignored and the browser falls back to default MIME-sniffing behavior, defeating the developer's clear intent.

Reproduction

  1. Identify an endpoint on the Studio origin that reflects or serves attacker-controlled bytes with a weak/missing Content-Type (e.g., proxied download or uploaded attachment).
  2. Deliver content containing embedded script via that endpoint.
  3. Victim's browser, lacking a valid nosniff directive, MIME-sniffs the response as text/html and executes the script in the Studio origin.

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

Suggested Fix

Change the X-Content-Type-Options header value from 'no-sniff' to the spec-defined 'nosniff' so browsers enforce declared content types.

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-66X46XSC.


Reference: ANT-2026-66X46XSC
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

UPSTREAM FIX

The change that resolved this finding.

diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md
index 955909985900e..728290838b28c 100644
--- a/apps/studio/TANSTACK_MIGRATION.md
+++ b/apps/studio/TANSTACK_MIGRATION.md
@@ -494,6 +494,80 @@ Keep this plugin even after migration — it's not a Next-related shim,
 it's general protection against this entire class of bug. Just clear
 the allowlist when the underlying cycle is gone.
 
+### `@sentry/nextjs` → `@sentry/react` alias
+
+`resolve.alias` in `vite.config.ts` rewrites the bare `@sentry/nextjs`
+import to `compat/sentry-nextjs.ts`, which re-exports `@sentry/react`
+(the same-version package `@sentry/nextjs` wraps on the client) plus
+explicit stand-ins for the Next-only APIs (`captureRouterTransitionStart`,
+`captureRequestError`, `withSentryConfig`).
+
+Why: `@sentry/nextjs`'s client entry imports
+`next/dist/shared/lib/constants`, whose module scope evaluates
+`...(process?.features?.typescript ? ['next.config.mts'] : [])`.
+Optional chaining does **not** guard an undeclared `process` identifier,
+so every built client chunk containing it (table editor was the canary)
+threw `ReferenceError: process is not defined` at module load. Dev was
+unaffected (dev pipeline shims `process`), so it only surfaced in the
+production/test build.
+
+The alias also made the previous `@sentry/nextjs` SSR workarounds
+(`ssr.noExternal` entry + `ssr.optimizeDeps.include`) obsolete — the id
+is rewritten before SSR resolution, and `@sentry/react` ships real ESM.
+App source keeps importing `@sentry/nextjs` so the Next build
+(`build:next`) is untouched; drop the alias + shim together with the
+Next build when the migration is done (switch imports to
+`@sentry/react` directly).
+
+### GraphiQL Monaco workers: `setup-workers/webpack` → `setup-workers/vite`
+
+App source (`GraphiQLTab.tsx`) imports `graphiql/setup-workers/webpack`,
+which registers `MonacoEnvironment.getWorker` using
+`new Worker(new URL('monaco-editor/...', import.meta.url))` — the URL form
+webpack/turbopack rewrites at build time. Vite doesn't rewrite bare module
+specifiers inside `new URL(..., import.meta.url)`, so under the TanStack
+build the worker URLs 404'd and Monaco fell back to running the `json`,
+`editorWorkerService` and `graphql` workers on the main thread ("Could not
+create web worker(s). Falling back to loading web worker code in main
+thread" in the console).
+
+The `graphiqlViteWorkers` plugin in `vite.config.ts` resolves that import
+to graphiql's own `setup-workers/vite` variant (same three workers via
+Vite `?worker` imports) in client builds; SSR resolution is untouched. The
+import specifier stays `.../webpack` in app source so the Next build keeps
+working. The whole setup-workers chain is also in `optimizeDeps.exclude` —
+the Rolldown dep optimizer can't load `?worker` ids
+(`UNLOADABLE_DEPENDENCY`), so the modules go through the normal transform
+pipeline where Vite's built-in worker plugin handles them. Drop the plugin
+and the exclude, and switch the import to `graphiql/setup-workers/vite`,
+when the Next build goes away.
+
+### Raw-text imports: `*.md` + `public/deno/*.d.ts` (`rawTextLoader`)
+
+Next's raw-loader rules (next.config.ts `turbopack.rules`) serve `*.md`
+files and the Deno typings `public/deno/edge-runtime.d.ts` /
+`public/deno/lib.deno.d.ts` as JS modules whose default export is the
+file's text. The `rawTextLoader` plugin in `vite.config.ts` mirrors that
+for the Vite pipeline:
+
+- `*.md` — plain `transform` (used by
+  `static-data/integrations/*/overview.md` via the literal-import registry
+  in `static-data/integrations/overviews.ts`).
+- The two Deno `.d.ts` files (used by `components/ui/AIEditor` as Monaco
+  extra libs for edge-function editors) — an exact-specifier allowlist
+  resolved to `\0`-virtual ids and served from a `load` hook. They can't go
+  through `transform`: Rolldown's native dep scanner skips JS plugin hooks
+  and hard-fails parsing TS _declaration_ syntax (`get stdin(): ...;`) as
+  runtime TS, which killed dependency pre-bundling wholesale. The previous
+  `/* @vite-ignore */` hack kept the scanner away but also meant the
+  imports failed at runtime, silently dropping Deno type hints in the
+  TanStack build. Do NOT widen the allowlist to `*.d.ts` — hijacking
+  declaration-file resolution globally would corrupt packages that ship
+  `.d.ts` next to their JS. The `as string` casts on the import specifiers
+  in `AIEditor/index.tsx` keep tsc from resolving the `.d.ts` files as
+  declaration files (TS2846) while erasing to plain literals both bundlers
+  statically analyze.
+
 ### Other build-side migration changes
 
 - `pnpm-workspace.yaml` catalog now includes `@tanstack/react-router`,
diff --git a/apps/studio/compat/next/link.tsx b/apps/studio/compat/next/link.tsx
index c662e623466af..cbb68e892ce87 100644
--- a/apps/studio/compat/next/link.tsx
+++ b/apps/studio/compat/next/link.tsx
@@ -11,6 +11,8 @@ import {
   type Ref,
 } from 'react'
 
+import { splitInternalUrl } from '@/lib/internal-url'
+
 // Next's Link accepts either a string `href` or a `UrlObject`
 // ({pathname, query, hash}). Workspace source does both — flatten
 // `UrlObject` into `pathname?search#hash` first so the TanStack `to`
@@ -62,92 +64,14 @@ function resolveHref(href: string | UrlObject): string {
   return `${pathname}${search}${hash}`
 }
 
-// Inlined at build time via Vite's `define`. Must agree with Vite `base`
-// and `tanstackStart({ router: { basepath } })`. Empty string when no
-// basePath is configured. Used to strip a duplicate prefix in
-// `splitInternalUrl` below — see the comment there.
-const NEXT_PUBLIC_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
-
 // TanStack Link's `to` prop is a route-pattern path; query params and hash
-// must be passed separately via `search` / `hash`. Studio code (and Next's
-// own contract) routinely passes one of three href shapes:
-//   1. a relative path like `/project/abc/editor/123?schema=public`
-//   2. a same-origin absolute URL produced by `new URL(...).toString()`,
-//      e.g. `http://localhost:8082/project/abc/editor/123?schema=public`
-//      (this is what `buildTableEditorUrl` does)
-//   3. a genuinely external URL like `https://supabase.com/docs`.
-//
-// If we forward any of these straight through to TanStack as `to`, TanStack
-// either fails to match a known route pattern (#1 with query) or treats
-// the whole thing as external (#2) and falls back to native browser
-// navigation — which the user sees as a full page reload.
-//
-// Split into three parts: pathname, search, hash. Same-origin absolute
-// URLs are normalised to a relative path. Cross-origin URLs are left
-// alone so TanStack's external-link path handles them.
-//
-// basePath quirk: TanStack's `to` is **basepath-relative** — given
-// `basepath: '/dashboard'` and `to: '/foo'`, TanStack builds the href
-// `/dashboard/foo`. Next's contract treats `href` as the **full path
-// from app root including basePath**, and studio code routinely
-// pre-prefixes BASE_PATH (e.g. `buildTableEditorUrl` calls
-// `new URL(`${BASE_PATH}/project/.../editor/...`, location.origin)`).
-// Forwarding the BASE_PATH-prefixed pathname as `to` makes TanStack
-// double-prefix it (`/dashboard/dashboard/project/...`). Strip the
-// basePath when we see it, so what we hand TanStack is always
-// basepath-relative.
-function splitInternalUrl(url: string): {
-  to: string
-  search?: Record<string, string>
-  hash?: string
-} {
-  // Try to detect cross-origin absolute URLs cheaply before paying for a
-  // full parse. Protocol-relative URLs (`//host/...`) are always external.
-  if (url.startsWith('//')) {
-    return { to: url }
-  }
-
-  // Use the document origin as the parse base so relative inputs resolve.
-  // SSR has no `location`; fall back to a placeholder host that won't ever
-  // collide with a real one.
-  const base =
-    typeof window !== 'undefined' && window.location ? window.location.origin : 'http://_/'
-
-  let parsed: URL
-  try {
-    parsed = new URL(url, base)
-  } catch {
-    return { to: url }
-  }
-
-  // Cross-origin → leave for TanStack to handle as external.
- 
… (truncated)

https://github.com/supabase/supabase/commit/18431efb258b0a8be98dfa7abdab4abb3e0899fd

TIMELINE

Dates from discovery through public reveal.

  1. 2026-05-14 Reported to tracker
  2. 2026-05-14 Maintainer acknowledged
  3. 2026-05-15 Sent to maintainer
  4. 2026-07-07 Patch released
  5. 2026-08-18 Publicly revealed
PROVENANCE

SHA-3-512 hash:

9524cd58959ec400250eeb15be404cabba6063cc1dc1c61120e7c78dab88321169f2ae5568e13a5728c3ca4a5e4eb07ca77467c1a2ffcef82ed5abec4f18e32c

Committed 2026-05-17 17:54 PT

Revealed 2026-08-18 07:12 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-66X46XSC",
  "bug_class": "other",
  "claude_severity": "low",
  "commit_sha": null,
  "created_at": "2026-05-14T22:04:19+00:00",
  "description": "The Next.js headers configuration for the Supabase Studio dashboard sets X-Content-Type-Options to 'no-sniff'. Per the Fetch Standard the only valid value is 'nosniff' (no hyphen), so browsers ignore the header entirely and the intended MIME-sniffing protection is never applied. In self-hosted mode the CSP is minimal (only frame-ancestors), so this removes one of the few content-type confusion defenses. This is a defense-in-depth gap rather than a directly exploitable flaw, but it would allow MIME-sniffing-based XSS if a separate content-reflection vector were found.",
  "discovered_at": "2026-05-10T00:00:00+00:00",
  "location": "apps/studio/next.config.ts:520",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "supabase/supabase",
  "reproduction": [
    "1. Identify an endpoint on the Studio origin that reflects or serves attacker-controlled bytes with a weak/missing Content-Type (e.g., proxied download or uploaded attachment).",
    "2. Deliver content containing embedded script via that endpoint.",
    "3. Victim's browser, lacking a valid nosniff directive, MIME-sniffs the response as text/html and executes the script in the Studio origin."
  ],
  "technical_details": "The header value contains a typo: 'no-sniff' instead of 'nosniff'. Because the Fetch Standard recognizes only the exact token 'nosniff', any other value is ignored and the browser falls back to default MIME-sniffing behavior, defeating the developer's clear intent.",
  "title": "X-Content-Type-Options header set to invalid value",
  "vendor_severity": null
}