Summary of all implemented security measures, gates and configurations — as the basis for an external security review.
What this document is: a consolidated self-disclosure of the current technical and organizational security posture — every measure genuinely exists in code or server configuration and is mostly covered by automated tests, not merely planned. Where a measure is still outstanding, that is explicitly marked as such rather than omitted.
What this document cannot replace: an actually performed, independent external penetration test. This overview is the basis for such a review, not its result.
bcrypt/Argon2id via UserPasswordHasherInterface, minimum cost factor deliberately set and tested (PasswordHashingCostFactorTest) instead of trusting the framework default. Real finding 2026-09-25: the Argon2id dummy hash used for login anti-enumeration was, with the default time_cost on the real server hardware, measurably faster than a genuine bcrypt login — discovered via an external timing test, recalibrated empirically (not from table values) to near-equal timing.
The same external test showed a smaller but measurable residual difference on forgot-password/self-register (synchronous DB lookup only on a hit). Fixed structurally rather than by cost-equalizing: the entire asymmetric part (lookup, branching, sending the email) now runs inside an asynchronous message handler — the controller itself does exactly the same, constant amount of work for every email address. Re-verified live: response time for an existing vs. a made-up account is no longer distinguishable.
Mandatory for all admin roles, optional for regular accounts. Backup codes are hashed, shown once in plaintext. Own finding fixed: a TOTP test flake resolved via a frozen clock, production tolerance (leeway=0) deliberately left strict and unchanged.
Dedicated policy for login, 2FA, registration, AI endpoints — each with genuine symfony/lock synchronization (otherwise unsynchronized under load).
Cryptographically secure random values (random_bytes()), never mt_rand(). Timing-safe comparison (hash_equals()), uniform error message regardless of hit rate.
Never ad-hoc if checks. Full owner/co-author/guest/admin matrix, four graduated guest tiers, two-stage removal for co-authors.
External scan finding: /api/docs exposed the OpenAPI schema (field names such as canManageUsers) to anonymous visitors as well — not a data leak (the actual endpoints were already correctly protected), but unnecessary structural disclosure. The documentation endpoint now requires login.
Co-owner, time-limited succession delegation, master recovery (two separate physical secrets + majority approval) — no single point of failure if the sole admin access is lost.
Every administrative 2FA/password reset creates an immutable (append-only) audit log entry. Four-eyes principle once ≥2 admin accounts exist. Email changes are exclusively self-service, never via an admin endpoint.
Remote lock on device loss (remote-wipe signal), idempotency keys against duplicate processing on offline reconnect — enforced system-wide via #[IdempotencyStrategy], not optional per endpoint.
Chapter content and version snapshots encrypted before being written (Sodium/XChaCha20-Poly1305), encapsulated in a dedicated Doctrine type.
Self-hosted HashiCorp Vault, genuine 2-of-3 unseal threshold (no solo key). Restore drill actually performed and passed, not just configured.
users.email stored encrypted, plus an HMAC-SHA256 hash for login/uniqueness checks — no plaintext comparison on a sensitive column.
A global Doctrine SQL filter automatically restricts every query to permitted projects — in addition to the voter check, not as a replacement. Fail-closed branch deliberately left unlogged (tripwire).
Application role holds only DML rights, no SUPERUSER. Separate app/batch role, batch user has no access to email/password-hash columns.
Instead of INT AUTO_INCREMENT — prevents enumeration of record counts. An invalid ID format returns 400, never 500.
Manuscripts are readable server-side (search, continuity checking, and collaboration require this) — honestly documented as an architectural decision, never marketed as "end-to-end encrypted."
Recursive allowlist instead of HTMLPurifier — TipTap content is JSON, not a raw HTML string. An unknown node type triggers full rejection, never silent stripping. Depth/size limits against payload bombs.
Genuine content sniffing (finfo, never the client-supplied value), a hard MIME allowlist, size limits, self-generated filenames (structurally no path traversal possible).
Self-hosted ClamAV (no SaaS provider), fail-closed — scanner unreachable means the upload is rejected, never let through unscanned. Verified for real with an EICAR test file, memory limit set against resource exhaustion.
Every archive entry's target size is checked before extraction. LIBXML_NONET explicitly enabled, DTD loading disabled for every XML parse in the import path.
Every server-side outbound call is checked against a fixed domain allowlist — there is no "import from arbitrary URL" feature that could bypass this. The export rendering pipeline has no network access outside its own whitelist.
Exclusively dedicated write DTOs, never generic entity hydration. No endpoint maps a request body directly onto an entity.
X-Content-Type-Options: nosniff globally, Content-Disposition on every file endpoint — defense in depth on top of the MIME allowlist.
HTTPS enforced (not only at the application level), Strict-Transport-Security, a restrictive Content Security Policy (connect-src 'self' — prevents data exfiltration in a hypothetical XSS). No AI provider in the allowlist. Real finding 2026-09-25: this previously applied only to responses actually served through the backend application — the SPA shell (HTML/assets) served directly by the web server simply lacked the same headers. Both delivery paths now carry identical values.
External scan finding: not set at all previously. strict-origin-when-cross-origin instead of implicitly trusting the browser default; Permissions-Policy demonstrably disables unused browser features (camera, geolocation, payment, …); X-Frame-Options: DENY as a fallback for browsers without CSP Level 2 support (frame-ancestors already covers modern browsers).
Exclusively exact, hardcoded domains for allow_credentials — never wildcard regexes across domain families.
SameSite cookie plus a mandatory X-Requested-With header on every mutating request.
v-html in the frontend implementedNever used anywhere in the code (deliberate, documented via comment) — editor rendering runs exclusively through TipTap's own, closed schema.
Opt-in feature for shared devices — quickly hides sensitive content.
Explicit browserslist baseline instead of an implicit build-tool assumption. Feature-detection script (ES5, loaded before the actual bundle) shows a notice for an outdated browser — no hard block, purely a usability measure rather than a security decision.
A custom, Docker-compatible INPUT-chain firewall (default-deny with allowlist) — deliberately not the Plesk firewall, whose generated script was shown to cut off the entire container stack from the network.
Real nftables integration (a Docker-host-specific pitfall found and fixed), verified for real with a dummy IP — not merely configured, an actual network rule confirmed on a ban.
Key auth only, no password login.
The web app and the Git/CI server initially ran under the same host UID (an RCE in the larger, more exposed app would have automatically granted access to the Git server) — fixed, dedicated system users per service, kept as a general principle going forward.
The repository directory was accidentally world-readable (to other, unrelated accounts on the shared host) — demonstrated for real with a foreign account and closed. Backup encryption code moved out of the crontab into its own 0600 file.
No direct push to main — tested for real, an actual push attempt was rejected. Mandatory CI checks before every merge.
Automatic staging deploy only on green CI — the negative path tested for real (red checks demonstrably triggered no deploy). Deploy token is least-privilege (read:repository, no write access, no SSH key).
Encrypted backups (Vault, Git server, database), restore drills actually performed and passed — not merely present as a script.
Explicit memory limit on the ClamAV container, so a resource outlier never takes down the shared host instead of just this one service.
Real finding during the timing-fix rollout: no process had been consuming the asynchronous message queue — multiple messages were sitting unprocessed in the database for real. A dedicated worker container (same image, messenger:consume) is now automatically carried along by the same compose file on every deploy.
Found while re-verifying that day's own changes: a changed web-server configuration (bind mount) was checked out by the automatic deploy but never actually reloaded without an extra manual step — the purely memory-rule nature of that step led to exactly one instance of forgetting it. Now a fixed, unconditional part of the deploy script, no manual step required anymore. Shows the project's own review discipline: the finding came from re-verifying its own work, not from an external source.
A consistent architectural principle, not limited to a single service: EU hosting only (Hetzner/Strato), no US hyperscalers (AWS/Google Cloud/Azure) and no US SaaS providers for security-relevant functions — because of the US CLOUD Act (an extraterritorial data-access claim, independent of physical storage location).
Technical fulfillment of GDPR Art. 20 — free on every plan, and by principle must never become a paid feature. Account-level and project-level data are kept separate, each complete (repeatedly checked for real against all ~170 entity types).
Every guest access to shared content is logged and visible to the owner/co-authors.
GDPR Art. 17(3)(b) — a narrowly scoped exception only for an open accountability matter, never a blanket delay of the entire deletion.
Guest accounts receive only what the specific share requires. Never-accepted invitations are automatically anonymized after 14 days.
No advertising/analytics cookies, no cross-site tracking. Performance telemetry is never sent silently in the background — every transmission produces a visible notice.
Security requirements are consolidated into a single, binding rulebook (illumentis-security-testing-lawbook.md, ~1000 lines) — additive-only: nothing is ever shortened or weakened without an explicit check-in. Every security-relevant measure is validated by an automated test class, not by manual review alone.
FunctionalCoverageManifestTest, a fixed feature-ID whitelist instead of an honor system)main (backend and frontend checks, including composer audit/npm audit)TotpRateLimitSubscriber) whose branch outcome depends on actual execution speed under parallel test load. Both values are well above the 90% threshold; noted here deliberately as measurement uncertainty rather than quietly reporting the more favorable figure.14 attack scenarios tested — 13 of them verified with concrete automated tests. Identified vulnerabilities were fixed and retested; one known limitation is documented rather than omitted. Current test run: 3,896 automated tests, 2 test failures in the last run, 1 known independent risky test tracked separately.
| Attack scenario | Result |
|---|---|
| Compromised User | passed |
| Cross-Tenant IDOR | passed |
| Compromised Co-Author | passed |
| Compromised Editor | passed |
| Compromised Admin | passed — with documented limits |
| Session Hijacking | passed — with documented limits |
| Race Conditions | passed |
| API Manipulation | passed |
| Vault Policy Bypass | passed |
| Authenticated Scraping | passed |
| Branch/Merge Manipulation | passed |
| Permission Escalation | passed |
| Compromised Symfony Runtime | passed — with documented limits |
| Deployment Integrity | known remaining measure |
The tests described here are not a guarantee against future vulnerabilities. They verify defined security properties under the documented assumptions — the four cases below were deliberately documented with their respective limits rather than presented as fully solved.
The principle "no admin can see book content" holds fully for the app admin (can_manage_users) via the application itself (voters, serializer groups, field-level encryption). It does NOT hold against a server root/infrastructure admin: they can read the KEK from Symfony Secrets/the environment variable. Genuine effectiveness even against a compromised root access would require an external KMS/Vault that decrypts itself (ciphertext in, plaintext out, the key never leaves the key server) — already implemented for content/media/email-hash (see Vault Policy Bypass/Compromised Symfony Runtime), but not yet for every data path.
Detection deliberately compares only the user agent, not the IP address (a network change in the middle of a legitimate session is normal). An attacker who exactly replicates both the victim's IP AND user agent goes undetected.
A compromised, actively running Symfony process can, at the time of access, reach plaintext keys or already-decrypted data present in process memory. The Vault architecture (envelope encryption) specifically reduces the risk of a permanently available, static application key — it does not prevent a full live RCE.
File-integrity monitoring (detecting subsequent code tampering) exists and is tested. Automated deployment hardening (signed manifest, SHA pinning, external out-of-band verification) is documented knowledge, but not yet a running measure.
Carried over from the lawbook's "external verifiability" section — self-disclosure is not external confirmation. This table deliberately distinguishes between the two.
| Area | Status |
|---|---|
| Auth, authorization, IDOR, encryption, input validation | Fully covered, automated tests |
| Network/transport, DNS/email authentication (SPF/DKIM/DMARC) | Covered |
| Backup/disaster recovery, incident response plan | Covered, restore tested for real |
| Secret scanning in CI (Gitleaks) | Implemented (2026-09-25) — a real setup misconfiguration was found and fixed (a missing useDefault=true would have silently disabled every rule) |
| License checking against an allowlist (dependencies) | Implemented (2026-09-25), backend + frontend, fail-closed on a missing license declaration |
| Automated coverage-threshold enforcement | Implemented (2026-09-26) — PCOV in CI, thresholds raised to 90%/80% after a real measurement run. All 101 gaps found in the security group systematically closed with real tests (not just reaching the threshold) — current actual state 99.4–99.6%/87.5–87.7% (small measurement variance between runs, see section 8), both values well above the threshold. Four consistently reproduced, documented lines remain open: two genuine dead code, two due to an external Vault rate limit |
| Rate-limit backoff for the Vault email-hash migration | Implemented (2026-09-26) — self-throttling + retry with exponential backoff against Vault's Transit rate limit (60/60s), previously entirely unaccounted for. Fully tested via an injectable test seam, without consuming the real rate-limit budget |
| Mutation testing (voter classes + ChapterContentSanitizer) | Implemented (2026-09-26) — monthly reporting cron (Infection), not a CI gate. First real run: 596/773 mutants killed, covered-code MSI 77% |
/.well-known/security.txt + vulnerability disclosure policy (safe harbor) | Implemented (2026-09-29) — real contact address, dedicated policy page |
| Malware scanning for JSON-import ZIP contents | Deliberately out of scope — a dedicated zip-bomb guard already exists |
| Dedicated production server (currently: shared Strato host for beta) | Hetzner migration planned, not yet live |
| Independent external penetration test | Not yet performed |
| Hosting provider certification (ISO 27001 or similar) | Only implicit via the EU-location decision |