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 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 — 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
lifetimeis 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_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 (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) — 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.