Compare commits
2
Commits
5ee706ebd6
...
e69e419721
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e69e419721 | ||
|
|
8e577165cf |
@@ -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:
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
}]
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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*'],
|
||||
}
|
||||
Reference in New Issue
Block a user