Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ export const getDomainMapping = async (domain) => {
return domainsMappings?.[domainName.toLowerCase()] ?? null
}

// checks if the given Origin header value matches an allowed origin
// e.g. main domain or a verified custom domain
export const isAllowedOrigin = async (origin) => {
if (!origin) return false

let originUrl
try {
originUrl = new URL(origin)
} catch {
return false
}

const originHost = originUrl.host
if (originHost === SN_MAIN_DOMAIN.host) return true

const mapping = await getDomainMapping(originHost)
return !!mapping

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Port stripping allows unintended origins through CORS check

Low Severity

isAllowedOrigin passes originUrl.host (which includes non-standard ports, e.g. verified-domain.com:8443) to getDomainMapping, which internally calls normalizeDomain that strips the port by splitting on :. This means any non-standard port on a verified custom domain (e.g. https://verified-domain.com:9999) would pass the CORS origin check, even though it's a distinct origin from the verified verified-domain.com.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c14d954. Configure here.

@Soxasora Soxasora Apr 30, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The custom domain must set a CNAME pointed directly to us, can't have port numbers. Now yes, at the moment there is no DNS check cronjob, but custom domains won't be public until it gets implemented.

}

export function createDomainsDebugLogger (domainName, debug = CUSTOM_DOMAINS_DEBUG) {
const noop = () => {}

Expand Down
31 changes: 30 additions & 1 deletion pages/api/graphql.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/dis
import PgBoss from 'pg-boss'
import { lexicalStateLoader } from '@/lib/lexical/server/loader'
import { createUserLoader, createSubLoader } from '@/api/loaders'
import { isAllowedOrigin } from '@/lib/domains'

const apolloServer = new ApolloServer({
typeDefs,
Expand Down Expand Up @@ -99,9 +100,37 @@ const apolloHandler = startServerAndCreateNextHandler(apolloServer, {
}
})

// CORS allowlist for /api/graphql: only the main domain and any
// ACTIVE custom domain mapping may make cross-origin calls.
async function applyGraphqlCORS (req, res) {
// vary on Origin so shared caches never serve a response intended for
// a different (or no) origin
res.setHeader('Vary', 'Origin')

const origin = req.headers.origin
if (!origin) return

const isAllowed = await isAllowedOrigin(origin)
if (!isAllowed) return

res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key, Authorization')
res.setHeader('Access-Control-Max-Age', '86400')
}

// Reject GET requests with non-standard Content-Type headers (e.g. message/*)
// to prevent cross-site timing attacks that bypass CORS preflight checks.
export default function protectedContentTypeHandler (req, res) {
export default async function protectedContentTypeHandler (req, res) {
await applyGraphqlCORS(req, res)

// short-circuit preflights so that if the origin was not allowlisted above, the
// response simply lacks the CORS headers and the browser will reject it
if (req.method === 'OPTIONS') {
return res.status(204).end()
}

// Check raw headers so duplicate Content-Type headers can't hide an invalid value.
const invalidGetContentType = req.method === 'GET' &&
req.rawHeaders.some((name, i, headers) =>
Expand Down
Loading