# Security

This page describes the controls the application implements. To report a vulnerability, see `SECURITY.md` in the project root.

## Request-level controls

| Control | Where it applies |
|---|---|
| CSRF tokens | Every state-changing request, public and admin |
| Server-side authorisation | Every admin and moderator action; hiding a control is never the control |
| Prepared statements | Every SQL parameter, without exception |
| Output escaping | Every value rendered into HTML |
| Secure sessions | HTTP-only cookies, regeneration on privilege change |
| Rate limiting | Sign-in, registration, recovery, posting, reporting, voting |

## Authentication

- Passwords are hashed with PHP's password API.
- Email verification and password reset use one-time, hashed tokens with an expiry.
- A consumed reset token cannot be reused.
- Unknown addresses produce the same response as known ones — recovery does not disclose whether an account exists.
- Rate-limit identifiers are hashed before storage.
- **Editorial and system accounts cannot sign in** regardless of the stored hash.

## Content safety

User-authored rich content is stored as **Markdown or plain source**, never as arbitrary user HTML, and rendered to safe HTML on the server. The editor is a convenience for producing that Markdown; it is not the security boundary.

The renderer refuses script, iframe, event handlers, and unsafe URL schemes. There is no member-facing control for arbitrary font, colour, raw HTML or embedded script.

## The trust boundary for code

Themes and plugins are **PHP**. They are installed over the filesystem. There is no browser upload of executable code and no marketplace.

The reasoning is deliberate: FTP or SSH access already decides who may run code on your server. Adding a browser upload path adds a second, weaker door to the same room.

- Disabled plugins and Safe Mode never execute plugin PHP.
- A plugin exception is isolated and logged; it does not break the site or the admin panel.
- A plugin cannot bypass a security check by returning `true` from a generic filter.
- Theme and plugin paths cannot traverse outside their roots.

## Privileged admin surfaces

Custom CSS, custom code snippets and theme JSON import are administrator-only and validated. Theme JSON import never imports PHP, templates or executable assets — it carries tokens and settings only.

## Content-Security-Policy

`security.strict_csp` tightens the policy. If you enable the optional Turnstile challenge, the challenge provider's origin must be permitted; the settings page documents what to allow.

## Optional challenge

Turnstile is **off by default**. When enabled it is intended for registration, account recovery, and risk-triggered posting — not as a toll booth on every ordinary comment. A challenge on every action trains members to click through challenges, which is worse than not having one.

## Secrets

SMTP password, Telegram bot token and Turnstile secret are never echoed into a page, never written to logs, and never included in a release package or manifest.

## What to verify after any change

- CSRF on every new write path
- Authorisation on every new admin or moderator action
- Prepared parameters in every new query
- Escaped output in every new template
- No secret in any new log line
