Security
SynaptikCMS ships with a layered security setup out of the box. This covers what is protected, how, and the recurring failure mode to watch for when extending it.
The one thing to watch for
The codebase's characteristic weakness is unevenly applied protection: a check done correctly in one place, missing in the adjacent one. When adding or reviewing any protection — a CSRF token, a rate limit, an escape call — ask where else the same operation happens, and whether the check went there too.
Admin folder
install.php lets you pick a custom admin folder name — avoid admin, cms, backend. Never hardcode admin/ in your own code; always read config.json['admin_dir'] via resolve_admin_dir().
Direct-access guard
Every non-public PHP file (templates, includes, plugin files) must start with:
if (!defined('INCLUDED')) { http_response_code(403); exit; }
This works on nginx too, unlike .htaccess — plugins relying only on .htaccess for access control are unprotected on nginx installs (see nginx.conf.example).
Protected paths
.htaccess blocks config.json, admin-credentials.php, and all .htaccess files by name at the root, and denies all access to /data/, /bckps/, /private/, and plugin data//private/ folders directly.
Security headers & CSP
Set via mod_headers in the root .htaccess (front end) and admin/.htaccess (admin panel, its own stricter block): X-Frame-Options, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy, and a Content-Security-Policy with no 'unsafe-inline' in script-src. Server-rendered data reaches front-end JS through <script type="application/json"> islands read by front-boot.js, not inline scripts. The one inline exception — the pre-paint dark/light + sidebar-state boot snippet — is allow-listed by a single SHA-256 hash instead, since it must be byte-identical and run before first paint. style-src keeps 'unsafe-inline' because user content can carry inline style="" from the rich-text editor.
Output escaping
hsc(?string $s, int $flags = ENT_QUOTES | ENT_SUBSTITUTE, string $enc = 'UTF-8'): string is the project-wide escaping helper — null in, '' out; arrays raise a TypeError by design. Use it for all HTML output; never bare htmlspecialchars().
CSRF
State-changing POST actions and any GET link that triggers a download or a state change carry a csrf_token, validated with hash_equals(). Confirmation/cancellation links (password reset, newsletter unsubscribe, booking cancellation) use a signed token instead: hash_hmac('sha256', $id . '|' . $action, $secret), including the action name in the signed data so tokens aren't interchangeable across actions, plus a timestamp for expiry.
Rate limiting
Login, contact form, and search all rate-limit by IP, stored in /private/ (auth_rate.json, contact_rate.json, search_rate.json). The correct pattern is a single fopen('c+') + flock() across the full read-modify-write cycle — reading without a lock and then writing with LOCK_EX reintroduces the race condition this is meant to prevent. Expired entries must be pruned on each check; unbounded growth here is a known recurring issue across the catalogue plugins.
Sessions & authentication
admin_is_logged_in() (never a plugin-local reimplementation of the check — that bypasses the shared timeout) enforces a 2-hour inactivity timeout, checked on every admin request. Session cookies are hardened in PHP directly (session-config.php), not via .htaccess/php_value, since PHP-FPM (what OVH and most shared hosts run) ignores php_value session directives in .htaccess.
Multi-user roles
Three roles, stored per-account in private/users.json: admin, editor, author. admin_current_user_role() reads the session; admin_is_admin() and admin_can_manage_all_content() (true for admin and editor, false for author) are the two permission gates used throughout the admin panel. admin_can_edit_item(array $item) layers on top of the second — full access if it returns true, otherwise only if $item['author_id'] matches the current user. The system always keeps at least one admin account — role changes and deletions that would leave zero admins are rejected server-side.
ZIP uploads
Theme, plugin, and backup-restore ZIPs are validated before extraction (admin/includes/zip-validation.php): MIME type, path traversal, null bytes, absolute paths, and dangerous extensions are all rejected.
Headers are stripped of CR/LF (str_replace(["\r","\n"], '', $v)) before use. Bulk mail (newsletter digests) sends one recipient at a time via addAddress(), never a shared To: header.
Secrets
Per-installation secrets — contact.secret, theme_preview.secret — are generated once with bin2hex(random_bytes(32)) and stored in /private/, never as constants in source.
Production checklist
[ ] Admin folder renamed to something non-obvious
[ ] /data/, /bckps/, /private/ return 403
[ ] config.json not accessible over HTTP
[ ] HTTPS enabled — CSP and session cookies assume it
[ ] Directory listing disabled (Options -Indexes)
[ ] display_errors = Off in production php.ini
[ ] On nginx: rules from nginx.conf.example applied — .htaccess has no effect
