03376b3ea1
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>
178 lines
5.6 KiB
Markdown
178 lines
5.6 KiB
Markdown
# 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
|