Files
xamxam/docs/security.md
T

5.8 KiB

Security

Current security posture for XAMXAM.

This supersedes the earlier security.md (2026-02-08 audit). The original 16-item audit is closed; the items below reflect the current state.


Authentication — admin

  • PHP session auth: app/src/AdminAuth.php. Password-only (no username). Credentials previously in a gitignored PHP file; the current build stores the bcrypt hash in site_settings.admin_password_hash (manageable from /admin/account).
  • AdminAuth::requireLogin() guards every admin action/route.
  • Session cookies hardened: HttpOnly, SameSite=Strict, Secure, Path=/admin; regenerated on login.
  • Session timeouts (server-side, enforced by AdminAuth::enforceSessionTimeout() on every gated request):
    • Idle timeout — 30 min without activity → session destroyed, redirected to login.
    • Absolute timeout — 12 h since login → forced re-login, regardless of activity.
    • Session ID rotated periodically (every 30 min) to limit fixation/replay.
    • The session cookie lifetime is 7 days, but that is only an upper bound on browser retention — the actual session is bounded by the two timeouts above.
  • PHP-FPM session GC tuning — see below. The app enforces its own timeouts, so session.gc_maxlifetime must be ≥ the 12 h absolute timeout or PHP would reap active sessions early.
  • nginx auth_basic layer has been removed; the PHP session layer is the only gate. (LDAP-based login is a proposed future enhancement — see ldap.md. It is not implemented.)

PHP-FPM session GC configuration

The admin session's lifetime is enforced by the app (the timeouts above), so PHP's own session garbage collector must not reap active sessions before the 12 h absolute timeout. The following is provisioned by scripts/deploy-server.sh (run via just deploy-nginx):

File: /etc/php/8.4/fpm/conf.d/zz-xamxam-session.ini

; XAMXAM session tuning.
; AdminAuth enforces its own idle/absolute timeouts (30 min / 12 h), so
; gc_maxlifetime must be >= the absolute timeout or PHP would reap active
; sessions from under the app.
session.gc_maxlifetime = 43200
session.gc_probability = 1
session.gc_divisor     = 100
  • gc_maxlifetime = 43200 (12 h) — matches ABSOLUTE_TIMEOUT_SECONDS; must stay ≥ the app's absolute timeout.
  • gc_probability = 1 / gc_divisor = 100 — re-enables the GC (default was gc_probability = 0, i.e. disabled, so stale session files were never reaped).
  • Applied on FPM reload (systemctl reload php8.4-fpm), done by the deploy script.

⚠️ Keep these three knobs in sync with the constants in app/src/AdminAuth.php (IDLE_TIMEOUT_SECONDS, ABSOLUTE_TIMEOUT_SECONDS, COOKIE_LIFETIME_SECONDS). If you raise the app's absolute timeout beyond 12 h, raise gc_maxlifetime to match.

Transport & headers

Enforced in nginx/xamxam.conf (see nginx/docs/SECURITY_HEADERS.md):

  • HSTS (Strict-Transport-Security, 730 days, preload)
  • CSP — default-src 'self'; … frame-ancestors 'none' on public pages; frame-ancestors 'self' where the app embeds allowed content; admin CSP includes script-src 'unsafe-inline' for the OverType editor. object-src 'none'.
  • X-Frame-Options: DENY (clickjacking)
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy (geo/mic/camera disabled)
  • Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy: same-origin
  • X-Robots-Tag: noindex, nofollow on /admin/

X-XSS-Protection is intentionally omitted (deprecated; see nginx/docs/SECURITY_HEADERS.md for rationale).

Rate limiting

Defined in the nginx config limit_req_zone and enforced by app/src/RateLimit.php:

  • General requests: 30 r/m
  • Search endpoint: 30 r/m
  • Admin panel: 300 r/m (burst 30)

The PHP limiter uses REMOTE_ADDR only (not X-Forwarded-For) to avoid IP spoofing.

Files & storage

  • Uploads live outside the webroot under app/storage/ (tfe/, theses/), served on demand via MediaController/FileAccessController through controlled endpoints (/media), not direct static access.
  • nginx blocks /storage, /src, /templates, DB/sql/env/md files, and hidden files. The DocumentRoot is app/public/ only.
  • Restricted-file downloads are gated by a request/approval/token flow (file_access_* tables).
  • Logs write to /var/log/xamxam/ in production (and app/storage/logs/ only in dev/cli-server) — outside the webroot, not publicly served.

Injection & output

  • SQL: all queries use PDO prepared statements.
  • LIKE wildcards: Database::escapeLikeString() escapes % and _ (public search and related queries).
  • XSS: htmlspecialchars() on all user-controlled output; integer/ID inputs cast.
  • CSRF: per-session tokens (bin2hex(random_bytes(32))), compared with hash_equals().
  • File uploads: MIME validated (finfo); FilePond prevalidation + server-side checks in FilepondHandler.
  • Markdown/HTML: user content (pages, help blocks) rendered via league/commonmark; HTML in admin-editable content is expected.

Defence in depth / misc

  • External links use rel="noopener noreferrer".
  • Decryption/Crypto failures are logged without leaking secrets; SMTP password is stored encrypted in smtp_settings and rotated via just reencrypt-password.
  • Admin operations are recorded in admin_audit_log (resource, action, status, IP, User-Agent).

Areas to keep monitored

  • Tightening the public CSP (frame-ancestors 'none' vs 'self' on embed routes) is an active topic — see TODO.md.
  • Sensitive file downloads and their expiry/token handling are worth periodic review as usage grows.

See also: nginx/docs/SECURITY_HEADERS.md, nginx/docs/PHP_AUTH_LAYER.md, nginx/docs/PRODUCTION_DEPLOYMENT.md.