Security
Context-aware escaping, CSRF, security headers, and the attacks the framework handles before your code runs.
The rule throughout: the safe path is the default path. You do not opt into safety; you opt out of it, visibly.
Escaping
There is no single “escape” function that is correct everywhere. A value safe inside an HTML element is still dangerous in an unquoted attribute, a script block or a URL — so each context has its own method.
<?php
use PhpOrbit\Security\Escaper;
Escaper::html($value); // text inside an element
Escaper::attribute($value); // an attribute value
Escaper::js($value); // a string literal in <script> — includes quotes
Escaper::url($value); // one query parameter or path segment
Escaper::urlAttribute($url); // a whole URL for href/srcIn templates, {{ }} calls html() for you. The others are explicit:
<p>{{ $name }}</p>
<button data-user="{!! Escaper::attribute($name) !!}">Save</button>
<a href="{!! Escaper::urlAttribute($link) !!}">Open</a>
<script>
const user = {!! Escaper::js($name) !!};
</script>Why attribute() is so aggressive
It hex-encodes every character outside [a-zA-Z0-9,._-], which stays safe even when the template author forgot the quotes:
{# Both are safe, which is the point #}
<div title={!! Escaper::attribute($value) !!}>
<div title="{!! Escaper::attribute($value) !!}">js() brings its own quotes
<?php
Escaper::js('hello "world"'); // "hello "world""
// WRONG — you end up with doubled quotes
// <script>const x = "{!! Escaper::js($v) !!}";</script>
// RIGHT
// <script>const x = {!! Escaper::js($v) !!};</script>It encodes as JSON, including the surrounding quotes, because manual backslash escaping reliably misses a case.
urlAttribute() neutralises dangerous schemes
<?php
Escaper::urlAttribute('https://example.test/page'); // escaped, allowed
Escaper::urlAttribute('/local/path'); // relative, allowed
Escaper::urlAttribute('mailto:a@example.test'); // allowed
Escaper::urlAttribute('javascript:alert(1)'); // "#"
Escaper::urlAttribute('data:text/html,<script>…'); // "#"
Escaper::urlAttribute(' javascript:alert(1)'); // "#" — leading space tooOnly http, https and mailto survive. Returning a harmless value rather than throwing keeps one hostile link from taking down a whole page render.
CSRF
Protection is on for every state-changing method. A form needs the token:
<form method="post" action="/articles">
<input type="hidden" name="_token" value="{{ $csrfToken }}">
<input name="title">
<button type="submit">Publish</button>
</form><?php
use PhpOrbit\Security\Csrf;
// In a controller
$token = Csrf::token($session);
// Or a ready-made input
$field = Csrf::field($session); // <input type="hidden" name="_token" value="…">For fetch/XHR, send it as a header instead:
<?php
// X-CSRF-Token: <token>Opting a route out
<?php
// A webhook authenticates by signature, not by session — there is no token to send.
$routes->add(Method::Post, '/webhooks/stripe', StripeWebhook::class, 'webhooks.stripe', csrfExempt: true);Per route, and explicit. Verify the signature in the handler.
How it works
- The token is 256 bits from the CSPRNG, stored in the session, minted on first use.
- It is bound to the session rather than one form, so several open tabs do not invalidate each other.
- Comparison uses
hash_equals—===would leak the correct prefix through timing. - Safe methods (GET, HEAD, OPTIONS) skip the check entirely.
Csrf::rotate()discards the token; the authenticator calls it on login.
A missing or wrong token gives 403 CSRF token missing or invalid.
Security headers
Applied to every response, including error pages:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; base-uri 'self'They live in Response rather than middleware so a response built on the error path still carries them. Overriding them →
Handled before your code runs
Path traversal
$ curl -i 'http://127.0.0.1:8080/files/../../etc/passwd'
HTTP/1.1 400 Bad Request
$ curl -i 'http://127.0.0.1:8080/files/%2E%2E%2F%2E%2E%2Fetc'
HTTP/1.1 400 Bad RequestUri splits on / before decoding, so an encoded separator cannot create a segment, and matches dot segments after decoding, so %2E%2E is caught. Encoded separators are rejected; a path climbing above the root is refused rather than clamped.
Header and response splitting
<?php
Headers::empty()->with('X-Note', "one\r\nX-Injected: yes");
// MalformedRequest: Header values may not contain CR, LF or NUL.Rejected, not stripped. Same for cookie values, which additionally refuse spaces, quotes, commas, semicolons and backslashes.
Request flooding
RequestParser bounds every read: request line, header count, total header bytes, body size. An HTTP parser without limits is a memory-exhaustion primitive — a client that opens a connection and streams header bytes forever would otherwise consume the whole process.
Host header poisoning
FpmSapi prefers SERVER_NAME (your web server's configuration) over the client-supplied Host, so a forged Host cannot poison generated URLs or password-reset links. X-Forwarded-Proto is believed only from a configured trusted proxy:
TRUSTED_PROXIES=10.0.0.1,10.0.0.2Believing it unconditionally would let anyone claim their plaintext request was HTTPS and unlock Secure-only cookies.
Errors keep their secrets
# production
Internal Server Error
# --debug
RuntimeException: SQLSTATE[HY000] … user=admin password=hunter2
in /srv/app/src/Database/Connection.php:142Exception messages carry paths, SQL and credentials, so detail is opt-in. The same applies to configuration: parse errors name the key and line but never the value, and Environment::__debugInfo() redacts everything.
A checklist for your own code
- Use
{{ }}. Reach for{!! !!}only for markup you built. - Never build SQL by interpolation — there is no API that accepts it, so this is mostly about hand-written strings.
- Map user input to identifiers through an allowlist before
orderBy(). - Judge uploads by
detectedType(), never by filename or declared type. - Call
$auth->login()rather than writing the user id into the session yourself — it regenerates the session id and rotates the CSRF token. - Put
required()on secrets in configuration, so a blank value fails at boot.