Total CMS Security Guide
This guide provides essential security recommendations for protecting your Total CMS installation and data.
Protecting the tcms-data Folder
Section titled “Protecting the tcms-data Folder”The tcms-data folder contains all your CMS content, including potentially sensitive information (API keys, user data, collection data). It’s crucial to protect this directory from unauthorized web access.
Automatic Apache Protection: Total CMS automatically creates a .htaccess file in the tcms-data folder to deny direct web access when using Apache. If you’re using Nginx or another web server, you must configure protection manually (see below).
Best Practice: Move Outside Document Root
Section titled “Best Practice: Move Outside Document Root”The most secure approach is to relocate the tcms-data folder outside your web server’s document root:
-
Move the folder to a location outside your public web directory
Terminal window # Example: Move from /var/www/html/tcms-data to /var/www/tcms-datamv /var/www/html/tcms-data /var/www/tcms-data -
Update the configuration in the Admin Dashboard:
- Navigate to Admin → Settings
- Update the “Data Directory” field to the new location
- Save the settings
-
Ensure proper permissions:
Terminal window chmod -R 755 /var/www/tcms-datachown -R www-data:www-data /var/www/tcms-data # Adjust user/group as needed
Alternative: Restrict Access Within Document Root
Section titled “Alternative: Restrict Access Within Document Root”If you must keep tcms-data within the document root, configure your web server to block access.
Apache (.htaccess)
Section titled “Apache (.htaccess)”Automatic Protection: Total CMS automatically creates a .htaccess file inside the tcms-data folder when it’s first initialized. This file denies all direct web access to the folder contents.
The auto-generated .htaccess file contains:
# Deny direct access to all files and folders in tcms-data# This protects sensitive data including API keys, collections, and user data
<IfModule mod_authz_core.c> Require all denied</IfModule><IfModule !mod_authz_core.c> Order deny,allow Deny from all</IfModule>Alternative Approach: You can also add this to your root .htaccess file:
# Define 404 pageErrorDocument 404 /404/
# Block access to tcms-data directoryRedirectMatch 404 ^/tcms-data/Required for Nginx Users: Unlike Apache, Nginx does not process .htaccess files. You must add this protection to your server block configuration manually.
Add this to your Nginx server block configuration:
# Block access to tcms-data directorylocation ~ ^/tcms-data/ { deny all; return 404;}
# More comprehensive blocking (optional)location ~ ^/tcms-data/.*\.(json|md|txt|log)$ { deny all; return 404;}Required for Caddy Users: Caddy does not process .htaccess files. You must add this protection to your Caddyfile manually.
Add this to your Caddyfile:
# Block access to tcms-data directory@blocked path /tcms-data/*respond @blocked 404
# Or use a more explicit approachhandle /tcms-data/* { respond 404}Authentication and Session Security
Section titled “Authentication and Session Security”Strong Password Requirements
Section titled “Strong Password Requirements”- Enforce minimum password length (8+ characters recommended)
- Require a mix of uppercase, lowercase, numbers, and special characters
- Implement password history to prevent reuse
- Consider implementing two-factor authentication (2FA)
Session Security
Section titled “Session Security”Total CMS implements several session security measures:
- Session regeneration on login
- Secure session cookies (when using HTTPS)
- CSRF protection on all state-changing operations
Where session files are stored
Section titled “Where session files are stored”Total CMS keeps session files in tcms-data/.system/sessions, not PHP’s default
location. PHP’s default is a single directory shared by every site on the
server, and its cleanup runs on whichever site happens to trigger it, using
that site’s session lifetime — often 24 minutes. A neighbouring site’s cleanup
would delete your session files no matter what Total CMS configured, which is a
common cause of “it keeps logging me out” on shared hosting.
If that directory cannot be created or written to, Total CMS falls back to PHP’s default rather than failing: a session directory it cannot write to would mean nobody can log in at all.
To put sessions somewhere else — a data directory on network storage is the
usual reason — set the path explicitly in tcms.php:
$settings['session']['save_path'] = '/var/lib/tcms-sessions';Sharing a login across subdomains
Section titled “Sharing a login across subdomains”By default a login applies to one hostname. The session cookie is set for the
exact host, so signing in at admin.example.com does not sign you in at
shop.example.com, even when both are Total CMS installs.
Two things have to line up to share a login across subdomains of the same domain:
-
The installs must share their session storage, which happens automatically when they share one
tcms-datafolder, or can be set explicitly withsession.save_pathon both. -
The cookie must be widened to the parent domain on both installs:
$settings['session']['cookie_domain'] = '.example.com';
“Keep me signed in” follows the same rule — its cookie inherits the session cookie’s domain — so a persistent login is shared too.
If the installs share one tcms-data folder, also set:
$settings['cache']['domainScoped'] = false;so they share one cache namespace instead of keeping separate ones.
Only do this if you control every subdomain. Widening the cookie means
every host under example.com receives it on every request — including any
subdomain running software you did not write, and any subdomain someone else
controls. HttpOnly stops JavaScript from reading the cookie; it does nothing
about the server on the other end, which sees it in the request headers. A
forgotten test.example.com or a customer-controlled blog.example.com is
enough to turn this into a way to collect admin sessions.
This works for subdomains of one domain only. example.com and example.net
cannot share a cookie, and no configuration changes that — sharing a login
across genuinely different domains needs a redirect-based handoff, which Total
CMS does not currently provide.
Account Security
Section titled “Account Security”- Limit login attempts to prevent brute force attacks
- Implement account lockout after failed attempts
- Log authentication events for monitoring
- Regularly review user accounts and remove inactive ones
Content Security
Section titled “Content Security”HTML Sanitization
Section titled “HTML Sanitization”Total CMS sanitizes HTML content by default to prevent XSS attacks. This is especially important for user-generated content.
⚠️ Warning: Disabling HTML Sanitization
While it’s possible to disable HTML sanitization for certain fields, this significantly increases security risks:
{ "htmlclean" : false}Risks of disabling sanitization:
- Cross-Site Scripting (XSS): Malicious scripts can steal user sessions, redirect users, or modify page content
- HTML Injection: Attackers can inject malicious HTML that breaks page layouts or functionality
- Data Theft: Scripts can access and transmit sensitive data to external servers
- Phishing: Malicious content can mimic legitimate forms to steal credentials
If you must allow raw HTML:
- Only enable it for trusted administrator accounts
- Never allow it for public-facing content
- Implement Content Security Policy headers
- Regularly audit content for suspicious code
- Consider using a more restrictive whitelist approach
SVG Security
Section titled “SVG Security”SVG files can contain JavaScript and other potentially dangerous content. Total CMS automatically sanitizes SVG uploads to remove:
<script>tags- Event handlers (onclick, onload, etc.)
- External references
- JavaScript in URLs
{ "svgclean" : false}Template Output Escaping (Twig)
Section titled “Template Output Escaping (Twig)”Twig autoescaping is OFF globally in Total CMS. This is deliberate: content fields hold author-authored HTML and Markdown that must render as markup, not as escaped text ({{ post.content }}, {{ body | markdown }}, etc.). With autoescape on, every content field would render as visible HTML entities.
The trade-off is that Twig does not escape output for you. The write-path HTML sanitizer (above) scrubs rich-text content saved through the admin, which covers the primary content vector — but it does not protect everything a template can print:
- Output rendered by an extension’s own templates (especially anything anonymous/agent-supplied)
- Free-form
page.data.*values on Site Builder pages - Query-string / request values echoed into a page
- Fields where
htmlcleanhas been disabled
For any value that is not trusted author content, escape it explicitly:
{# Escape untrusted text #}{{ user_supplied | e }}
{# Escape inside an HTML attribute #}<a title="{{ user_supplied | e('html_attr') }}">…</a>
{# Or turn autoescaping on for a whole block #}{% autoescape 'html' %} {{ user_supplied }}{% endautoescape %}This is the responsibility of template and extension authors: a handler that prints {{ input }} has no automatic XSS protection. When in doubt, escape.
File Upload Security
Section titled “File Upload Security”Best Practices
Section titled “Best Practices”- File Type Restrictions: Only allow necessary file types
- File Size Limits: Set appropriate maximum file sizes
- Filename Sanitization: Special characters are automatically removed
- MIME Type Verification: Files are checked beyond just extensions
- Upload Directory: Ensure upload directories are not executable
Configuration
Section titled “Configuration”Configure upload restrictions in your collection schemas:
{ "rules" : { "size" : {"min":0,"max":300}, "filetype" : ["image/jpeg", "image/png", "application/pdf"], "filename" : ["image.jpg"] }}Running Behind a Proxy
Section titled “Running Behind a Proxy”Rate limiting, webhook throttling and the OAuth registration log all need to
know which address a request came from. When Total CMS sits behind a reverse
proxy or a CDN, the connecting address is the proxy’s, and the real visitor is
in a header the proxy adds — CF-Connecting-IP for Cloudflare, or
X-Forwarded-For.
The catch is that a visitor can send those headers too, and nothing about a header says who set it. If Total CMS believed them unconditionally, anyone could send a different value on each request, land in a fresh rate-limit bucket every time, and never be limited at all.
trustProxyHeaders in tcms.php decides when those headers are believed:
$settings['trustProxyHeaders'] = 'auto'; // 'auto' | 'always' | 'never'| Value | Behaviour |
|---|---|
auto (default) | Believe the headers only when the request arrives from a private or loopback address — a reverse proxy on the same machine or LAN. |
always | Believe the headers on every request. |
never | Ignore the headers; always use the connecting address. |
auto is right for nginx or Apache in front of PHP, which is the usual
setup, and it needs no configuration. It is also safe on a server exposed
directly to the internet, because a request arriving from a public address is
not coming from your proxy.
Behind Cloudflare
Section titled “Behind Cloudflare”Nothing to configure. Total CMS ships Cloudflare’s published edge ranges and
recognises requests coming from them, so auto believes CF-Connecting-IP when
it genuinely came from Cloudflare and ignores it when someone else sends it. The
ranges are refreshed from
cloudflare.com/ips when each release is
built.
Settings → Server Info shows what your install is doing, including when the
bundled ranges were last verified. If Cloudflare ever adds a range that your
version predates, that panel says so directly rather than leaving you to work it
out from rate limits misbehaving — Cloudflare stamps every forwarded request
with a CF-Ray header, so a request carrying one from an address outside the
known ranges is a reliable sign the list needs updating. Updating Total CMS
fixes it.
Whatever the setting, restricting your origin so it can only be reached through Cloudflare remains worth doing — see protecting your origin server.
Behind another CDN or proxy
Section titled “Behind another CDN or proxy”For a proxy that connects from a public address and is not Cloudflare — Fastly,
Akamai, a load balancer on another host — auto has no way to tell it from a
visitor, so it will ignore its headers. Two options:
-
Resolve the address in your web server. Apache’s
mod_remoteipand nginx’sreal_ipmodule rewrite the connecting address from a header, but only for requests that genuinely came from the proxy’s IP ranges.REMOTE_ADDRthen holds the real visitor before PHP sees it,autois correct, and your access logs get the right address too.set_real_ip_from 10.0.0.0/8; # your proxy's addressesreal_ip_header X-Forwarded-For; -
Set
trustProxyHeaderstoalways. Simpler, and appropriate when you cannot configure the web server. It believes the headers no matter who sent them, so it is only safe once your origin cannot be reached directly. If someone can reach your origin by IP, this setting is an open door.
HTTPS and Transport Security
Section titled “HTTPS and Transport Security”Always Use HTTPS
Section titled “Always Use HTTPS”- Obtain an SSL certificate (Let’s Encrypt provides free certificates)
- Configure your web server to use HTTPS
- Implement HTTP to HTTPS redirects
- Enable HSTS (HTTP Strict Transport Security)
Apache HTTPS Configuration
Section titled “Apache HTTPS Configuration”# Redirect HTTP to HTTPSRewriteEngine OnRewriteCond %{HTTPS} offRewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]
# Enable HSTSHeader always set Strict-Transport-Security "max-age=31536000; includeSubDomains"Nginx HTTPS Configuration
Section titled “Nginx HTTPS Configuration”# Redirect HTTP to HTTPSserver { listen 80; server_name example.com; return 301 https://$server_name$request_uri;}
# HTTPS server blockserver { listen 443 ssl http2; server_name example.com;
# Enable HSTS add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;}Caddy HTTPS Configuration
Section titled “Caddy HTTPS Configuration”# Caddy automatically handles HTTPS, but you can be explicitexample.com { header Strict-Transport-Security "max-age=31536000; includeSubDomains"}Additional Security Headers
Section titled “Additional Security Headers”Total CMS sets security headers on every admin, login, and setup page automatically — no server configuration needed:
| Header | Value |
|---|---|
Content-Security-Policy | frame-ancestors 'self' (clickjacking protection) |
X-Frame-Options | SAMEORIGIN |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
Your public pages are a different story: whether they may be framed, what referrer they send, and any Content Security Policy are decisions for your site, not the CMS. Set those at the server level if you want them:
Apache
Section titled “Apache”# Security headersHeader set X-Content-Type-Options "nosniff"Header set X-Frame-Options "SAMEORIGIN"Header set X-XSS-Protection "1; mode=block"Header set Referrer-Policy "strict-origin-when-cross-origin"Header set Permissions-Policy "geolocation=(), microphone=(), camera=()"# Security headersadd_header X-Content-Type-Options "nosniff" always;add_header X-Frame-Options "SAMEORIGIN" always;add_header X-XSS-Protection "1; mode=block" always;add_header Referrer-Policy "strict-origin-when-cross-origin" always;add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;header { X-Content-Type-Options nosniff X-Frame-Options SAMEORIGIN X-XSS-Protection "1; mode=block" Referrer-Policy strict-origin-when-cross-origin Permissions-Policy "geolocation=(), microphone=(), camera=()"}Error Monitoring
Section titled “Error Monitoring”Total CMS can report application errors to the developer so bugs get discovered and fixed quickly — often before you notice anything went wrong, and without you having to file a report. You choose during the setup wizard, and you can change your mind at any time in Settings → General (“Share Application Errors with Developer”).
What a report contains: the exception, its stack trace, the request URL and method, and the Total CMS version.
What is never sent: the reporting client is explicitly configured to never attach request bodies, cookies, session data, IP addresses, or user information. Your content, credentials, and CMS data stay on your server.
Air-gapped deployments: installing an offline license disables error monitoring automatically at the configuration layer — a network-isolated install never attempts an outbound call, regardless of the settings toggle.
When disabled, errors are still logged locally on your server — see the log files in your data directory.
Regular Security Maintenance
Section titled “Regular Security Maintenance”Keep Software Updated
Section titled “Keep Software Updated”- Total CMS: Regularly check for and install updates
- PHP: Keep PHP version current (8.2+ recommended)
- Web Server: Update Apache/Nginx/Caddy regularly
- Dependencies: Run
composer updateregularly (test first!)
Security Monitoring
Section titled “Security Monitoring”- Access Logs: Regularly review web server access logs
- Error Logs: Monitor PHP and application error logs
- Failed Logins: Track and investigate failed login attempts
- File Changes: Monitor for unexpected file modifications
Backup Strategy
Section titled “Backup Strategy”- Regular Backups: Automate daily backups of tcms-data
- Offsite Storage: Store backups in a separate location
- Test Restores: Regularly verify backup integrity
- Version Control: Consider using Git for configuration files
Security Checklist
Section titled “Security Checklist”- tcms-data folder is protected or moved outside document root
- HTTPS is enabled with valid certificate
- Strong passwords are enforced
- File upload restrictions are configured
- Security headers are properly set
- Regular backups are configured
- Software is kept up to date
- Access logs are monitored
- HTML sanitization is enabled (unless explicitly required otherwise)
Emergency Response
Section titled “Emergency Response”If you suspect a security breach:
-
Immediate Actions:
- Change all passwords
- Review access logs
- Check for unauthorized file changes
- Clear the cache:
/api/emergency/cache/clear
-
Investigation:
- Review user accounts for unauthorized access
- Check for suspicious files in upload directories
- Examine database/JSON files for injected content
-
Recovery:
- Restore from clean backup if necessary
- Update all software
- Implement additional security measures
- Document the incident for future reference
Additional Resources
Section titled “Additional Resources”Remember: Security is an ongoing process, not a one-time setup. Regular reviews and updates are essential for maintaining a secure Total CMS installation.