public/ as docroot
.env, vendor/, core/, config/, storage/ live outside the webroot. A misconfigured .htaccess rule can't leak them. Visible banner if your docroot is misconfigured.
your-site/
├── .env ← unreachable
├── vendor/ ← unreachable
├── core/ ← unreachable
└── public/ ← ← DOCROOT HERE
└── index.php
Operator unlock - no IP allowlist required
Set a passphrase in .env. Visit ?_op=<key> once - cookie sticks for a year. /debug, /theme/save, the framework nav are gated through it.
# .env
OPERATOR_KEY=long-random-passphrase
# Then once:
https://yoursite/?_op=long-random-passphrase
# → bc_op_mode cookie set (Secure, HttpOnly,
# SameSite=Lax, year expiry)
CSRF, body limits, scheme allowlist
Timing-safe CSRF on every mutating request. JSON body cap + depth limit. CleanInput::url() rejects javascript: / data: / file: schemes.
// config.php
'http' => [
'max_body_bytes' => 1_048_576,
'json_max_depth' => 64,
],
'security' => [
'csrf' => true,
'csp' => "default-src 'self'; ...",
'safe_ips' => ['127.0.0.1', '::1'],
'operator_key' => Env::get('OPERATOR_KEY', ''),
],
Rate-limit that doesn't trip browsers
Mutating methods only (POST / PUT / PATCH / DELETE). Safe IPs exempt. A frantic browser refresh never returns 429.
// Per-action limit for login etc.
$rl = $this->c->rateLimiter();
if (!$rl->attempt('login:'.$ip, 5, 60)) {
return $this->fail('Too many', 429);
}