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.
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 insite_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
lifetimeis 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_activityis 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.phpat most once per 5 min while the tab is visible. The endpoint callsAdminAuth::isAuthenticated(), which runs the same timeout enforcement and refreshes the timestamp; it returns204when authenticated and401otherwise (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 withjust smoke-session-keepalive. - PHP-FPM session GC tuning — see below. The app enforces its own timeouts,
so
session.gc_maxlifetimemust be ≥ the 12 h absolute timeout or PHP would reap active sessions early. - nginx
auth_basiclayer has been removed; the PHP session layer is the only gate. (LDAP-based login is a proposed future enhancement — not implemented. Seeldap.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) — matchesABSOLUTE_TIMEOUT_SECONDS; must stay ≥ the app's absolute timeout.gc_probability = 1/gc_divisor = 100— re-enables the GC (default wasgc_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, raisegc_maxlifetimeto 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 includesscript-src 'unsafe-inline'for the OverType editor.object-src 'none'. X-Frame-Options: DENY(clickjacking)X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy(geo/mic/camera disabled)Cross-Origin-Opener-Policy/Cross-Origin-Resource-Policy: same-originX-Robots-Tag: noindex, nofollowon/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 viaMediaController/FileAccessControllerthrough controlled endpoints (/media), not direct static access. - nginx blocks
/storage,/src,/templates, DB/sql/env/md files, and hidden files. The DocumentRoot isapp/public/only. - Restricted-file downloads are gated by a request/approval/token flow
(
file_access_*tables). - Logs write to
/var/log/xamxam/in production (andapp/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 withhash_equals(). - File uploads: MIME validated (
finfo); FilePond prevalidation + server-side checks inFilepondHandler. - 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/
Cryptofailures are logged without leaking secrets; SMTP password is stored encrypted insmtp_settingsand rotated viajust 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-rootTODO.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.