Humblee

A humble PHP framework & CMS

Security

Humblee enforces security at the framework level wherever possible. This page documents the mechanisms in place and the patterns every custom controller and model must follow.


CSRF Protection (HMAC Tokens)

Every state-changing POST — whether a form submission or an AJAX call — must carry a valid HMAC token pair. This is not optional; there is no way to disable it. The Xhr base class (require_hmac()) and the form helper (Crypto::get_hmac_pair()) make it straightforward to add this to any endpoint.

How it works

  1. Crypto::get_hmac_pair() generates a one-time key/token pair:

    • key — a random hex nonce
    • tokenbase64(hash_hmac('sha256', key, csrf_session_token))
    • The session CSRF token is a BLAKE2b hash stored in $_SESSION[session_key]['csrf_token']
  2. Both values are included in the request (as hidden form fields or request body fields named hmac_key and hmac_token).

  3. The server validates with Crypto::check_hmac_pair(), which uses a timing-safe comparison.

Tokens are single-use. After each successful AJAX action, the server returns a fresh pair so the next request can proceed. If validation fails, the request is rejected with HTTP 401.

Using HMAC in PHP forms

<?php $hmac = \Humblee\Model\Crypto::get_hmac_pair() ?>
<form method="post">
    <input type="hidden" name="hmac_key"   value="<?php echo $hmac['key'] ?>">
    <input type="hidden" name="hmac_token" value="<?php echo $hmac['token'] ?>">
    <!-- your form fields -->
</form>

Using HMAC in XHR controllers

public function save(): void
{
    $this->require_hmac();       // 401 + exit if invalid
    $this->require_role('admin'); // 403 + exit if unauthorized
    // safe to process POST data now
}

Refreshing the token after an AJAX call

After every successful action, return a fresh pair so the Svelte frontend can make the next request:

Core::json([
    'status' => 'ok',
    'data'   => $result,
    'csrf'   => \Humblee\Model\Crypto::get_hmac_pair(),
]);

On the Svelte side, update the stored csrf object when the response includes one:

const data = await res.json()
if (data.csrf) {
    csrf = data.csrf
}

Authentication Check Order

In any XHR endpoint, always check in this order. Never reverse it.

public function myAction(): void
{
    $this->require_hmac();          // 1. Validate CSRF — always first
    $this->require_role('admin');   // 2. Check role
    // 3. Read and validate input
    // 4. Perform the action
}

Checking roles before HMAC is incorrect — the CSRF token must be validated regardless of whether the role check would also fail. This prevents timing-based information leakage.


Input Validation

Validate all data at the system boundary, before it reaches the ORM or any business logic.

URL slugs

if (!preg_match('/^[\w\-\.]+$/', $slug)) {
    http_response_code(400);
    exit;
}
$page = \ORM::for_table(_table_pages)->where('slug', $slug)->find_one();

Integer IDs

$id = (int) Package::current()->get('id');
if ($id <= 0) {
    Core::json(['error' => 'Invalid ID'], 400);
}

Whitelisting POST fields

Only read the fields you expect. Do not pass $_POST or Package::current()->all() directly to ORM methods.

$package = Package::current();
$record->name    = trim((string) $package->get('name', ''));
$record->sort    = (int) $package->get('sort', 0);
$record->save();

Output Escaping

  • User-controlled strings in PHP views: always escape with htmlspecialchars($value, ENT_QUOTES, 'UTF-8')
  • CMS content and Parsedown output: output directly — these are stored HTML and are treated as trusted
  • JSON responses: json_encode() escapes by default; no additional escaping is needed
// Safe
<span><?php echo htmlspecialchars($user->name, ENT_QUOTES, 'UTF-8') ?></span>

// Also safe — Draw::content() renders stored HTML
<?php Draw::content($content, 'pagebody') ?>

SQL Injection

All queries use the Idiorm ORM, which parameterizes every value. Never concatenate user input into a query string.

// Correct — parameterized
$record = \ORM::for_table(_table_pages)
    ->where('slug', $slug)
    ->find_one();

// Forbidden — string concatenation
$record = \ORM::for_table(_table_pages)
    ->where_raw("slug = '" . $slug . "'")  // never do this
    ->find_one();

The only place raw SQL appears in the framework is in Tools::CRUD() column names, which are validated against an explicit whitelist before use.


Encryption at Rest

Use Crypto::encrypt() and Crypto::decrypt() for any data you want to protect on disk. The framework uses this for media files — the same API is available for your own data.

use Humblee\Model\Crypto;

// Encrypt
$encrypted = Crypto::encrypt($plaintext);
if ($encrypted === false) {
    // handle failure
}

// Decrypt
$plaintext = Crypto::decrypt($encrypted);
if ($plaintext === false) {
    // handle failure — wrong key, corrupted data, or tampered ciphertext
}
  • Algorithm: XSalsa20-Poly1305 authenticated encryption via PHP's native sodium extension
  • Key: 32-byte symmetric key in humblee/configuration/crypto/key.php (never committed to version control)
  • Nonce: 24 bytes, randomly generated per encryption, prepended to the ciphertext — never store it separately

decrypt() returns false if authentication fails (tampered ciphertext, wrong key, or data corruption). Always check the return value.


Media Access Control

Files uploaded to the media library are stored outside the web root (/storage/) and are never served directly by the web server. All file access goes through Humblee\Controller\Media, which enforces per-file role restrictions and handles decryption on the fly. See Media Manager for details.


Forbidden Patterns

The following patterns are prohibited in this codebase and will be rejected in code review:

Pattern Risk
eval() Code injection
preg_replace('/e', ...) Code execution via regex callback
include($_GET[...]) or require($userInput) Path traversal / local file inclusion
Concatenating user input into SQL SQL injection
Hardcoded secrets, API keys, or credentials Secret exposure in version control
MD5 or SHA1 for passwords Broken — use Argon2ID
serialize() on untrusted input PHP object injection
Reading $_POST / $_GET directly instead of Package Bypasses normalization
Checking roles before HMAC in XHR endpoints CSRF token left unvalidated