Files
meezi/docs/SECURITY.md
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

5.6 KiB

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.jsonSecurity.


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)

"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.
  2. Set Site keySecurity:Turnstile:SiteKey.
  3. Set Secret keySecurity: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:

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).


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

# 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).


  • docs/DOCKER.md — build and network issues
  • docs/MEEZI_FEATURE_ROADMAP_PLAN.md — Phase 0 quality items