Files
QRForge-selfhosted/src/includes/security.php
T
dillard feb5380b28 Security hardening: CSRF, rate limiting, session/password policy, audit log
Fixes critical pre-existing issues found during review: bulk_action.php had no
auth check at all (unauthenticated download/delete of any qrcode) and built a
table name from unwhitelisted user input (SQL injection); the QR generator
classes wrote files from unvalidated filename/format, allowing path traversal
and arbitrary file writes. Also pins chillerlan/php-qrcode to 5.0.5 since
master now requires PHP 8.4, breaking the PHP 8.3 build.

- CSRF tokens on all POST forms and the bulk_action.php JSON endpoint
- Login rate limiting (5 attempts / 15 min) via new login_attempts table
- Hardened sessions: httponly/samesite cookies, 30 min idle timeout,
  session regeneration on login
- Forced password change for the default superadmin/superadmin account
- Server-side validation in Users/DynamicQrcode/Qrcode classes
- Audit log table for auth, user, and qrcode actions
- Checked-in db schema (db/init.sql, migrations/) instead of relying on an
  opaque prebuilt db image
- Production docker-compose with Nginx + php-fpm instead of the PHP dev server

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 15:00:33 +02:00

162 lines
4.5 KiB
PHP

<?php
/**
* Fase 1 security hardening: sessiebeheer, CSRF, rate limiting, audit log.
* Wordt geladen via includes/bootstrap.php, dat als eerste in elke entrypoint hoort te staan.
*/
define('SESSION_IDLE_TIMEOUT', 30 * 60); // 30 minuten inactiviteit -> uitloggen
define('LOGIN_MAX_ATTEMPTS', 5);
define('LOGIN_LOCKOUT_WINDOW', 15 * 60); // 15 minuten
/**
* Start de sessie met verharde cookie-instellingen. Moet vóór elke output aangeroepen worden.
*/
function qr_session_start() {
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$is_https = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
ini_set('session.gc_maxlifetime', (string) SESSION_IDLE_TIMEOUT);
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => $is_https,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
function qr_client_ip() {
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
/**
* Logt de gebruiker uit als de sessie te lang inactief is geweest.
*/
function qr_enforce_session_timeout() {
if (empty($_SESSION['user_logged_in'])) {
return;
}
$now = time();
if (isset($_SESSION['last_activity']) && ($now - $_SESSION['last_activity']) > SESSION_IDLE_TIMEOUT) {
$_SESSION = [];
$_SESSION['login_failure'] = 'Je sessie is verlopen wegens inactiviteit. Log opnieuw in.';
header('Location: login.php');
exit;
}
$_SESSION['last_activity'] = $now;
}
/**
* Stuurt ingelogde gebruikers met een verplichte wachtwoordwijziging naar change_password.php,
* behalve op de wijzigingspagina en logout zelf.
*/
function qr_enforce_password_change() {
if (empty($_SESSION['user_logged_in']) || empty($_SESSION['must_change_password'])) {
return;
}
$current_script = basename(parse_url($_SERVER['SCRIPT_NAME'], PHP_URL_PATH));
$exempt = ['change_password.php', 'logout.php'];
if (in_array($current_script, $exempt, true)) {
return;
}
header('Location: change_password.php');
exit;
}
/**
* CSRF-bescherming
*/
function csrf_token() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field() {
return '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') . '">';
}
function csrf_is_valid($token) {
return isset($_SESSION['csrf_token']) && is_string($token) && hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Voor klassieke form-POSTs: verwacht een verborgen veld "csrf_token".
*/
function csrf_verify_or_die() {
if (!csrf_is_valid($_POST['csrf_token'] ?? '')) {
http_response_code(403);
exit('403 Forbidden: invalid or missing CSRF token.');
}
}
/**
* Voor JSON/AJAX-endpoints (bv. bulk_action.php): verwacht header X-CSRF-Token.
*/
function csrf_verify_header_or_die() {
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!csrf_is_valid($token)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['data' => 'Invalid or missing CSRF token', 'status' => 403]);
exit;
}
}
/**
* Rate limiting op login
*/
function qr_record_login_attempt($username, $success) {
$db = getDbInstance();
$db->insert('login_attempts', [
'username' => $username,
'ip_address' => qr_client_ip(),
'success' => $success ? 1 : 0,
'attempted_at' => date('Y-m-d H:i:s'),
]);
}
function qr_is_login_locked_out($username) {
$db = getDbInstance();
$window_start = date('Y-m-d H:i:s', time() - LOGIN_LOCKOUT_WINDOW);
$db->where('username', $username);
$db->where('success', 0);
$db->where('attempted_at', $window_start, '>=');
$count = $db->getValue('login_attempts', 'count(*)');
return $count !== null && $count >= LOGIN_MAX_ATTEMPTS;
}
/**
* Audit log
*/
function audit_log($action, $target_type = null, $target_id = null) {
$db = getDbInstance();
$db->insert('audit_log', [
'user_id' => $_SESSION['user_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'action' => $action,
'target_type' => $target_type,
'target_id' => $target_id !== null ? (string) $target_id : null,
'ip_address' => qr_client_ip(),
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
'created_at' => date('Y-m-d H:i:s'),
]);
}