Merge pull request 'Security fixes from pentest 2026-10-09: auth rate limits, timing equalization, HSTS, edge DoS caps' (#1) from security/pentest-fixes-2026-10-09 into main
Deploy GitHub Pages / deploy (push) Canceled after 0s

This commit was merged in pull request #1.
This commit is contained in:
2026-10-09 17:01:00 +03:00
4 changed files with 84 additions and 1 deletions
+7
View File
@@ -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:
+1
View File
@@ -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' },
],
}]
},
+13 -1
View File
@@ -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,
+63
View File
@@ -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<string, { limit: number; windowMs: number }> = {
'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<string, Bucket>()
// 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*'],
}