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>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user