Rewrites dashboard and finder Dockerfiles to use a clean multi-stage build (deps → builder → runner) that installs npm packages inside Alpine Linux, avoiding the SWC musl binary issue when building from Windows host. Uses registry.npmmirror.com for reliable installs from restricted networks (Iran). - docker/api/Dockerfile: .NET 10 multi-stage build - docker/web/Dockerfile: Node 20-alpine multi-stage, npmmirror - docker/finder/Dockerfile: Node 20-alpine multi-stage, npmmirror - docker/website/Dockerfile: marketing website build - scripts/: PowerShell helper scripts for local dev Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
5.6 KiB
Meezi — Security & abuse protection (operations)
This document describes how to protect public café-facing endpoints (QR menu, discover, OTP, reviews) in development and production. Application code lives under src/Meezi.API/Security/ and appsettings.json → Security.
1. Defense layers (use all in production)
| Layer | Where | Purpose |
|---|---|---|
| CDN / WAF | Arvan Cloud (recommended) | Block volumetric DDoS, geo rules, bot scores |
| ASP.NET rate limiting | SecurityExtensions policies |
Per-IP caps on reads and OTP |
| Redis sliding windows | AbuseProtectionService |
Guest orders, public writes, OTP by IP |
| Cloudflare Turnstile | Optional CAPTCHA | Bots on order/review/reservation |
| Suspended café | PublicCafeGuard |
Stop traffic to closed/suspended venues |
App-layer limits do not replace edge protection. Configure Arvan in front of api.* and dashboard.*.
2. Configuration (appsettings / env)
"Security": {
"Enabled": true,
"RequireCaptchaOnPublicWrites": false,
"Turnstile": {
"SiteKey": "",
"SecretKey": ""
},
"RateLimits": {
"AuthOtpPerIpPerHour": 15,
"PublicReadsPerIpPerMinute": 120,
"PublicWritesPerIpPerMinute": 30
},
"GuestOrders": {
"PerIpPerCafePerHour": 25,
"PerCafePerHour": 200,
"PerIpGlobalPerHour": 60
}
}
| Env override | Example |
|---|---|
Security__Turnstile__SiteKey |
Turnstile site key |
Security__Turnstile__SecretKey |
Turnstile secret |
Security__RequireCaptchaOnPublicWrites |
true in production |
Security__Enabled |
false only in isolated local debug |
Also: Auth:MaxOtpAttemptsPerHour (per phone, Redis key otp:attempts:{phone}).
3. Rate limit reference
ASP.NET policies ([EnableRateLimiting])
| Policy | Routes | Default limit |
|---|---|---|
public-read |
GET /api/public/*, GET /api/q/* |
120 / IP / minute |
auth-otp |
POST /api/auth/send-otp |
15 / IP / hour |
Response: HTTP 429, body { success: false, error: { code: "RATE_LIMITED", ... } }.
Redis (AbuseProtectionService)
| Key pattern | Limit | Window |
|---|---|---|
abuse:otp-ip:{ip} |
15 | 1 hour |
abuse:pub-write:{ip} |
30 | 1 minute |
abuse:qr-ip:{cafeId}:{ip} |
25 | 1 hour |
abuse:qr-cafe:{cafeId} |
200 | 1 hour |
abuse:qr-ip-global:{ip} |
60 | 1 hour |
Public write paths also run Turnstile when RequireCaptchaOnPublicWrites is true and SecretKey is set.
4. Cloudflare Turnstile
- Create a widget at Cloudflare Turnstile.
- Set Site key →
Security:Turnstile:SiteKey. - Set Secret key →
Security:Turnstile:SecretKey. - Set
RequireCaptchaOnPublicWrites: true. - QR guest UI loads
GET /api/public/security-configand shows the widget whencaptchaRequiredis true.
Client bodies must include captchaToken on:
POST /api/public/{cafeId}/branches/{branchId}/ordersPOST /api/public/cafes/{slug}/ordersPOST /api/public/cafes/{slug}/reservationsPOST /api/public/cafes/{slug}/reviews
5. Real client IP (reverse proxy)
Rate limits use ClientIpResolver:
X-Forwarded-For(first hop)X-Real-IPCF-Connecting-IP- Connection remote address
Arvan / nginx must forward:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
Without this, all traffic may appear as one IP (under- or over-limiting).
6. Arvan CDN / WAF (recommended rules)
Apply to the API origin (and dashboard if served separately).
| Rule | Path / condition | Action |
|---|---|---|
| Rate limit | POST /api/auth/send-otp |
e.g. 10 req/min per IP |
| Rate limit | POST /api/public/* |
e.g. 30 req/min per IP |
| Rate limit | GET /api/q/*, GET /api/public/* |
e.g. 300 req/min per IP |
| Challenge / CAPTCHA | High bot score on POST |
Optional if Turnstile not enough |
| Block | Countries not in scope (optional) | Only if product is IR-only |
| Cache | GET public menu/discover |
Short TTL only if responses are anonymous |
Do not cache authenticated GET /api/cafes/{id}/*.
7. Protected public endpoints checklist
| Endpoint | Auth | Limits |
|---|---|---|
GET /api/q/{code} |
None | public-read |
GET /api/public/discover |
None | public-read |
GET /api/public/security-config |
None | public-read |
POST /api/auth/send-otp |
None | auth-otp + Redis IP + phone |
POST .../orders (guest) |
None | public-write + guest order + CAPTCHA |
POST .../reviews |
None | public-write + CAPTCHA |
Café suspended: CAFE_SUSPENDED (403) on public writes.
8. Incident response (short)
- OTP flood — Lower
Auth:MaxOtpAttemptsPerHourandAuthOtpPerIpPerHour; enable Arvan rule onsend-otp. - QR order spam — Lower
GuestOrders.*; enable Turnstile; suspend café in admin if targeted. - Scrape / discover — Tighten
PublicReadsPerIpPerMinute; WAF rate limit on/api/public/discover. - False positives — Temporarily
Security:Enabled: falseonly on a staging slot, not production.
Never log: phone numbers, national IDs, payment tokens, Turnstile secrets.
9. Verification
# Load script (local)
k6 run tests/load/public-abuse.js
# E2E smoke
cd web/dashboard && npm run test:e2e
Expect 429 / RATE_LIMITED when exceeding limits (see tests/load/README.md).
10. Related docs
docs/DOCKER.md— build and network issuesdocs/MEEZI_FEATURE_ROADMAP_PLAN.md— Phase 0 quality items