# 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`](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` ```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`.