fix production issues
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 51s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped

This commit is contained in:
root
2026-08-12 16:48:41 -04:00
parent 53de25120a
commit 8fc88ffc14
117 changed files with 4717 additions and 1443 deletions
+16 -21
View File
@@ -2,7 +2,7 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { Request } from 'express'
import { verifyAnyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { sharedRateLimitStore } from './redisRateLimitStore'
const SESSION_COOKIE_NAMES = [
getSessionCookieName('admin'),
@@ -50,15 +50,18 @@ function getAuthenticatedActorKey(req: Request): string | null {
}
}
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
const skipPreflightRequest = (req: Request) => req.method === 'OPTIONS'
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
// attempts count toward the cap.
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
function withStore(options: Parameters<typeof rateLimit>[0]) {
return rateLimit({
...options,
store: sharedRateLimitStore,
})
}
export const authLimiter = withStore({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: 'draft-7',
legacyHeaders: false,
@@ -68,9 +71,8 @@ export const authLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
})
// Standard limiter for general authenticated API endpoints
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
export const apiLimiter = withStore({
windowMs: 60 * 1000,
max: 300,
standardHeaders: 'draft-7',
legacyHeaders: false,
@@ -85,8 +87,7 @@ export const apiLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Limiter for public carplace and site endpoints (no auth)
export const publicLimiter = rateLimit({
export const publicLimiter = withStore({
windowMs: 60 * 1000,
max: 60,
standardHeaders: 'draft-7',
@@ -96,9 +97,7 @@ 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({
export const webhookLimiter = withStore({
windowMs: 60 * 1000,
max: 30,
standardHeaders: 'draft-7',
@@ -108,8 +107,7 @@ export const webhookLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
})
// Tight limiter for admin endpoints
export const adminLimiter = rateLimit({
export const adminLimiter = withStore({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-7',
@@ -122,10 +120,7 @@ export const adminLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
// Applied after authentication so limits can include actor identity rather than
// pretending every employee behind the same NAT is the same organism.
export const actorLimiter = rateLimit({
export const actorLimiter = withStore({
windowMs: 60 * 1000,
max: 240,
standardHeaders: 'draft-7',
@@ -0,0 +1,80 @@
import type { Store, Options, ClientRateLimitInfo, IncrementResponse } from 'express-rate-limit'
import { redis } from '../lib/redis'
/**
* Redis-backed store for express-rate-limit.
* Uses memory fallback only when explicitly requested (tests) via RATE_LIMIT_STORE=memory.
*/
export class RedisRateLimitStore implements Store {
prefix: string
windowMs = 60_000
#local = new Map<string, { totalHits: number; resetTime: Date }>()
constructor(prefix = 'rl:') {
this.prefix = prefix
}
init(options: Options): void {
this.windowMs = options.windowMs
}
private useMemory() {
return process.env.RATE_LIMIT_STORE === 'memory' || process.env.NODE_ENV === 'test'
}
async get(key: string): Promise<ClientRateLimitInfo | undefined> {
if (this.useMemory()) {
const hit = this.#local.get(key)
if (!hit) return undefined
return { totalHits: hit.totalHits, resetTime: hit.resetTime }
}
const redisKey = `${this.prefix}${key}`
const [count, ttl] = await Promise.all([redis.get(redisKey), redis.pttl(redisKey)])
if (count == null) return undefined
const resetTime = ttl > 0 ? new Date(Date.now() + ttl) : new Date(Date.now() + this.windowMs)
return { totalHits: Number(count), resetTime }
}
async increment(key: string): Promise<IncrementResponse> {
if (this.useMemory()) {
const now = Date.now()
const existing = this.#local.get(key)
if (!existing || existing.resetTime.getTime() <= now) {
const resetTime = new Date(now + this.windowMs)
this.#local.set(key, { totalHits: 1, resetTime })
return { totalHits: 1, resetTime }
}
existing.totalHits += 1
return { totalHits: existing.totalHits, resetTime: existing.resetTime }
}
const redisKey = `${this.prefix}${key}`
const totalHits = await redis.incr(redisKey)
if (totalHits === 1) await redis.pexpire(redisKey, this.windowMs)
const ttl = await redis.pttl(redisKey)
const resetTime = new Date(Date.now() + (ttl > 0 ? ttl : this.windowMs))
return { totalHits, resetTime }
}
async decrement(key: string): Promise<void> {
if (this.useMemory()) {
const existing = this.#local.get(key)
if (existing && existing.totalHits > 0) existing.totalHits -= 1
return
}
const redisKey = `${this.prefix}${key}`
const value = await redis.decr(redisKey)
if (value < 0) await redis.set(redisKey, '0', 'KEEPTTL')
}
async resetKey(key: string): Promise<void> {
if (this.useMemory()) {
this.#local.delete(key)
return
}
await redis.del(`${this.prefix}${key}`)
}
}
export const sharedRateLimitStore = new RedisRateLimitStore('rl:api:')
@@ -165,7 +165,21 @@ describe('requireAdminRole middleware', () => {
})
describe('requireFreshAdmin2FA middleware', () => {
it('allows a 2FA-verified admin session until the session ends', () => {
it('allows a recently 2FA-verified admin session', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 5 * 60 * 1000,
} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose 2FA proof is stale', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 24 * 60 * 60 * 1000,
@@ -175,8 +189,13 @@ describe('requireFreshAdmin2FA middleware', () => {
requireFreshAdmin2FA(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'fresh_2fa_required',
message: 'Admin 2FA verification has expired; verify again to continue',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose session has no 2FA verification proof', () => {
@@ -86,6 +86,15 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
}
const maxAgeMs = Number(process.env.ADMIN_FRESH_2FA_MAX_AGE_MS ?? 30 * 60 * 1000)
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA freshness policy is misconfigured')
}
if (Date.now() - req.adminAuthLast2faAt > maxAgeMs) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification has expired; verify again to continue')
}
next()
}