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

155 lines
7.0 KiB
Markdown

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