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
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:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user