Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e577165cf |
@@ -38,6 +38,7 @@ services:
|
|||||||
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`)
|
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`)
|
||||||
- traefik.http.routers.anwarpi.entrypoints=websecure
|
- traefik.http.routers.anwarpi.entrypoints=websecure
|
||||||
- traefik.http.routers.anwarpi.tls.certresolver=mytlschallenge
|
- 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.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.rule=PathPrefix(`/api`) && (Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`))
|
||||||
- traefik.http.routers.anwarpi-api.entrypoints=websecure
|
- traefik.http.routers.anwarpi-api.entrypoints=websecure
|
||||||
@@ -49,6 +50,12 @@ services:
|
|||||||
- traefik.http.middlewares.apilimit.ratelimit.average=90
|
- traefik.http.middlewares.apilimit.ratelimit.average=90
|
||||||
- traefik.http.middlewares.apilimit.ratelimit.burst=180
|
- traefik.http.middlewares.apilimit.ratelimit.burst=180
|
||||||
- traefik.http.middlewares.apilimit.ratelimit.period=1m
|
- 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
|
- traefik.docker.network=proxy
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const nextConfig = {
|
|||||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' },
|
{ 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'
|
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||||
|
|
||||||
const isAdmin = (args: AccessArgs) =>
|
const isAdmin = (args: AccessArgs) =>
|
||||||
@@ -42,7 +44,17 @@ const Users: CollectionConfig = {
|
|||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
})
|
})
|
||||||
const user = docs[0] as { id: number; role?: string } | undefined
|
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({
|
await req.payload.db.updateOne({
|
||||||
collection: 'users',
|
collection: 'users',
|
||||||
id: user.id,
|
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