# 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
