Skip to content

Pramnos Security Guide

Security is a core concern in Pramnos Framework v1.2. This guide covers built-in security features and best practices.

CSRF Protection

Session Token Hardening (v1.2)

Change Before After
Session token entropy random_bytes(5) → 40-bit random_bytes(32) → 256-bit
Fingerprint algorithm md5($ua . $ip . $token) hash_hmac('sha256', $ua . $ip, $token)
Existing sessions Silently upgraded on first request

New methods on Session:

Method Description
getCsrfToken(): string Returns/generates the synchronizer CSRF token (256-bit)
verifyCsrfToken(string $submitted): bool Timing-safe comparison via hash_equals()
regenerateCsrfToken(): void Regenerate the CSRF token (call after login/logout)

CsrfMiddleware

Validates the token on POST, PUT, PATCH, DELETE. Passes GET, HEAD, OPTIONS, TRACE through unchecked.

// Global protection for all state-changing routes
$router->addGlobalMiddleware(new CsrfMiddleware());

// Per-route
$router->post('/transfer', fn() => ...)
       ->middleware(new CsrfMiddleware());

Token lookup order per request: 1. $_POST[$fieldName] (default field: _csrf_token) 2. X-CSRF-Token request header

In HTML Forms

<!-- Synchronizer token field -->
<?php echo \Pramnos\Http\Middleware\CsrfMiddleware::tokenField(); ?>
<!-- → <input type="hidden" name="_csrf_token" value="…" /> -->

AJAX / Fetch

<meta name="csrf-token" content="<?php echo \Pramnos\Http\Middleware\CsrfMiddleware::token(); ?>">
fetch('/api/data', {
    method: 'POST',
    headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content }
});

Legacy API (unchanged)

// Existing code — continues to work
$session->checkToken('POST');
$session->getTokenField();

CsrfMiddleware API

Method Description
new CsrfMiddleware(string $fieldName = '_csrf_token') Constructor; custom field name for legacy forms
CsrfMiddleware::token(): string Returns the session CSRF token
CsrfMiddleware::tokenField(string $fieldName = '_csrf_token'): string Returns <input type="hidden"> with HTML-escaped token

Session Security

Three security improvements added transparently:

  1. Session fixation preventionSession::reset() now calls session_regenerate_id(true) on login/logout. An attacker who planted a session ID is immediately locked out.
  2. Strict session ID modesession.use_strict_mode = 1 is set before session_start(). PHP rejects any session ID not generated by itself.
  3. HTTPS detection fixSession::isHttps() now accepts both 'on' (Apache/nginx) and '1' (IIS/CGI).

Usage

The hardening is transparent — existing login/logout code gets protection automatically:

// Session::start() sets strict mode automatically
$session = Session::getInstance();
$session->start();

// Session::reset() regenerates session ID + CSRF token
// Call AFTER setting session data on login/logout
$session->set('userid', $user->userid);
$session->reset();

Session::isHttps()

// Before (fragile — missed IIS/CGI '1' value)
if ($_SERVER['HTTPS'] === 'on') { ... }

// After (handles 'on', '1' consistently)
if (Session::isHttps()) {
    // set Secure cookie, redirect HTTP→HTTPS, etc.
}
// HttpOnly, SameSite=Lax, and Secure (when HTTPS) are set automatically
'session' => [
    'secure'    => true,      // HTTPS only
    'http_only' => true,      // No JavaScript access
    'same_site' => 'Lax',     // CSRF protection
],

Password Security

Hashing

Always hash passwords before storing:

// DO NOT store plain passwords
$plainPassword = $_POST['password'];

// Hash using secure algorithm (bcrypt/scrypt)
$hashedPassword = password_hash($plainPassword, PASSWORD_BCRYPT);

// OR use PHP 8.1+ modern syntax
$hashedPassword = password_hash($plainPassword, PASSWORD_DEFAULT);

// Store $hashedPassword in database

Password Verification

// Verify against stored hash
if (password_verify($plainPassword, $storedHash)) {
    // Password correct
} else {
    // Password incorrect
}

// Check if hash needs rehashing (algorithm updated)
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
    $newHash = password_hash($plainPassword, PASSWORD_DEFAULT);
    // Update database with new hash
}

XSS Prevention

View Escaping Helpers (v1.2)

A global e() function and View::escape() / View::e() instance methods wrap htmlspecialchars() with the safest flags.

Flags used: ENT_QUOTES | ENT_SUBSTITUTE — escapes both single and double quotes; replaces invalid UTF-8 with U+FFFD.

<!-- Short form — most common -->
<h1><?php echo e($model->title); ?></h1>
<p><?php echo e($model->description); ?></p>
<input name="q" value="<?php echo e($request->get('q')); ?>">

