ZefiraZEFIRA Docs Changelog Donate GitHub ↗

Security

How Zefira defends itself — and how to keep it that way.

Built-in defenses

LayerImplementation
Passwordsscrypt hashing (N=2^14), 10+ chars with letters + digits enforced on change
SessionsJWT in HttpOnly + SameSite=Strict cookies, Secure behind HTTPS, versioned (logout / password change kills all sessions)
Brute force8 tries / 15 min per IP+user, plus a loose global per-user bucket (lockout-DoS resistant); identical 401s + dummy-hash timing for unknown users
CSRFMutating /api calls require X-Requested-With: XMLHttpRequest (Bearer tokens exempt: they cannot be sent cross-origin without a preflight the panel never passes)
HeadersCSP, frame-ancestors 'none', X-Frame-Options DENY, nosniff, no-referrer, CORP same-origin, HSTS on HTTPS, no Server banner
InputStrict Pydantic schemas everywhere, 1 MiB body cap (64 MiB only for restore) enforced on actual streamed bytes — chunked bodies without Content-Length cannot bypass it
SSRFAI base_url rejects userinfo, metadata hosts, link-local/multicast targets + DNS-rebinding (any resolved IP blocked = refused) and never follows redirects; IPv4-mapped IPv6, 6to4 and Teredo forms are unwrapped first, so a wrapped metadata address cannot receive your AI key; node probes filter link-local/metadata dials; create/patch reject metadata addresses
QuotaSubscriptions with used_gb >= volume_gb 404 like expired/disabled (raw, Clash and dashboard). device_limit is advisory only — shown in panel + Clash comment, never blocks
TokensAPI tokens are full (default) or bot least-privilege (GET /api/me, GET /api/stats, GET /api/users, username lookup, POST /api/users, GET /api/templates, QR, developer resets). Bot tokens can create and renew users including start_on_first_use (server-computed expiry, bot-safe) but can never delete/patch, backup/restore, or manage settings/tokens
Restore uploadThe body is buffered before the route authenticates, so anonymous uploads are cut off first: Content-Length is mandatory (chunked → 411), a per-source budget and one global slot bound it to a single in-flight restore, and a 60 s deadline stops a slow trickle from holding memory.
RestoreAdmin hashes must be scrypt with N>=2^14 (weak/forged hashes skipped); zero-volume users skipped; trusted_proxies and AI URL re-validated strictly on import; token scopes preserved
SecretsBot tokens, tunnel tokens and REALITY private keys encrypted with a host-local master key. ZEFIRA_ADMIN_PASSWORD is one-time: scrubbed from .env on first boot
Not encryptedCustomer VPN credentials in the database (secret_data) are plain JSON — the panel rebuilds links from them without a decrypt round-trip, so they are also in plain form in an unencrypted backup. Treat the DB and unencrypted backups as customer credentials: keep instance/ at 700, prefer encrypted backups ({"encrypt": true}) or full-disk encryption.
Sensitive exportsBackup download is POST-only with password confirm (no GET, so no CSRF-able download); {"encrypt": true} returns a scrypt + Fernet blob restorable via POST /api/restore-encrypted; access logs omit subscription tokens
ServiceNon-root systemd (User=zefira, ProtectSystem=strict, UMask=0077); updater fetches only mrlurix/zefira-panel@main, installs exactly the advertised commit SHA (a force-push mid-update is refused), reports that commit's GitHub signature state and can refuse unsigned commits with ZEFIRA_REQUIRE_SIGNED_UPDATE=1 , fixed service name, ZEFIRA_ALLOW_UPDATE=0 kill-switch
Proxiestrusted_proxies refuses 0.0.0.0/0, multicast and overly-broad ranges at input and ignores them at runtime (XFF spoofing = rate-limit bypass)
VisibilityAudit log (last 2000 events), Telegram brute-force alerts (spam-throttled)

Penetration-test suite

security_test.py attacks a live panel: auth boundary, JWT forgery (alg=none, tampering, bad signature), rate limits, CSRF, mass assignment, backup/restore atomicity, fuzzing, XSS sources, header checks — currently 117/117 passing:

.venv\Scripts\python security_test.py http://127.0.0.1:8000 admin YOUR_PASSWORD
Restore/atomicity tests wipe users. The suite refuses to run against a panel with users unless you pass --allow-live — back up first.

Operator checklist