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
<?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/src

In templates, {{ }} calls html() for you. The others are explicit:

Template
<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:

Template
{# Both are safe, which is the point #}
<div title={!! Escaper::attribute($value) !!}>
<div title="{!! Escaper::attribute($value) !!}">

js() brings its own quotes

PHP
<?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
<?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 too

Only 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:

Template
<form method="post" action="/articles">
    <input type="hidden" name="_token" value="{{ $csrfToken }}">
    <input name="title">
    <button type="submit">Publish</button>
</form>
PHP
<?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
<?php
// X-CSRF-Token: <token>

Opting a route out

PHP
<?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:

Output
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

Shell
$ 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 Request

Uri 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
<?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:

.env
TRUSTED_PROXIES=10.0.0.1,10.0.0.2

Believing it unconditionally would let anyone claim their plaintext request was HTTPS and unlock Secure-only cookies.

Errors keep their secrets

Output
# production
Internal Server Error

# --debug
RuntimeException: SQLSTATE[HY000] … user=admin password=hunter2
  in /srv/app/src/Database/Connection.php:142

Exception 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.