# 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) ```json "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 1. Create a widget at [Cloudflare Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile). 2. Set **Site key** → `Security:Turnstile:SiteKey`. 3. Set **Secret key** → `Security:Turnstile:SecretKey`. 4. Set `RequireCaptchaOnPublicWrites: true`. 5. QR guest UI loads `GET /api/public/security-config` and shows the widget when `captchaRequired` is true. **Client bodies** must include `captchaToken` on: - `POST /api/public/{cafeId}/branches/{branchId}/orders` - `POST /api/public/cafes/{slug}/orders` - `POST /api/public/cafes/{slug}/reservations` - `POST /api/public/cafes/{slug}/reviews` --- ## 5. Real client IP (reverse proxy) Rate limits use `ClientIpResolver`: 1. `X-Forwarded-For` (first hop) 2. `X-Real-IP` 3. `CF-Connecting-IP` 4. Connection remote address **Arvan / nginx** must forward: ```nginx 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) 1. **OTP flood** — Lower `Auth:MaxOtpAttemptsPerHour` and `AuthOtpPerIpPerHour`; enable Arvan rule on `send-otp`. 2. **QR order spam** — Lower `GuestOrders.*`; enable Turnstile; suspend café in admin if targeted. 3. **Scrape / discover** — Tighten `PublicReadsPerIpPerMinute`; WAF rate limit on `/api/public/discover`. 4. **False positives** — Temporarily `Security:Enabled: false` only on a **staging** slot, not production. Never log: phone numbers, national IDs, payment tokens, Turnstile secrets. --- ## 9. Verification ```bash # 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 issues - `docs/MEEZI_FEATURE_ROADMAP_PLAN.md` — Phase 0 quality items