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()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
export const SPOOFABLE_FORWARDING_HEADERS = [
|
||||
'x-forwarded-for',
|
||||
'x-forwarded-host',
|
||||
'x-forwarded-proto',
|
||||
'x-real-ip',
|
||||
'forwarded',
|
||||
'cf-connecting-ip',
|
||||
'true-client-ip',
|
||||
'x-client-ip',
|
||||
]
|
||||
|
||||
/**
|
||||
* Drop client-supplied forwarding headers unless the deployment explicitly
|
||||
* states that the immediate proxy has already scrubbed and reset them.
|
||||
*/
|
||||
export function sanitizeForwardedHeaders(req: Request, _res: Response, next: NextFunction) {
|
||||
if (process.env.TRUSTED_FORWARD_HEADERS === 'true') return next()
|
||||
|
||||
for (const header of SPOOFABLE_FORWARDING_HEADERS) {
|
||||
delete req.headers[header]
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -92,6 +92,17 @@ export const publicLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Dedicated limiter for public payment/subscription webhooks. Provider retries
|
||||
// still fit under this limit, but spray-and-pray signature attempts do not.
|
||||
export const webhookLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Tight limiter for admin endpoints
|
||||
export const adminLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('requireAdminRole middleware', () => {
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks admins below the required rank', () => {
|
||||
it('blocks admins without the explicit required admin role', () => {
|
||||
const req = { admin: { role: 'VIEWER' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
@@ -133,13 +133,26 @@ describe('requireAdminRole middleware', () => {
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'forbidden',
|
||||
message: 'This action requires the FINANCE role or higher',
|
||||
message: 'This action requires explicit FINANCE permission',
|
||||
statusCode: 403,
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows admins at or above the required rank', () => {
|
||||
|
||||
|
||||
it('does not allow SUPPORT to access FINANCE-only routes', () => {
|
||||
const req = { admin: { role: 'SUPPORT' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAdminRole('FINANCE' as any)(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows admins explicitly permitted for the required admin role', () => {
|
||||
const req = { admin: { role: 'ADMIN' } } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
@@ -5,12 +5,12 @@ import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers'
|
||||
import { verifyActorToken } from '../security/tokens'
|
||||
import { getSessionCookieName } from '../security/sessionCookies'
|
||||
|
||||
const ROLE_RANK: Record<AdminRole, number> = {
|
||||
SUPER_ADMIN: 5,
|
||||
ADMIN: 4,
|
||||
SUPPORT: 3,
|
||||
FINANCE: 2,
|
||||
VIEWER: 1,
|
||||
const ADMIN_ROLE_ALLOWLIST: Record<AdminRole, readonly AdminRole[]> = {
|
||||
SUPER_ADMIN: ['SUPER_ADMIN'],
|
||||
ADMIN: ['SUPER_ADMIN', 'ADMIN'],
|
||||
SUPPORT: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT'],
|
||||
FINANCE: ['SUPER_ADMIN', 'ADMIN', 'FINANCE'],
|
||||
VIEWER: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'],
|
||||
}
|
||||
|
||||
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
|
||||
@@ -67,11 +67,10 @@ export function requireAdminRole(minimumRole: AdminRole) {
|
||||
const admin = req.admin
|
||||
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
|
||||
const rank = ROLE_RANK[admin.role] ?? 0
|
||||
const required = ROLE_RANK[minimumRole] ?? 99
|
||||
const allowedRoles = ADMIN_ROLE_ALLOWLIST[minimumRole] ?? []
|
||||
|
||||
if (rank < required) {
|
||||
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
|
||||
if (!allowedRoles.includes(admin.role)) {
|
||||
return sendForbidden(res, 'forbidden', `This action requires explicit ${minimumRole} permission`)
|
||||
}
|
||||
|
||||
next()
|
||||
@@ -92,3 +91,11 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
|
||||
export function requireFreshAdmin2FAWhenEnabled(req: Request, res: Response, next: NextFunction) {
|
||||
const admin = req.admin
|
||||
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
if (!admin.totpEnabled) return next()
|
||||
return requireFreshAdmin2FA(req, res, next)
|
||||
}
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy'
|
||||
import { getAccessLevel, hasAnyAccess, hasFullAccess, hasWriteAccess } from '../modules/subscriptions/subscription.policy'
|
||||
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
|
||||
|
||||
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
|
||||
const COMPANY_READ_BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
|
||||
const COMPANY_WRITE_BLOCKED_STATUSES = ['SUSPENDED', 'PENDING', 'PAUSED']
|
||||
|
||||
function billingUrl() {
|
||||
return `${process.env.NEXT_PUBLIC_DASHBOARD_URL ?? process.env.DASHBOARD_URL ?? '/dashboard'}/subscription`
|
||||
}
|
||||
|
||||
async function getSubscriptionStatus(companyId: string) {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { companyId },
|
||||
select: { status: true },
|
||||
})
|
||||
return subscription?.status ?? 'EXPIRED'
|
||||
}
|
||||
|
||||
function blockSubscription(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
|
||||
return sendPaymentRequired(res, error, message, { billingUrl: billingUrl(), ...extra })
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks requests for companies with lapsed or unactivated subscriptions.
|
||||
* Must be applied after `requireTenant`.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.company.status is not SUSPENDED or PENDING, and subscription access is not none
|
||||
* Read access: allows healthy read-only states, but blocks companies that should
|
||||
* not have normal application visibility at all.
|
||||
*/
|
||||
export async function requireSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
export async function requireSubscriptionRead(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const company = req.company
|
||||
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
|
||||
|
||||
if (BLOCKED_STATUSES.includes(company.status)) {
|
||||
return sendPaymentRequired(
|
||||
if (COMPANY_READ_BLOCKED_STATUSES.includes(company.status)) {
|
||||
return blockSubscription(
|
||||
res,
|
||||
`subscription_${company.status.toLowerCase()}`,
|
||||
company.status === 'SUSPENDED'
|
||||
? 'Your account has been suspended. Please contact support or renew your subscription.'
|
||||
: 'Your account is pending activation. Please complete your subscription setup.',
|
||||
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` },
|
||||
)
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { companyId: company.id },
|
||||
select: { status: true },
|
||||
})
|
||||
const subscriptionStatus = subscription?.status ?? 'EXPIRED'
|
||||
|
||||
const subscriptionStatus = await getSubscriptionStatus(company.id)
|
||||
if (!hasAnyAccess(subscriptionStatus)) {
|
||||
return sendPaymentRequired(
|
||||
res,
|
||||
'subscription_required',
|
||||
'Your subscription has ended. Please reactivate to continue.',
|
||||
{
|
||||
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`,
|
||||
subscriptionStatus,
|
||||
accessLevel: getAccessLevel(subscriptionStatus),
|
||||
},
|
||||
)
|
||||
return blockSubscription(res, 'subscription_required', 'Your subscription has ended. Please reactivate to continue.', {
|
||||
subscriptionStatus,
|
||||
accessLevel: getAccessLevel(subscriptionStatus),
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
@@ -52,3 +54,58 @@ export async function requireSubscription(req: Request, res: Response, next: Nex
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write access: blocks read-only/limited subscription states from mutating data.
|
||||
*/
|
||||
export async function requireSubscriptionWrite(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const company = req.company
|
||||
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
|
||||
|
||||
if (COMPANY_WRITE_BLOCKED_STATUSES.includes(company.status)) {
|
||||
return blockSubscription(res, `subscription_${company.status.toLowerCase()}`, 'Your current account status does not allow changes.')
|
||||
}
|
||||
|
||||
const subscriptionStatus = await getSubscriptionStatus(company.id)
|
||||
if (!hasWriteAccess(subscriptionStatus)) {
|
||||
return blockSubscription(res, 'subscription_write_required', 'Your subscription is read-only. Reactivate or update billing to make changes.', {
|
||||
subscriptionStatus,
|
||||
accessLevel: getAccessLevel(subscriptionStatus),
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full access: required for booking/payment/billing-sensitive actions.
|
||||
*/
|
||||
export async function requireSubscriptionFull(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const company = req.company
|
||||
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
|
||||
|
||||
if (COMPANY_WRITE_BLOCKED_STATUSES.includes(company.status)) {
|
||||
return blockSubscription(res, `subscription_${company.status.toLowerCase()}`, 'Your current account status does not allow this action.')
|
||||
}
|
||||
|
||||
const subscriptionStatus = await getSubscriptionStatus(company.id)
|
||||
if (!hasFullAccess(subscriptionStatus)) {
|
||||
return blockSubscription(res, 'subscription_full_access_required', 'This action requires an active subscription in good standing.', {
|
||||
subscriptionStatus,
|
||||
accessLevel: getAccessLevel(subscriptionStatus),
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Backward-compatible alias. New routes should choose read/write/full explicitly.
|
||||
export const requireSubscription = requireSubscriptionRead
|
||||
|
||||
Reference in New Issue
Block a user