Files
xamxam/docs/security.md
T
Pontoporeia 554ba3ee8d fix(admin): stop logging out active long-form work; raise idle timeout to 4h
The admin idle timeout (30 min) was refreshed only by navigations and HTMX
requests. During long encoding sessions on an open form there are none, so
an actively-typing admin was logged out mid-work after ~30-45 min.

Add an activity-driven keepalive:
- /admin/session-keepalive.php: 204 when authenticated (refreshes
  admin_last_activity via AdminAuth::isAuthenticated()), 401 otherwise.
- admin-session-keepalive.js: marks activity only on real user input
  (pointer/keyboard/input/scroll/wheel/touch/focus) and pings at most once
  per 5 min while the tab is visible. A genuinely idle tab never pings, so
  the idle timeout still applies.

Raise the idle window 30 min -> 4 h: for a single-/few-admin back-office
whose main workflow is data entry, 30 min still kicked admins who stepped
away mid-form. With the keepalive in place, 4 h means "no interaction at
all", not "no navigation". Absolute timeout stays 12 h.

Also fix session ID rotation, which never fired: it used
`$absolute % IDLE_TIMEOUT_SECONDS === 0`, i.e. required a request to land
exactly on a multiple of the interval relative to login time. Replaced with
an explicit admin_last_rotation timestamp and a ROTATION_INTERVAL_SECONDS
(30 min) constant decoupled from the idle timeout, so raising the idle
window does not widen the fixation/replay window.

Refactor AdminAuth::enforceSessionTimeout() to return bool instead of
redirecting/exiting, so the keepalive endpoint can report 401 cleanly
rather than letting fetch follow a redirect to the login page.

Smoke test (just smoke-session-keepalive) covers activity refresh, 2 h idle
accepted, rotation firing, idle rejection+destruction, and unauthenticated
rejection. Docs updated.
2026-09-18 16:26:49 +02:00

7.0 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 — 4 h without activity → session destroyed, redirected to login. Raised from 30 min because the back-office's primary workflow is long-form data entry; combined with the activity-driven keepalive below, this means "no interaction at all", not "no navigation".
    • Absolute timeout — 12 h since login → forced re-login, regardless of activity.
    • Session ID rotated every 30 min to limit fixation/replay (interval is independent of the idle timeout, so the replay window did not widen when the idle window was raised).
    • 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.
  • Activity-driven keepalive (app/public/admin/session-keepalive.php + assets/js/app/admin-session-keepalive.js). admin_last_activity is only refreshed by requests the browser makes. A long data-entry session on an open form issues none, so an actively-typing admin used to be logged out after the idle window. The keepalive closes that gap: the client marks activity on real user input (pointer, keyboard, input, scroll, wheel, touch, focus) and pings /admin/session-keepalive.php at most once per 5 min while the tab is visible. The endpoint calls AdminAuth::isAuthenticated(), which runs the same timeout enforcement and refreshes the timestamp; it returns 204 when authenticated and 401 otherwise (the client then reloads, and the server redirects to login). A tab left open with no interaction never pings, so the idle timeout still applies. Verify with just smoke-session-keepalive.
  • 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 — not implemented. See ldap.md.)

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 (4 h / 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 — tracked in the repo-root 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.