Files
soroush.asadi 03376b3ea1 feat(docker): multi-stage Dockerfiles with npmmirror registry
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>
2026-05-27 21:33:29 +03:30

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