Server hardening: nginx origin locked to ArvanCloud + CSP, demo logins removed, docs

- ops/nginx/: the two nginx sites as they now run on 249. Only ArvanCloud
  edges reach the app (direct hits on the IP get 403, so Arvan's DDoS/WAF
  can't be skipped); the frontend gets CSP (own sources only, the one inline
  script by hash), X-Frame-Options, nosniff, Referrer-Policy,
  Permissions-Policy, HSTS; API body limit 100M -> 20M plus HSTS. Checked from
  outside and in Chrome (all tabs and an island quiz, logged in): no CSP errors.
- Demo accounts (users 11-15, 25) and TEST_LOGIN_PHONES removed from the server;
  the health warning for fixed-code logins now has a test and says how to
  test instead (an OTPCode made in the admin).
- SMS daily cap on the server is 2000 (launch of ~600 users).
- CLAUDE.md: never run tests with --parallel on macOS, always --noinput
  (both hang forever without an error).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 19:17:11 +03:30
parent 2a03dc81b5
commit ba4ecff29e
9 changed files with 196 additions and 117 deletions

23
ops/nginx/mathapp Normal file
View File

@@ -0,0 +1,23 @@
# روی ۲۴۹: /etc/nginx/sites-enabled/mathapp (فایلِ معمولی، نه symlink) — API، mathapi.tadrisino2.ir.
server {
listen 80;
listen 443 ssl;
server_name mathapi.tadrisino2.ir;
ssl_certificate /etc/ssl/certs/mathapp.crt;
ssl_certificate_key /etc/ssl/private/mathapp.key;
include /etc/nginx/snippets/zareian-arvan-only.conf;
# بزرگ‌ترین آپلودِ واقعی فایلِ سوال/برنامه‌ی درسی در پنلِ ادمین است (~۹ مگابایت). nginx بدنه را پیش از
# رد شدن روی دیسکِ مشترک می‌نویسد، پس سقفِ ۱۰۰ مگابایتیِ قبلی جای پر کردنِ دیسک می‌داد.
client_max_body_size 20M;
# بقیه‌ی هدرهای امنیتی (nosniff، X-Frame-Options، Referrer-Policy) را خودِ جنگو می‌گذارد.
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
include proxy_params;
proxy_pass http://unix:/run/gunicorn/mathapp.sock;
}
}

31
ops/nginx/mathapp-front Normal file
View File

@@ -0,0 +1,31 @@
# روی ۲۴۹: /etc/nginx/sites-available/mathapp-front (sites-enabled یک symlink به آن است) — math.tadrisino2.ir.
server {
listen 80;
listen 443 ssl;
server_name math.tadrisino2.ir;
ssl_certificate /etc/ssl/certs/mathapp.crt;
ssl_certificate_key /etc/ssl/private/mathapp.key;
root /home/administrator/mathapp_frontend_new/dist;
include /etc/nginx/snippets/zareian-arvan-only.conf;
include /etc/nginx/snippets/zareian-front-headers.conf;
# index.html و config.js اسم ثابت دارند: مرورگر باید هر بار از سرور بپرسد (etag → ۳۰۴)،
# وگرنه بعد از دیپلوی نسخه‌ی قدیمی را نشان می‌دهد.
add_header Cache-Control "no-cache" always;
location / {
index index.html;
try_files $uri $uri/ @fallback;
}
# فایل‌های assets/ اسمشان هش دارد و هیچ‌وقت با همان اسم عوض نمی‌شوند.
location /assets/ {
include /etc/nginx/snippets/zareian-front-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location @fallback {
rewrite ^ /index.html break;
}
}

View File

@@ -0,0 +1,22 @@
# روی ۲۴۹: /etc/nginx/snippets/zareian-arvan-only.conf — داخلِ server {}ِ دو سایتِ ما include می‌شود.
# فقط edgeهای ArvanCloud (و خودِ ماشین) به اپ می‌رسند؛ درخواستِ مستقیم به IPِ سرور ۴۰۳ می‌گیرد، پس سپرِ
# DDoS/WAFِ آروان دور زده نمی‌شود. فهرست از https://www.arvancloud.ir/fa/ips.txt (۲۰۲۶-۱۰-۱۳)؛ همان فهرستِ
# TRUSTED_PROXY_NETWORKS در config/settings.py. اگر آروان رنجِ تازه بگیرد و این‌جا نباشد، کاربرانِ پشتِ آن edge
# ۴۰۳ می‌گیرند (در /var/log/nginx/access.log دیده می‌شود): هر دو فهرست را با هم تازه کنید.
allow 127.0.0.1;
allow ::1;
allow 185.143.232.0/22;
allow 188.229.116.16/30;
allow 94.101.182.0/27;
allow 2.144.3.128/28;
allow 37.32.16.0/27;
allow 37.32.17.0/27;
allow 37.32.18.0/27;
allow 37.32.19.0/27;
allow 185.215.232.0/22;
allow 178.131.120.48/28;
allow 94.101.183.0/28;
allow 78.157.36.112/28;
allow 95.38.61.80/28;
allow 193.24.119.0/29;
deny all;

View File

@@ -0,0 +1,17 @@
# روی ۲۴۹: /etc/nginx/snippets/zareian-front-headers.conf — هدرهای امنیتیِ math.tadrisino2.ir.
# در هر locationی که خودش add_header دارد دوباره include شود: nginx هدرهای سطحِ server را در چنین locationی ارث نمی‌دهد.
#
# CSP فقط منابعِ خودمان را می‌پذیرد:
# - script: فایل‌های خودِ سایت، و تنها اسکریپتِ درون‌خطیِ index.html (کلاسِ in-app برای اسپلش) با هشش. اگر آن
# اسکریپت در فرانت عوض شود، هش را از نو بسازید (python: base64(sha256(متنِ بینِ <script> و </script>)))؛
# وگرنه فقط همان اسکریپت اجرا نمی‌شود و کنسول «Refused to execute inline script» می‌گوید.
# - connect: API. با عوض شدنِ دامنه‌ی API (config.js) این‌جا هم عوض شود (docs/operations.md «عوض کردنِ دامنه»).
# - worker blob: کتابخانه‌ی confetti یک Worker از blob می‌سازد.
# - style 'unsafe-inline': <style>ِ index.html و pay.html و استایلِ درون‌خطیِ React.
# HSTS بی includeSubDomains: زیردامنه‌های دیگرِ tadrisino2.ir مالِ تیم‌های دیگرند.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-utBc36Q6M7NacvbHQCaMGCgm3osZ627xGQGm/tnSEXM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://mathapi.tadrisino2.ir; worker-src 'self' blob:; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Strict-Transport-Security "max-age=31536000" always;