⌘K

Security

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

img-src and remote images

img-src 'self' data: blob: https: allows images from any https host, so ![alt](https://example.com/photo.jpg) works the way it does in every other CMS. Only images are affected — links, scripts, fonts and frames each have their own directive, and none of them allow third-party origins.

The tradeoff: any host serving an image on your pages sees your visitors' IP and referrer, so an embedded image can act as a tracking pixel. That is the author's own choice on a single-author site. On a multi-author site it is worth reconsidering — a contributor without full privileges can embed a remote image, and with it a tracker.

To lock it down, replace https: with the hosts you actually use, in both the root .htaccess and admin/.htaccess (and nginx.conf.example if you run nginx — it carries three copies of the policy):

img-src 'self' data: blob: https://cdn.example.com;

Be aware of the failure mode: a blocked image produces no error in the admin and no message on the page. The <img> element still exists, so its title tooltip works and its alt text shows while the image itself never loads. If images from one particular host stop appearing, check img-src before looking anywhere else.


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.


Page cache: key it on every input that changes the output

The full-page HTML cache (sl_page_cache_get()/sl_page_cache_set(), core/data/page-cache.php) shipped in 1.4.1 with a key built from lang + urlPath only — missing scheme and host. Whichever hostname rendered a page first "won" the cache entry for every hostname afterward, and a spoofed Host header could get its response cached and served to every anonymous visitor after it. Fixed in 1.4.2 by routing scheme/host through _sl_request_is_https() and _sl_request_host() (both in core/data/page-cache.php) — never read $_SERVER['HTTP_HOST']/HTTPS directly in any code that builds an absolute URL or a cache key; that direct read is exactly what caused the bug. _sl_request_host() also validates the host format and honors the optional canonical_host setting, which redirects (and refuses to cache) any request arriving on a hostname other than the configured one.

This is a general rule, not a one-off fix: whenever you add or change any cache keyed on the request (page, fragment, an API response) — list every input that can change its output (language, host, scheme, query params, user role, A/B variant…) the same way HTTP's Vary header would, and make sure all of them are in the key. tests/Unit/PageCacheKeyTest.php locks in this exact invariant for the page cache (different host/scheme ⇒ different key; a malformed host resolves to '', never reflected raw; canonical_host overrides a spoofed Host header) — add an equivalent test alongside any new cache layer.


Atomic writes

Every JSON write goes through _sl_write_json() (core/admin/writer.php): encode, write to a PID-suffixed temp file ({path}.{getmypid()}.tmp), then rename(). The PID matters — a fixed {path}.tmp name lets two near-simultaneous writes to the same file interleave, and one of them silently vanishes. Every plugin's own data layer follows the same pattern; if you add a new write path anywhere in core or a plugin, use _sl_write_json() rather than hand-rolling file_put_contents() + rename().


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.


Mail

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