apply security fix
Build & Push / Build & Push Docker Image (push) Failing after 3m46s

This commit is contained in:
root
2026-07-19 22:22:33 -04:00
parent ec03e783e5
commit a010ad6811
41 changed files with 626 additions and 224 deletions
+88
View File
@@ -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()
}
+11
View File
@@ -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
+17 -10
View File
@@ -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)
}
+84 -27
View File
@@ -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