Skip to content

Security

Technomantus Corvi edited this page Sep 5, 2026 · 1 revision

Security

Risk Mitigation
XSS Use e() in every view
SQL injection All Model methods use prepared statements; column/table names are validated against a strict identifier pattern
Credential exposure Credentials only live in .env (excluded from git)
Error exposure APP_DEBUG=false in production
Source code exposure Only public/ is reachable by the web server; core/, controllers/, models/, config/ sit outside the document root
Directory listing Options -Indexes in public/.htaccess
Sensitive files (Nginx) deny all for .env, .git, .htaccess
Spoofed client IP Request::ip() trusts X-Forwarded-For, which any client can set — only rely on it behind a trusted reverse proxy that overwrites that header
Password storage password_hash()/password_verify() (bcrypt), never plain text — see Authentication
Password reset tokens Stored as SHA-256 hashes, single-use (deleted after redemption), 1-hour expiry
Session fixation session_regenerate_id(true) on login and logout
Email enumeration /forgot-password shows the same message whether or not the email is registered

CSRF protection (opt-in)

Tanuki doesn't enforce CSRF verification by default — the base skeleton avoids anything that would run silently on every request without you choosing to add it.

csrf_token()      // returns the current session's token (creates it on first call)
csrf_field()      // renders a ready-to-use hidden <input>
csrf_verify($tok) // timing-safe comparison against the session's token

1. Add the field to any form that mutates data:

<form method="POST" action="/todo">
    <?= csrf_field() ?>
    <!-- ... -->
</form>

2. Verify it at the top of the controller action:

public function store(): void
{
    if (!csrf_verify($this->request->post('_token'))) {
        $this->flash('error', 'Your session expired. Please try again.');
        $this->redirect('/todo/create');
    }
    // ...
}

Why this isn't wired into the router by default: enforcing it globally for every POST/PUT/DELETE route would require every form in every project — including webhooks and API-style endpoints that don't use sessions — to carry a token, whether they need it or not. Keeping it explicit per form keeps the base skeleton unopinionated. The example TODO CRUD and the tanuki_login/tanuki_admin extensions already use it on every mutating form — copy that pattern for your own resources.

Why public/ is the only exposed folder

Everything except public/ sits outside the web server's document root. core/, controllers/, models/, and config/ can never be requested directly by URL, no matter how the server is configured. Same pattern used by Laravel, Symfony, and most modern PHP frameworks — see Project Structure.

Clone this wiki locally