From 8e577165cf82a11c7e188042b26bb014c3b0179f Mon Sep 17 00:00:00 2001 From: AHMED Date: Fri, 9 Oct 2026 17:00:15 +0300 Subject: [PATCH] Security fixes from pentest 2026-10-09 (F1, F2, F4, F7) - F2: per-IP rate limits on POST /api/users, /login, /forgot-password via new Next middleware (5/h signup, 10/15min login, 5/h forgot); 429 + Retry-After, generic Arabic error, no UX change - F7: ghost-email logins burn the same PBKDF2 cost (600k/32/sha256) as real verifies, closing response-time user enumeration - F4: HSTS max-age=63072000; includeSubDomains on all routes - F1: Traefik pagelimit (60/min, burst 120) + connlimit (in-flight 50) on the page router; apilimit untouched - Pentester accounts (ids 42-48) deleted in prod DB --- web/docker-compose.vps.yml | 7 ++++ web/next.config.mjs | 1 + web/src/collections/Users.ts | 14 +++++++- web/src/middleware.ts | 63 ++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 web/src/middleware.ts diff --git a/web/docker-compose.vps.yml b/web/docker-compose.vps.yml index 81bd0e5..7830e04 100644 --- a/web/docker-compose.vps.yml +++ b/web/docker-compose.vps.yml @@ -38,6 +38,7 @@ services: - traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`) - traefik.http.routers.anwarpi.entrypoints=websecure - traefik.http.routers.anwarpi.tls.certresolver=mytlschallenge + - traefik.http.routers.anwarpi.middlewares=pagelimit@docker,connlimit@docker - traefik.http.services.anwarpi.loadbalancer.server.port=3000 - traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && (Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`)) - traefik.http.routers.anwarpi-api.entrypoints=websecure @@ -49,6 +50,12 @@ services: - traefik.http.middlewares.apilimit.ratelimit.average=90 - traefik.http.middlewares.apilimit.ratelimit.burst=180 - traefik.http.middlewares.apilimit.ratelimit.period=1m + # F1: the pentest flood froze pages while /api held. Cap per-IP page + # rate and in-flight requests; apilimit already guards /api itself. + - traefik.http.middlewares.pagelimit.ratelimit.average=60 + - traefik.http.middlewares.pagelimit.ratelimit.period=1m + - traefik.http.middlewares.pagelimit.ratelimit.burst=120 + - traefik.http.middlewares.connlimit.inflightreq.amount=50 - traefik.docker.network=proxy depends_on: db: diff --git a/web/next.config.mjs b/web/next.config.mjs index 66d6bfe..a246e9e 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -12,6 +12,7 @@ const nextConfig = { { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' }, + { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' }, ], }] }, diff --git a/web/src/collections/Users.ts b/web/src/collections/Users.ts index 8b4d7d9..b086ee3 100644 --- a/web/src/collections/Users.ts +++ b/web/src/collections/Users.ts @@ -1,3 +1,5 @@ +import { pbkdf2 } from 'crypto' +import { promisify } from 'util' import type { AccessArgs, CollectionConfig } from 'payload' const isAdmin = (args: AccessArgs) => @@ -42,7 +44,17 @@ const Users: CollectionConfig = { overrideAccess: true, }) const user = docs[0] as { id: number; role?: string } | undefined - if (user?.role !== 'admin') return + if (!user) { + // F7: with no matching user, Payload would skip hashing and fail + // fast, letting attackers enumerate emails via response time. Burn + // the same PBKDF2 cost as a real verify (600000 iters / 32 bytes / + // sha256 — same params as payload's generatePasswordSaltHash), then + // fall through to the standard invalid-credentials error. + const password = typeof args?.data?.password === 'string' ? args.data.password : 'x' + await promisify(pbkdf2)(password, 'anwarpy-dummy-salt-v1', 600000, 32, 'sha256') + return + } + if (user.role !== 'admin') return await req.payload.db.updateOne({ collection: 'users', id: user.id, diff --git a/web/src/middleware.ts b/web/src/middleware.ts new file mode 100644 index 0000000..07cb65d --- /dev/null +++ b/web/src/middleware.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +// F2 (pentest 2026-10-09): throttle anonymous auth abuse. Tuned generous so +// real users never trip it — no CAPTCHA, no UX change (owner constraint). +// +// In-memory buckets are correct for the single-container VPS deployment. +// If the app ever scales horizontally, this must move to a shared store +// (e.g. Redis) or each replica gets its own counter. +const LIMITS: Record = { + 'POST /api/users': { limit: 5, windowMs: 60 * 60 * 1000 }, // signup: 5/hour + 'POST /api/users/login': { limit: 10, windowMs: 15 * 60 * 1000 }, // login: 10/15min + 'POST /api/users/forgot-password': { limit: 5, windowMs: 60 * 60 * 1000 }, // 5/hour +} + +type Bucket = { count: number; windowStart: number } +const buckets = new Map() + +// Traefik sets x-forwarded-for; first hop is the real client. If Cloudflare is +// added later it stays the client (CF appends, not prepends). +function clientIp(req: NextRequest): string { + const xff = req.headers.get('x-forwarded-for') + const first = xff?.split(',')[0]?.trim() + return first || 'unknown' +} + +export function middleware(req: NextRequest) { + const route = `POST ${req.nextUrl.pathname}` + const conf = LIMITS[route] + // GET/DELETE/PATCH and non-throttled paths pass through untouched. + if (!conf) return NextResponse.next() + + const now = Date.now() + const prefix = `${route}|` + // Prune expired entries for this route to bound memory. + for (const [k, v] of buckets) { + if (k.startsWith(prefix) && now - v.windowStart >= conf.windowMs) buckets.delete(k) + } + + const key = `${prefix}${clientIp(req)}` + const bucket = buckets.get(key) + if (!bucket || now - bucket.windowStart >= conf.windowMs) { + buckets.set(key, { count: 1, windowStart: now }) + return NextResponse.next() + } + bucket.count += 1 + if (bucket.count <= conf.limit) return NextResponse.next() + + const retryAfterSec = Math.max( + 1, + Math.ceil((bucket.windowStart + conf.windowMs - now) / 1000), + ) + // Message matches the generic Arabic error the auth pages already show on + // non-ok responses. + return NextResponse.json( + { error: 'محاولات كثيرة جدًا. حاول بعد قليل.' }, + { status: 429, headers: { 'Retry-After': String(retryAfterSec) } }, + ) +} + +export const config = { + matcher: ['/api/users', '/api/users/:path*'], +}