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.
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
