archetecture security fix

This commit is contained in:
root
2026-06-11 03:22:12 -04:00
parent 6def9993da
commit 9483750161
3126 changed files with 177194 additions and 37211 deletions
+25
View File
@@ -0,0 +1,25 @@
import crypto from 'crypto'
export function generateCompanyApiKey() {
const secret = crypto.randomBytes(32).toString('base64url')
const prefix = `rdg_${crypto.randomBytes(6).toString('hex')}`
const rawKey = `${prefix}_${secret}`
return { rawKey, prefix, keyHash: hashApiKey(rawKey) }
}
export function getApiKeyPrefix(rawKey: string) {
const parts = rawKey.split('_')
if (parts.length < 3) return null
return `${parts[0]}_${parts[1]}`
}
export function hashApiKey(rawKey: string) {
return crypto.createHash('sha256').update(rawKey).digest('hex')
}
export function timingSafeEqualHex(a: string, b: string) {
const left = Buffer.from(a, 'hex')
const right = Buffer.from(b, 'hex')
if (left.length !== right.length) return false
return crypto.timingSafeEqual(left, right)
}
@@ -0,0 +1,12 @@
import type { AdminRole } from '@rentaldrivego/database'
export const AdminPolicy = {
impersonateCompany: ['SUPER_ADMIN'],
manageAdmins: ['SUPER_ADMIN', 'ADMIN'],
managePricingPlans: ['SUPER_ADMIN', 'ADMIN'],
manageSubscriptions: ['SUPER_ADMIN', 'ADMIN', 'FINANCE'],
performRefund: ['SUPER_ADMIN', 'ADMIN', 'FINANCE'],
viewSensitiveCredentials: ['SUPER_ADMIN'],
} as const satisfies Record<string, readonly AdminRole[]>
export type AdminPolicyAction = keyof typeof AdminPolicy
@@ -0,0 +1,14 @@
import type { EmployeeRole } from '@rentaldrivego/database'
export const CompanyPolicy = {
viewApiKey: ['OWNER'],
regenerateApiKey: ['OWNER'],
updateAccountingSettings: ['OWNER', 'MANAGER'],
deletePricingRule: ['OWNER', 'MANAGER'],
managePricingRules: ['OWNER', 'MANAGER'],
manageTeam: ['OWNER', 'MANAGER'],
viewBilling: ['OWNER', 'MANAGER'],
manageBilling: ['OWNER'],
} as const satisfies Record<string, readonly EmployeeRole[]>
export type CompanyPolicyAction = keyof typeof CompanyPolicy
@@ -0,0 +1,17 @@
import crypto from 'crypto'
export function generatePublicAccessToken() {
const token = crypto.randomBytes(32).toString('base64url')
return { token, tokenHash: hashPublicAccessToken(token) }
}
export function hashPublicAccessToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex')
}
export function timingSafeEqualTokenHash(a: string, b: string) {
const left = Buffer.from(a, 'hex')
const right = Buffer.from(b, 'hex')
if (left.length !== right.length) return false
return crypto.timingSafeEqual(left, right)
}
+31
View File
@@ -0,0 +1,31 @@
import type { Response } from 'express'
import type { ActorType } from './tokens'
const COOKIE_NAMES: Record<ActorType, string> = {
admin: 'admin_session',
employee: 'employee_session',
renter: 'renter_session',
}
export function getSessionCookieName(actorType: ActorType) {
return COOKIE_NAMES[actorType]
}
export function setSessionCookie(res: Response, actorType: ActorType, token: string, maxAgeMs?: number) {
res.cookie(getSessionCookieName(actorType), token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: actorType === 'admin' ? 'strict' : 'lax',
path: '/',
maxAge: maxAgeMs,
})
}
export function clearSessionCookie(res: Response, actorType: ActorType) {
res.clearCookie(getSessionCookieName(actorType), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: actorType === 'admin' ? 'strict' : 'lax',
path: '/',
})
}
+67
View File
@@ -0,0 +1,67 @@
import jwt from 'jsonwebtoken'
export type ActorType = 'admin' | 'employee' | 'renter'
export type ActorTokenPayload = jwt.JwtPayload & {
sub: string
type: ActorType
sessionVersion?: number
last2faAt?: number
}
const ISSUER = 'rentaldrivego-api'
const ALG: jwt.Algorithm[] = ['HS256']
function getJwtSecret() {
const secret = process.env.JWT_SECRET
if (!secret) throw new Error('JWT_SECRET is required')
return secret
}
export function signActorToken(
actorId: string,
actorType: ActorType,
options: Pick<jwt.SignOptions, 'expiresIn'> & { sessionVersion?: number; last2faAt?: number } = {},
) {
const { sessionVersion, last2faAt, ...signOptions } = options
return jwt.sign(
{
sub: actorId,
type: actorType,
...(sessionVersion === undefined ? {} : { sessionVersion }),
...(last2faAt === undefined ? {} : { last2faAt }),
},
getJwtSecret(),
{
algorithm: 'HS256',
issuer: ISSUER,
audience: actorType,
expiresIn: signOptions.expiresIn ?? '8h',
},
)
}
export function verifyActorToken(token: string, expectedType: ActorType): ActorTokenPayload {
const payload = jwt.verify(token, getJwtSecret(), {
algorithms: ALG,
issuer: ISSUER,
audience: expectedType,
}) as jwt.JwtPayload
if (typeof payload.sub !== 'string' || payload.type !== expectedType) {
throw new Error('Invalid actor token')
}
return payload as ActorTokenPayload
}
export function verifyAnyActorToken(token: string): ActorTokenPayload {
const decoded = jwt.decode(token) as jwt.JwtPayload | null
const actorType = decoded?.type
if (actorType !== 'admin' && actorType !== 'employee' && actorType !== 'renter') {
throw new Error('Invalid actor token')
}
return verifyActorToken(token, actorType)
}
@@ -0,0 +1,97 @@
import crypto from 'crypto'
type WebhookStatus = 'PROCESSING' | 'PROCESSED' | 'FAILED'
type WebhookProcessInput<T> = {
provider: string
providerEventId: string
eventType: string
rawBody: string | Buffer
handle: () => Promise<T>
}
function payloadHash(rawBody: string | Buffer) {
return crypto.createHash('sha256').update(rawBody).digest('hex')
}
function getWebhookEventModel() {
try {
// Lazy load keeps isolated unit tests from requiring a generated Prisma client
// before `prisma generate` has run in the local sandbox.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { prisma } = require('../lib/prisma')
return (prisma as any).webhookEvent as any | undefined
} catch {
return undefined
}
}
async function findExisting(model: any, provider: string, providerEventId: string) {
return model.findUnique({
where: { provider_providerEventId: { provider, providerEventId } },
})
}
async function mark(model: any, id: string, status: WebhookStatus, errorMessage?: string) {
await model.update({
where: { id },
data: {
status,
processedAt: status === 'PROCESSED' ? new Date() : undefined,
errorMessage: errorMessage?.slice(0, 1000),
},
})
}
export async function processWebhookOnce<T>({
provider,
providerEventId,
eventType,
rawBody,
handle,
}: WebhookProcessInput<T>): Promise<{ duplicate: boolean; result?: T }> {
const model = getWebhookEventModel()
if (!model) {
const result = await handle()
return { duplicate: false, result }
}
const hash = payloadHash(rawBody)
let eventRecord: any
try {
eventRecord = await model.create({
data: {
provider,
providerEventId,
eventType,
status: 'PROCESSING',
payloadHash: hash,
},
})
} catch (err: any) {
const existing = await findExisting(model, provider, providerEventId)
if (existing?.status === 'PROCESSED') return { duplicate: true }
if (existing?.payloadHash && existing.payloadHash !== hash) {
throw new Error('Webhook event ID replayed with a different payload hash')
}
if (existing?.status === 'PROCESSING') return { duplicate: true }
eventRecord = existing
}
try {
const result = await handle()
if (eventRecord?.id) await mark(model, eventRecord.id, 'PROCESSED')
return { duplicate: false, result }
} catch (err: any) {
if (eventRecord?.id) await mark(model, eventRecord.id, 'FAILED', err?.message)
throw err
}
}
export function getWebhookEventId(provider: 'amanpay' | 'paypal', payload: any) {
if (provider === 'paypal') {
return String(payload.id ?? payload.event_id ?? `${payload.event_type ?? 'unknown'}:${payload.resource?.id ?? 'missing'}`)
}
return String(payload.event_id ?? payload.id ?? payload.transaction_id ?? 'missing')
}