Files
supabase__supabase/apps/studio/csp.ts
Alaister Young 67b17c885c fix(studio): allow github.com and vercel.com avatars in img-src CSP (#47885)
The TanStack build renders remote images as plain `<img>` tags — there's
no Next.js image optimizer rewriting them to same-origin `/_next/image`
URLs — so remote avatar origins now hit the CSP directly and were being
blocked (e.g. `Loading the image
'https://github.com/alaister.png?size=96' violates the following Content
Security Policy directive: "img-src 'self' ..."`).

**Changed:**
- Added `https://github.com` to `img-src` — GitHub profile avatars
(`https://github.com/<username>.png`, used by the user dropdown, AI
assistant messages, org invites, and audit logs). The URL 302s to
`avatars.githubusercontent.com`, which is already allowed (CSP validates
both hops of a redirect).
- Moved `https://vercel.com` from the dev/staging-only `img-src` list to
the unconditional list — Vercel integration account avatars
(`https://vercel.com/api/www/avatar/...`) load in prod too.

Audited all other `next/image` usages in studio: everything else is
either a local `${BASE_PATH}/img/...` asset (`'self'`) or a marketplace
image served from `NEXT_PUBLIC_MARKETPLACE_API_URL`, which is already in
`img-src`. The old `remotePatterns` entry for `api-frameworks.vercel.sh`
has no remaining references, so it was deliberately not ported.

## To test

- On the TanStack build, sign in with a GitHub-linked account and check
the user avatar renders in the top-right user dropdown (no CSP violation
in the console)
- Check org audit logs (`/org/_/audit`) render member avatars
- With a Vercel integration installed, check the account avatar renders
in org integration settings
- Sanity-check the Next.js build still renders the same avatars (both
builds share `getCSP()`)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
  * GitHub profile avatar images now display correctly.
* Image loading rules for staging and development environments were
refined to improve content security.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-13 22:20:12 +08:00

249 lines
8.1 KiB
TypeScript

const API_URL = process.env.NEXT_PUBLIC_API_URL
? new URL(process.env.NEXT_PUBLIC_API_URL).origin
: ''
const SUPABASE_URL = process.env.SUPABASE_URL ? new URL(process.env.SUPABASE_URL).origin : ''
const GOTRUE_URL = process.env.NEXT_PUBLIC_GOTRUE_URL
? new URL(process.env.NEXT_PUBLIC_GOTRUE_URL).origin
: ''
const MARKETPLACE_API_URL = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL
? new URL(process.env.NEXT_PUBLIC_MARKETPLACE_API_URL).origin
: ''
const SUPABASE_PROJECTS_URL = 'https://*.supabase.co https://*.storage.supabase.co'
const SUPABASE_PROJECTS_URL_WS = 'wss://*.supabase.co'
// construct the URL for the Websocket Local URLs
let SUPABASE_LOCAL_PROJECTS_URL_WS = ''
if (SUPABASE_URL) {
const url = new URL(SUPABASE_URL)
const wsUrl = `${url.hostname}:${url.port}`
SUPABASE_LOCAL_PROJECTS_URL_WS = `ws://${wsUrl} wss://${wsUrl}`
}
// Needed to test docs search in local dev
const SUPABASE_DOCS_PROJECT_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin
: ''
// Needed to test docs content API in local dev
const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL
? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin
: ''
const isDevOrStaging =
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
const NIMBUS_STAGING_PROJECTS_URL = 'https://*.nmb-proj.com'
const NIMBUS_STAGING_PROJECTS_URL_WS = 'wss://*.nmb-proj.com'
const NIMBUS_PROD_PROJECTS_URL = process.env.NIMBUS_PROD_PROJECTS_URL || ''
const NIMBUS_PROD_PROJECTS_URL_WS = process.env.NIMBUS_PROD_PROJECTS_URL_WS || ''
const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red https://*.storage.supabase.red'
const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red'
const SUPABASE_COM_URL = 'https://supabase.com'
const CLOUDFLARE_CDN_URL = 'https://cdnjs.cloudflare.com'
const HCAPTCHA_SUBDOMAINS_URL = 'https://*.hcaptcha.com'
const HCAPTCHA_ASSET_URL = 'https://newassets.hcaptcha.com'
const HCAPTCHA_JS_URL = 'https://js.hcaptcha.com'
const CONFIGCAT_URL = 'https://cdn-global.configcat.com'
const CONFIGCAT_PROXY_URL = ['staging', 'local'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT ?? '')
? 'https://configcat.supabase.green'
: 'https://configcat.supabase.com'
const STRIPE_SUBDOMAINS_URL = 'https://*.stripe.com'
const STRIPE_JS_URL = 'https://js.stripe.com'
const STRIPE_NETWORK_URL = 'https://*.stripe.network'
const CLOUDFLARE_URL = 'https://www.cloudflare.com'
const VERCEL_URL = 'https://vercel.com'
const VERCEL_INSIGHTS_URL = 'https://*.vercel-insights.com'
const GITHUB_URL = 'https://github.com'
const GITHUB_API_URL = 'https://api.github.com'
const GITHUB_USER_CONTENT_URL = 'https://raw.githubusercontent.com'
const GITHUB_USER_AVATAR_URL = 'https://avatars.githubusercontent.com'
const GOOGLE_USER_AVATAR_URL = 'https://lh3.googleusercontent.com'
// This is a custom domain for Stape, which isused for GTM servers
const STAPE_URL = 'https://ss.supabase.com'
const VERCEL_LIVE_URL = 'https://vercel.live'
const SENTRY_URL =
'https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io'
const SUPABASE_ASSETS_URL =
process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
? 'https://frontend-assets.supabase.green'
: 'https://frontend-assets.supabase.com'
const POSTHOG_URL = isDevOrStaging ? 'https://ph.supabase.green' : 'https://ph.supabase.com'
const USERCENTRICS_URLS = 'https://*.usercentrics.eu'
const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu'
// used by vercel live preview
const PUSHER_URL = 'https://*.pusher.com'
const PUSHER_URL_WS = 'wss://*.pusher.com'
const GOOGLE_MAPS_API_URL = 'https://maps.googleapis.com'
export function getCSP() {
const DEFAULT_SRC_URLS = [
API_URL,
SUPABASE_URL,
GOTRUE_URL,
MARKETPLACE_API_URL,
SUPABASE_LOCAL_PROJECTS_URL_WS,
SUPABASE_PROJECTS_URL,
SUPABASE_PROJECTS_URL_WS,
HCAPTCHA_SUBDOMAINS_URL,
CONFIGCAT_URL,
CONFIGCAT_PROXY_URL,
STRIPE_SUBDOMAINS_URL,
STRIPE_NETWORK_URL,
CLOUDFLARE_URL,
VERCEL_INSIGHTS_URL,
GITHUB_API_URL,
GITHUB_USER_CONTENT_URL,
SUPABASE_ASSETS_URL,
USERCENTRICS_URLS,
STAPE_URL,
GOOGLE_MAPS_API_URL,
POSTHOG_URL,
...(!!NIMBUS_PROD_PROJECTS_URL ? [NIMBUS_PROD_PROJECTS_URL, NIMBUS_PROD_PROJECTS_URL_WS] : []),
CLOUDFLARE_CDN_URL,
]
const SCRIPT_SRC_URLS = [
CLOUDFLARE_CDN_URL,
HCAPTCHA_JS_URL,
STRIPE_JS_URL,
SUPABASE_ASSETS_URL,
STAPE_URL,
POSTHOG_URL,
USERCENTRICS_URLS,
]
const FRAME_SRC_URLS = [
HCAPTCHA_ASSET_URL,
STRIPE_JS_URL,
STAPE_URL,
...(isDevOrStaging ? [POSTHOG_URL] : []),
]
// The TanStack build renders remote images as plain <img> tags (no image
// optimizer proxy), so every remote image origin must be listed here
// explicitly — unlike the Next.js build, where the optimizer serves them
// from same-origin /_next/image URLs covered by 'self'.
const IMG_SRC_URLS = [
SUPABASE_URL,
SUPABASE_COM_URL,
SUPABASE_PROJECTS_URL,
// GitHub profile avatars, e.g. https://github.com/<username>.png
GITHUB_URL,
GITHUB_USER_AVATAR_URL,
GOOGLE_USER_AVATAR_URL,
// Vercel integration account avatars (https://vercel.com/api/www/avatar/...)
VERCEL_URL,
SUPABASE_ASSETS_URL,
USERCENTRICS_APP_URL,
STAPE_URL,
USERCENTRICS_URLS,
MARKETPLACE_API_URL,
...(!!NIMBUS_PROD_PROJECTS_URL ? [NIMBUS_PROD_PROJECTS_URL, NIMBUS_PROD_PROJECTS_URL_WS] : []),
]
const STYLE_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL]
const FONT_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL]
const defaultSrcDirective = [
`default-src 'self'`,
...DEFAULT_SRC_URLS,
...(isDevOrStaging
? [
SUPABASE_STAGING_PROJECTS_URL,
SUPABASE_STAGING_PROJECTS_URL_WS,
NIMBUS_STAGING_PROJECTS_URL,
NIMBUS_STAGING_PROJECTS_URL_WS,
VERCEL_LIVE_URL,
SUPABASE_DOCS_PROJECT_URL,
SUPABASE_CONTENT_API_URL,
]
: []),
PUSHER_URL_WS,
SENTRY_URL,
].join(' ')
const imgSrcDirective = [
`img-src 'self'`,
`blob:`,
`data:`,
...IMG_SRC_URLS,
...(isDevOrStaging ? [SUPABASE_STAGING_PROJECTS_URL, NIMBUS_STAGING_PROJECTS_URL] : []),
].join(' ')
const scriptSrcDirective = [
`script-src 'self'`,
`'unsafe-eval'`,
`'unsafe-inline'`,
...SCRIPT_SRC_URLS,
VERCEL_LIVE_URL,
PUSHER_URL,
GOOGLE_MAPS_API_URL,
].join(' ')
const frameSrcDirective = [`frame-src 'self'`, ...FRAME_SRC_URLS, VERCEL_LIVE_URL].join(' ')
const styleSrcDirective = [
`style-src 'self'`,
`'unsafe-inline'`,
...STYLE_SRC_URLS,
VERCEL_LIVE_URL,
].join(' ')
// `data:` is required — GraphiQL's bundled Monaco ships the codicon icon font
// as a data: URI, and Stripe's payment element inlines its fonts the same way.
const fontSrcDirective = [`font-src 'self'`, `data:`, ...FONT_SRC_URLS, VERCEL_LIVE_URL].join(' ')
const workerSrcDirective = [`worker-src 'self'`, `blob:`, `data:`].join(' ')
const connectSrcDirective = [
`connect-src 'self'`,
`data:`,
`blob:`,
...DEFAULT_SRC_URLS,
...(isDevOrStaging
? [
SUPABASE_STAGING_PROJECTS_URL,
SUPABASE_STAGING_PROJECTS_URL_WS,
NIMBUS_STAGING_PROJECTS_URL,
NIMBUS_STAGING_PROJECTS_URL_WS,
VERCEL_LIVE_URL,
SUPABASE_DOCS_PROJECT_URL,
SUPABASE_CONTENT_API_URL,
]
: []),
PUSHER_URL_WS,
SENTRY_URL,
].join(' ')
const cspDirectives = [
connectSrcDirective,
defaultSrcDirective,
imgSrcDirective,
scriptSrcDirective,
frameSrcDirective,
styleSrcDirective,
fontSrcDirective,
workerSrcDirective,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`block-all-mixed-content`,
...(process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' &&
process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod'
? [`upgrade-insecure-requests`]
: []),
]
const csp = cspDirectives.join('; ') + ';'
// Replace newline characters and spaces
return csp.replace(/\s{2,}/g, ' ').trim()
}