Files
supabase/apps/studio/lib/api/edgeFunctions.ts
Matt Johnston 04f4cc6c1c fix(studio): correct edge function URL validation for additional project domains
`isValidEdgeFunctionURL` guards the server-side fetch in
`pages/api/edge-functions/test.ts`. When `NIMBUS_PROD_PROJECTS_URL` is set it
built a regex by string concatenation, which had several problems:

- The branch returned early, so a deployment configured with an additional
  projects domain rejected every `*.supabase.co` / `*.supabase.red` URL, and
  rejected self-hosted URLs even when `isPlatform` was false.
- The guard was `!== undefined`, so an env var declared-but-blank took the
  branch and rejected every URL.
- The subdomain pattern `[a-z]*` matched a single label of letters only, so
  refs containing digits, nested subdomains, and explicit ports all failed.
- Only `.` was escaped before interpolation into the regex, so a value without
  the exact `https://*.` prefix or with a trailing slash silently produced a
  pattern that matched nothing.
- Reading `process.env` at module scope made the branch untestable, and it had
  no test coverage.

Parse the URL instead of pattern-matching it, and treat the additional domain
as additive to the default hosts rather than a replacement.

The default `[a-z]{20}.supabase.(co|red)` host check is deliberately unchanged.
This function doubles as an SSRF allowlist for a fetch that carries the
caller's credentials, so the default allowlist is left exactly as it was.
2026-08-25 19:45:17 -03:00

86 lines
3.0 KiB
TypeScript

import { IS_PLATFORM } from '@/lib/constants'
// Cron jobs and database hooks run inside Postgres, where Kong is available by this network alias.
const SELF_HOSTED_EDGE_FUNCTIONS_URL = 'http://kong:8000/functions/v1'
const PLATFORM_TLDS = ['co', 'red'] as const
export const buildDatabaseEdgeFunctionUrl = (
slug: string,
projectRef: string,
restUrl?: string,
isPlatform = IS_PLATFORM
) => {
if (!isPlatform) return `${SELF_HOSTED_EDGE_FUNCTIONS_URL}/${slug}`
const projectOrigin = restUrl ? new URL(restUrl).origin : `https://${projectRef}.supabase.co`
return `${projectOrigin}/functions/v1/${slug}`
}
export const isEdgeFunctionUrl = (
url: string,
projectRef: string,
restUrl?: string,
isPlatform = IS_PLATFORM
) => {
if (!isPlatform && url.startsWith(`${SELF_HOSTED_EDGE_FUNCTIONS_URL}/`)) return true
const projectOrigin = restUrl ? new URL(restUrl).origin : undefined
if (projectOrigin && url.startsWith(`${projectOrigin}/functions/v1/`)) return true
return PLATFORM_TLDS.some(
(tld) =>
url.startsWith(`https://${projectRef}.functions.supabase.${tld}/`) ||
url.startsWith(`https://${projectRef}.supabase.${tld}/functions/`)
)
}
/**
* Normalises `NIMBUS_PROD_PROJECTS_URL` (e.g. `https://*.example.com`) down to its apex domain.
* Returns null when unset, blank, or whitespace-only so callers fall through to the default hosts.
* Read at call time rather than module scope so the value is stubbable in tests.
*/
const getAdditionalProjectsApexDomain = () => {
const configured = process.env.NIMBUS_PROD_PROJECTS_URL?.trim()
if (!configured) return null
return (
configured
.replace(/^https?:\/\//, '')
.replace(/^\*\./, '')
.replace(/\/+$/, '')
.toLowerCase() || null
)
}
const isFunctionsPath = (pathname: string) => /^\/functions\/v\d\/.+/.test(pathname)
/**
* Guards the server-side fetch in `pages/api/edge-functions/test.ts`, so this doubles as an
* SSRF allowlist: only hosts that match are fetched with the caller's credentials attached.
*/
export const isValidEdgeFunctionURL = (url: string, isPlatform: boolean) => {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return false
}
// Parse rather than pattern-match the whole URL, so credentials/query smuggling such as
// `https://localhost?https://ref.supabase.co/functions/v1/x` can't satisfy the host check.
if (!isFunctionsPath(parsed.pathname)) return false
if (!isPlatform) return parsed.protocol === 'http:' || parsed.protocol === 'https:'
if (parsed.protocol !== 'https:') return false
const host = parsed.hostname.toLowerCase()
// Additive: a deployment serving additional project domains must still validate the
// default ones, so this is checked alongside PLATFORM_TLDS rather than instead of them.
const additionalApexDomain = getAdditionalProjectsApexDomain()
if (additionalApexDomain && host.endsWith(`.${additionalApexDomain}`)) return true
return PLATFORM_TLDS.some((tld) => new RegExp(`^[a-z]{20}\\.supabase\\.${tld}$`).test(host))
}