<!-- Via $this in a View template -->
<h1><?php echo $this->e($model->title); ?></h1>
<a href="<?php echo $this->escape($model->url); ?>"><?php echo e($model->label); ?></a>

<!-- Trusted HTML — no escaping needed -->
<?php echo $doc->getContent(); ?>

Escaping Table

Input Output
<script>alert(1)</script> &lt;script&gt;alert(1)&lt;/script&gt;
" onclick="alert(1) &quot; onclick=&quot;alert(1)
it's fine it&#039;s fine
AT&T AT&amp;T
null / false '' (empty string)
42 / 3.14 '42' / '3.14'

API Reference

Symbol Description
e(mixed $value, string $encoding = 'UTF-8'): string Global function — HTML-escape a value
View::escape(mixed $value, string $encoding = 'UTF-8'): string Instance method — delegates to e()
View::e(mixed $value, string $encoding = 'UTF-8'): string Short alias for escape()

Context-Aware Escaping

e() is for HTML character escaping only. For other contexts:

// JavaScript context
$escaped = json_encode($text);

// URL context
$escaped = urlencode($text);

// CSS context — whitelist approach
$escaped = preg_replace('![^a-z0-9-]!i', '', $text);

Note: e() does not filter javascript: URIs. Validate and whitelist URLs at the application level, or use CSP.

SQL Injection Prevention

Use Parameterized Queries

// UNSAFE — never do this
$sql = "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'";
$result = $db->query($sql);

// SAFE — use QueryBuilder
$user = $db->queryBuilder()
    ->from('users')
    ->where('email', $_POST['email'])  // Automatically parameterized
    ->first();

// SAFE — use prepareQuery with printf-style
$sql = $db->prepareQuery("SELECT * FROM users WHERE email = %s", $_POST['email']);
$result = $db->query($sql);

QueryBuilder Escaping

The QueryBuilder automatically handles escaping:

$users = $db->queryBuilder()
    ->from('users')
    ->where('username', 'LIKE', '%' . $search . '%')  // Auto-escaped
    ->get();

Authentication

Login/Logout

// Login
$user = \Pramnos\User\User::authenticate($username, $password);
if ($user) {
    $session->set('userid', $user->userid);
    // Success
} else {
    // Authentication failed
}

// Logout
$session->destroy();

Login Lockout

Prevent brute-force attacks:

$lockout = new \Pramnos\Auth\Loginlockout($user);

// Check if user is locked out
if ($lockout->isLocked()) {
    return "Too many login attempts. Try again in " . $lockout->getRemainingTime() . " seconds";
}

// Record failed attempt
$lockout->recordFailure();

// Clear failures on success
$lockout->clearFailures();

Two-Factor Authentication

Protect accounts with 2FA:

$totp = new \Pramnos\Auth\TOTPHelper($user);

// Generate secret
$secret = $totp->generateSecret();

// Verify code
if ($totp->verify($code)) {
    // Code valid
} else {
    // Code invalid
}

Content Security Policy

CSP Headers

Protect against XSS by restricting script sources:

// In controller or middleware
$response->setHeader('Content-Security-Policy', 
    "default-src 'self'; script-src 'nonce-" . $nonce . "'; style-src 'unsafe-inline'");

// In template
<script nonce="<?php echo $nonce; ?>">
    // Only this script executes
</script>

Nonce Generation

$nonce = bin2hex(random_bytes(16));
// Pass to template and validate on execution

File Upload Security

Validate Uploads

if ($_FILES['avatar']['size'] > 5 * 1024 * 1024) {
    throw new \RuntimeException('File too large');
}

$allowed = ['jpg', 'png', 'gif'];
$ext = pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION);

if (!in_array(strtolower($ext), $allowed)) {
    throw new \RuntimeException('File type not allowed');
}

// Verify MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['avatar']['tmp_name']);

if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif'])) {
    throw new \RuntimeException('Invalid file type');
}

// Move to secure location
move_uploaded_file($_FILES['avatar']['tmp_name'], '/secure/uploads/avatar_' . uniqid() . '.jpg');

Dependency Security

Keep Dependencies Updated

# Check for security vulnerabilities
composer audit

# Update dependencies
composer update

# Require security patches
composer require symfony/security --security-advisories

Security Headers

// In base controller or middleware
$response->setHeader('X-Content-Type-Options', 'nosniff');
$response->setHeader('X-Frame-Options', 'SAMEORIGIN');
$response->setHeader('X-XSS-Protection', '1; mode=block');
$response->setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->setHeader('Permissions-Policy', 'geolocation=(), microphone=()');

Reference

For complete technical details, see the inline documentation in the framework source.

Related Guides: - Pramnos_Authentication_Guide.md — Login lockout, 2FA/TOTP, OAuth2 - Pramnos_Framework_Guide.md — Middleware pipeline, CORS, exception handler - Pramnos_Authorization_Guide.md — Policy engine, gates, access control