This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
||||
const SESSION_COOKIE_PATTERN = /(?:^|;\s*)(?:admin_session|employee_session|renter_session)=/
|
||||
|
||||
function configuredOrigins() {
|
||||
return [
|
||||
process.env.DASHBOARD_URL,
|
||||
process.env.ADMIN_URL,
|
||||
process.env.CARPLACE_URL,
|
||||
process.env.WEBSITE_URL,
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL,
|
||||
process.env.NEXT_PUBLIC_ADMIN_URL,
|
||||
process.env.NEXT_PUBLIC_CARPLACE_URL,
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL,
|
||||
process.env.CORS_ORIGINS,
|
||||
]
|
||||
.flatMap((value) => (value ?? '').split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeOrigin(value: string | undefined): string | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function originFromReferer(value: string | undefined): string | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
return new URL(value).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedDevelopmentOrigin(origin: string) {
|
||||
if (process.env.NODE_ENV === 'production') return false
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
return url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isTrustedBrowserOrigin(origin: string | null) {
|
||||
if (!origin) return false
|
||||
const allowed = new Set(configuredOrigins().map(normalizeOrigin).filter((value): value is string => Boolean(value)))
|
||||
return allowed.has(origin) || isAllowedDevelopmentOrigin(origin)
|
||||
}
|
||||
|
||||
function isCookieAuthenticatedBrowserMutation(req: Request) {
|
||||
if (!MUTATING_METHODS.has(req.method.toUpperCase())) return false
|
||||
const cookie = req.headers.cookie ?? ''
|
||||
if (!SESSION_COOKIE_PATTERN.test(cookie)) return false
|
||||
|
||||
const secFetchSite = Array.isArray(req.headers['sec-fetch-site'])
|
||||
? req.headers['sec-fetch-site'][0]
|
||||
: req.headers['sec-fetch-site']
|
||||
if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'same-site' && secFetchSite !== 'none') return true
|
||||
|
||||
// Browser cookie-authenticated mutations must present Origin. Referer is a
|
||||
// fallback for older clients only; API clients should use Bearer tokens.
|
||||
return true
|
||||
}
|
||||
|
||||
export function requireTrustedOriginForCookieMutations(req: Request, res: Response, next: NextFunction) {
|
||||
if (!isCookieAuthenticatedBrowserMutation(req)) return next()
|
||||
|
||||
const origin = normalizeOrigin(req.headers.origin as string | undefined)
|
||||
?? originFromReferer(req.headers.referer as string | undefined)
|
||||
|
||||
if (!isTrustedBrowserOrigin(origin)) {
|
||||
return res.status(403).json({
|
||||
error: 'csrf_origin_rejected',
|
||||
message: 'Mutating cookie-authenticated requests must come from a trusted application origin.',
|
||||
statusCode: 403,
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
Reference in New Issue
Block a user