Security Best Practices

Security Best Practices

Security Overview

Flatboard 5 includes multiple security layers:

  • CSRF Protection - Prevents cross-site request forgery
  • Rate Limiting - Prevents abuse and brute force attacks
  • Input Validation - Sanitizes all user input
  • Permission System - Granular access control
  • Secure Sessions - Encrypted session management with fingerprinting and group-change invalidation
  • File Upload Security - Validates actual file content (MIME), not the client-supplied type
  • Content Security Policy - Strict CSP with per-request nonces (no unsafe-inline, no unsafe-eval)
  • Trusted Proxies - Only honour X-Forwarded-For/X-Real-IP/CF-Connecting-IP from configured proxy IPs
  • Email Validation - Optional MX record check at registration to reject fake domains

CSRF Protection

What is CSRF?

Cross-Site Request Forgery (CSRF) attacks trick users into performing actions they didn't intend.

How Flatboard 5 Protects

  • CSRF Tokens - All forms include CSRF tokens
  • Token Validation - Tokens are validated on submission
  • Automatic Handling - Protection is automatic, no configuration needed
  • State-changing API routes (since 5.6.9) - POST/PUT/DELETE on /api/* (discussions, posts, reactions, notifications, presence, typing) require a valid CSRF token. The shared window.apiRequest() helper injects it as an X-CSRF-Token header automatically, so existing frontend callers keep working. The HMAC-signed /api/webhooks and the idempotent /api/markdown/parse are intentionally exempt.

Best Practices

  • Never Disable CSRF - Always keep CSRF protection enabled
  • Use HTTPS - Encrypt connections to protect tokens
  • Validate Tokens - Always validate in custom code

Rate Limiting

Purpose

Rate limiting prevents:

  • Brute force attacks
  • Spam and abuse
  • Resource exhaustion
  • DDoS attacks

Configuration

Access: Admin Panel > Settings > Security

Configure limits for:

  • Login Attempts - Max attempts per IP (default: 5)
  • Registration Attempts - Max registrations per IP (default: 3)
  • Password Reset - Max requests per hour (default: 3)
  • Post Creation - Max posts per time period
  • API Requests - Max API calls per key

Rate Limit Headers

Flatboard 5 sends rate limit headers:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1640995200

Input Validation

Sanitization

All user input is sanitized:

  • HTML Sanitization - Removes dangerous HTML
  • SQL Injection Prevention - Parameterized queries (SQLite)
  • XSS Prevention - Escapes output
  • Path Traversal Prevention - Validates file paths

Validation Rules

  • Email - Valid email format
  • URL - Valid URL format
  • Username - Alphanumeric and allowed characters
  • Password - Meets strength requirements
  • File Uploads - Valid file types and sizes

Permission System

Principle of Least Privilege

Grant minimum necessary permissions:

  1. Default Deny - Deny by default
  2. Explicit Allow - Explicitly grant permissions
  3. Regular Review - Review permissions regularly
  4. Document Changes - Keep permission change log

Permission Levels

  • Guest - View only
  • Member - Post and interact
  • Moderator - Moderate content
  • Admin - Full system access

Best Practices

  • Separate Roles - Use different accounts for admin/moderator
  • Limit Admin Count - Keep admin accounts minimal
  • Review Regularly - Audit permissions periodically
  • Use Groups - Manage via groups, not individual users

Password Security

Requirements (strengthened in 5.7.1)

Flatboard enforces a strong password policy on both the installer's admin account and member registration:

  • Minimum length - 10 characters (raised from 8 in 5.7.1). Controlled by security.password_min_length in config.json, which the installer now writes as 10.
  • Complexity - the password must contain at least one lowercase letter, one uppercase letter, one digit and one special character. This is mandatory, not a recommendation.

The rule is enforced server-side (the validator used by RegisterController and the installer) and mirrored client-side through the input pattern attribute, so a weak password is rejected before the form is even submitted.

A live password strength meter — a coloured bar plus a requirements checklist that ticks off as you type — is shown on the installer's admin-account step and on the member registration form. It is a shared, dependency-free component (themes/assets/js/shared/password-strength.{css,js}) that auto-initialises on any <input data-password-strength> and reads its labels from data-* attributes, so it stays translatable per context.

Password Hashing

Flatboard 5 uses:

  • bcrypt - Secure password hashing
  • Salt - Unique salt per password
  • Cost Factor - Configurable bcrypt cost

Two-Factor Authentication (2FA)

Enable 2FA for additional security:

  1. Enable in Settings - Admin Panel > Settings > Security
  2. User Setup - Users configure in profile
  3. Backup Codes - Generate backup codes
  4. Recovery - Account recovery options

Password Reset Token TTL (since 5.3.7)

The expiry of the link sent by Forgot password is controlled by the security.password_reset_token_ttl config key (in seconds). Default: 3600 (1 hour) — significantly shorter than the previous hardcoded 24 hours, which was unnecessarily long for a one-shot reset link.

Adjust it in config.json:

{
  "security": {
    "password_reset_token_ttl": 1800
  }
}

A shorter TTL reduces the window in which a compromised inbox can be used to take over an account. Anything between 900 (15 min) and 7200 (2 h) is reasonable.

Authentication & Password Reset Hardening (since 5.6.8 – 5.6.9)

A hardening sweep across the login and password-reset flows:

  • Logout is POST-only — the GET /logout route was removed. A GET logout could be triggered cross-site (<img src="
/logout">) to silently sign visitors out. All themes already render a POST form with a CSRF token, so there is no UI change.
  • Login enumeration closed — when the submitted identifier matches no user, the controller still runs password_verify() against a fixed dummy hash, equalising response time so an attacker can't tell valid accounts from invalid ones by timing.
  • Per-account login throttle — a login_account bucket (10 attempts / 15 min, keyed by the lowercased identifier) sits on top of the per-IP throttle, so rotating IPs no longer help brute-force a single account.
  • Reset tokens validated and hashed at rest — submitted tokens must match ^[a-f0-9]{64}$ before any storage lookup (blocking path traversal in the JSON backend), and only the sha256 hash of a token is persisted. A storage leak no longer exposes usable reset tokens.
  • Session invalidation after reset — changing password_hash bumps the user's permissions_version; any session still open under the old password is destroyed on its next protected request.
  • "Your password was changed" email — the account owner is notified immediately after a successful reset, with a link to re-secure the account if they didn't initiate it.
  • No PII in debug logs — the login controller no longer echoes the submitted identifier or matched username to the debug log.

Installer Hardening (since 5.7.1)

The installer (install.php) carries the admin-credentials form, so it received its own hardening pass:

  • Security headers — every setup page sends X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and a restrictive CSP (frame-ancestors 'none', form-action 'self', base-uri 'self'), so the form can't be framed or have its action hijacked.
  • Self-deletes after install — once .install.lock is written, the installer attempts to unlink() itself. The .lock 403 guard remains the primary protection if the unlink fails (read-only filesystem); the success screen says whether the file was removed or must be deleted by hand.
  • Whitelisted inputs — the submitted timezone is validated against timezone_identifiers_list() and the default language against the locales present on disk, each falling back to a safe default (Europe/Paris / fr) instead of being written verbatim into config.json.
  • Re-run guard fixed — a path-sanitisation bug previously let the installer run again over a live install. A valid non-empty config.json now counts as "already installed" and the installer shows a proper "Flatboard is already installed" page (HTTP 403) instead of silently redirecting.

Email Verification & MX DNS Validation (since 5.4.2)

A new optional check, email_mx_validation (enabled by default), verifies at registration time that the email domain has at least one MX record. Addresses whose domain has no mail server (e.g. demo@demo.com, test@nope.invalid) are rejected before any user row is written.

Toggle in Admin → Settings → Email. When disabled, MX validation is skipped — useful in air-gapped or test environments without external DNS.

The check is purely DNS-based (uses checkdnsrr($domain, 'MX')), runs only on registration, and adds at most a few hundred milliseconds. It does not block updates of an existing user's email if MX is temporarily unreachable.

File Upload Security

Restrictions

  • File Types - Only allowed MIME types
  • File Size - Maximum file size limits
  • File Validation - Validates actual file content
  • Virus Scanning - Optional virus scanning

Configuration

// Allowed file types
$allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];

// Maximum file size (2MB)
$maxSize = 2 * 1024 * 1024;

// Upload directory
$uploadDir = 'uploads/attachments/';

Best Practices

  • Whitelist Types - Only allow necessary file types
  • Scan Uploads - Scan for malware
  • Isolate Uploads - Store outside web root when possible
  • Validate Content - Check actual file content, not just extension

Server-side MIME Validation (hardened in 5.3.1)

UploadController::upload() previously trusted $_FILES['type'], which is supplied by the browser and can be trivially forged. The check now uses finfo_file() (with mime_content_type() as a fallback), and a second guard ensures the file's declared extension matches the detected MIME type. The avatar, image, and attachment endpoints already validated content via UploadService and were unaffected.

Session Security

Configuration

  • Secure Cookies - Use HTTPS-only cookies
  • HttpOnly - Prevent JavaScript access
  • SameSite - CSRF protection
  • Session Timeout - Automatic logout after inactivity

Session Timeouts & Fingerprinting

Flatboard 5 enforces layered session protection via App\Core\Session:

ConstantValueEffect
REGENERATE_INTERVAL1800 sSession ID is automatically regenerated every 30 minutes
IDLE_TIMEOUT3600 sSession expires after 1 hour of inactivity
MAX_LIFETIME86400 sAbsolute session maximum regardless of activity (24 hours)
REMEMBER_ME_LIFETIME2592000 sCookie and session lifetime when "Remember Me" is checked (30 days)

Session fingerprinting — on each request, a fingerprint is computed as SHA-256(User-Agent + Accept-Language). If the fingerprint changes mid-session, the session is immediately invalidated to prevent hijacking. IP validation is also performed by default and can be toggled for CDN/mobile environments:

Session::disableIpValidation();   // e.g., for users behind a CDN or mobile network
Session::enableIpValidation();    // default — re-enable IP binding

Group Change Invalidates Active Sessions (since 5.3.7)

When an administrator changes a user's group from Admin → Users, every active session of that user is invalidated on its next authenticated request.

Mechanics: User::update() stamps permissions_version = time() on group change; LoginController stores this value in the session at login; AuthMiddleware compares the session value against the database on every protected request. A mismatch triggers Session::destroy() and a forced re-login.

This means demoting an admin or moving a user into the banned group takes effect immediately — there's no "session window" where stale permissions linger.

Remember Me — Absolute 30-Day Ceiling (since 5.3.7)

REMEMBER_ME_LIFETIME (2,592,000 s = 30 days) is enforced as an absolute ceiling on remember-me sessions. Previously, the idle-timeout/max-lifetime checks were bypassed entirely for remember-me sessions, making them effectively immortal. They now ignore only the idle timeout while still respecting the 30-day absolute cap.

IP Address Detection — Trusted Proxies (since 5.3.7)

Session::getClientIp() and Request::getIp() used to trust X-Forwarded-For, X-Real-IP, and CF-Connecting-IP unconditionally — letting any client spoof their IP by setting these headers in the request.

A new IpHelper class reads those headers only when REMOTE_ADDR belongs to the configured security.trusted_proxies list. Default is an empty list (safe by default).

Configure in config.json:

{
  "security": {
    "trusted_proxies": [
      "10.0.0.0/8",
      "192.168.0.0/16",
      "172.16.0.0/12"
    ]
  }
}

Supports individual IPs and CIDR ranges, both IPv4 and IPv6. If you put Flatboard behind nginx/Apache as a reverse proxy, behind Cloudflare, or behind any load balancer that adds X-Forwarded-For, you must populate this list — otherwise every visitor will appear to come from your proxy's IP.

For Cloudflare specifically, populate from Cloudflare's published IP ranges.

Core IP Tracking on Users (since 5.6.6)

Two columns are now stored directly in the user record (both JSON and SQLite backends):

  • registration_ip — set once at registration, never updated
  • last_ip — updated on each login when the IP has changed

Only valid, non-fallback IPs are stored (0.0.0.0 is skipped). Both fields are available without any plugin dependency. Existing SQLite databases receive the two columns automatically on next startup via the migration system (SCHEMA_VERSION = 18).

Side effect: the Add ban form in admin/ban now auto-fills the last_ip field when an admin selects a member, and a separate IP ban is created automatically when an admin bans by user ID — see the Admin Panel guide for the full flow.

Advanced Session Methods

Available for plugin developers:

// Get and immediately remove a value (useful for one-time tokens)
$value = Session::pull('key', $default);

// Atomically increment/decrement a session counter
Session::increment('login_attempts', 1);
Session::decrement('credits', 1);

// Set a flash value visible only on the NEXT request
Session::flashNext('success', 'Saved!');
$message = Session::getFlash('success');

// Get only user-scoped session data (excludes internal _security, _remember_me, etc.)
$userData = Session::allUser();

// Session metadata for debugging
$meta = Session::metadata();
// Returns: id, started_at, last_activity, fingerprint, age, idle_time

// Migrate session data to a new ID without destroying the old one
Session::migrate();

Best Practices

  • Use HTTPS - Always use HTTPS in production
  • Regenerate IDs - Regenerate session ID on login
  • Short Timeout - Set reasonable session timeout
  • Secure Storage - Store sessions securely

Server Hardening

File Permissions

Set correct permissions:

# Directories
chmod 755 app/ themes/ plugins/ languages/
chmod 750 stockage/ uploads/

# Files
chmod 644 *.php
chmod 600 stockage/json/config.json

Directory Protection

Protect sensitive directories:

# .htaccess in stockage/
<FilesMatch "\.(json|log)$">
    Deny from all
</FilesMatch>

PHP Configuration

Secure PHP settings:

; Disable dangerous functions
disable_functions = exec,passthru,shell_exec,system

; Hide PHP version
expose_php = Off

; Error reporting (production)
display_errors = Off
log_errors = On

Security Headers

Add security headers:

# Apache .htaccess
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "0"
Header set Referrer-Policy "strict-origin-when-cross-origin"
# Nginx configuration
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "0";
add_header Referrer-Policy "strict-origin-when-cross-origin";

Content Security Policy with Nonces (since 5.3.7)

Flatboard's response sets a strict CSP without 'unsafe-inline' or 'unsafe-eval' in script-src / script-src-elem. Inline scripts are allowed only when they carry the per-request nonce.

How it works:

  • A new CspNonce class generates a cryptographically random 16-byte nonce per request (random_bytes(16))
  • The nonce is embedded in the script-src directive as 'nonce-{nonce}'
  • An output-buffer callback in App::run() automatically injects nonce="{nonce}" into every <script> tag — no view or plugin needs to be modified

upgrade-insecure-requests is also added to the CSP, telling browsers to silently rewrite any http:// sub-resource request to https:// before sending it. This eliminates mixed-content warnings without requiring a server-side redirect.

If a plugin or theme emits inline JavaScript via a path that bypasses the output buffer (extremely rare), the script will be blocked by the browser. The fix is either to register it through the normal asset loaders or to add the nonce attribute manually using CspNonce::get().

Regular Security Maintenance

Checklist

  • [ ] Update Regularly - Keep Flatboard 5 updated
  • [ ] Update Plugins - Update plugins regularly
  • [ ] Review Logs - Check error and access logs
  • [ ] Monitor Activity - Watch for suspicious activity
  • [ ] Backup Regularly - Maintain regular backups
  • [ ] Review Permissions - Audit user permissions
  • [ ] Test Security - Perform security audits

Security Audits

Regular security audits:

  1. Review Users - Check for suspicious accounts
  2. Check Permissions - Verify permission settings
  3. Review Logs - Analyze error and access logs
  4. Test Updates - Test updates in staging
  5. Monitor Performance - Watch for unusual activity

Incident Response

If Compromised

  1. Isolate - Take site offline if necessary
  2. Assess - Determine extent of compromise
  3. Contain - Prevent further damage
  4. Remediate - Fix vulnerabilities
  5. Notify - Inform users if data exposed
  6. Document - Document incident and response

Prevention

  • Regular Backups - Maintain backups
  • Monitor Logs - Watch for anomalies
  • Update Promptly - Apply security updates
  • Limit Access - Minimize admin access
  • Use HTTPS - Encrypt all connections

Resources

Last updated: May 31, 2026