fix: harden sessions and domain validation

This commit is contained in:
Marco0300
2026-09-01 18:58:18 +02:00
parent 480494c5ed
commit 9f5142b071
9 changed files with 40 additions and 16 deletions
+19 -11
View File
@@ -3,8 +3,11 @@ declare(strict_types=1);
require_once __DIR__ . '/../config/bootstrap.php';
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
session_set_cookie_params(['httponly' => true, 'secure' => !empty($_SERVER['HTTPS']), 'samesite' => 'Lax']);
ini_set('session.use_strict_mode', '1');
$forwardedHttps = getenv('TRUST_PROXY') === '1' && scalar_input($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
session_set_cookie_params(['httponly' => true, 'secure' => !empty($_SERVER['HTTPS']) || $forwardedHttps, 'samesite' => 'Lax', 'path' => '/']);
session_start();
function render_header(string $title): void
@@ -12,7 +15,7 @@ function render_header(string $title): void
$user = current_user();
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . e($title) . ' · JOBcard</title><link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"><link href="/assets/app.css" rel="stylesheet"></head><body>';
if ($user) {
echo '<nav class="navbar navbar-dark bg-primary"><div class="container-fluid"><a class="navbar-brand fw-bold" href="/?route=dashboard">JOBcard</a><span class="text-white small">' . e($user['name']) . ' · ' . e($user['role_name']) . ' <a class="btn btn-sm btn-light ms-2" href="/?route=logout">Sign out</a></span></div></nav><div class="container-fluid"><div class="row"><aside class="col-md-2 col-lg-2 border-end bg-white min-vh-100 p-3"><nav class="nav flex-column gap-1"><a class="nav-link sidebar-link" href="/?route=dashboard">Dashboard</a>';
echo '<nav class="navbar navbar-dark bg-primary"><div class="container-fluid"><a class="navbar-brand fw-bold" href="/?route=dashboard">JOBcard</a><span class="text-white small">' . e($user['name']) . ' · ' . e($user['role_name']) . ' <form method="post" action="/?route=logout" class="d-inline"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><button class="btn btn-sm btn-light ms-2">Sign out</button></form></span></div></nav><div class="container-fluid"><div class="row"><aside class="col-md-2 col-lg-2 border-end bg-white min-vh-100 p-3"><nav class="nav flex-column gap-1"><a class="nav-link sidebar-link" href="/?route=dashboard">Dashboard</a>';
if (can('clients.view')) echo '<a class="nav-link sidebar-link" href="/?route=clients">Clients</a>';
if (can('jobcards.view')) echo '<a class="nav-link sidebar-link" href="/?route=jobcards">Jobcards</a>';
if (can('reports.view')) echo '<a class="nav-link sidebar-link" href="/?route=reports">Reports</a>';
@@ -29,9 +32,11 @@ function render_footer(): void
echo '</main>' . ($user ? '</div></div>' : '') . '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script></body></html>';
}
$route = $_GET['route'] ?? (current_user() ? 'dashboard' : 'login');
$route = scalar_input($_GET['route'] ?? null, current_user() ? 'dashboard' : 'login');
if ($route === 'logout') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit('Logout requires POST'); }
verify_csrf();
if (current_user()) audit('logout', 'user', (int)current_user()['id']);
$_SESSION = [];
session_destroy();
@@ -45,9 +50,9 @@ if ($route === 'login') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.email = :email LIMIT 1');
$stmt->execute(['email' => strtolower(trim((string)($_POST['email'] ?? ''))) ]);
$stmt->execute(['email' => strtolower(trim(scalar_input($_POST['email'] ?? null))) ]);
$user = $stmt->fetch();
if (!$user || !$user['is_active'] || !password_verify((string)($_POST['password'] ?? ''), $user['password_hash'])) {
if (!$user || !$user['is_active'] || !password_verify(scalar_input($_POST['password'] ?? null), $user['password_hash'])) {
$error = 'The email or password is incorrect.';
} else {
session_regenerate_id(true);
@@ -74,9 +79,9 @@ if ($route === 'jobcards') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_permission('jobcards.manage');
verify_csrf();
$clientId = filter_var($_POST['client_id'] ?? null, FILTER_VALIDATE_INT);
$workRequested = trim((string)($_POST['work_requested'] ?? ''));
$priority = (string)($_POST['priority'] ?? 'normal');
$clientId = filter_var(scalar_input($_POST['client_id'] ?? null), FILTER_VALIDATE_INT);
$workRequested = trim(scalar_input($_POST['work_requested'] ?? null));
$priority = scalar_input($_POST['priority'] ?? null, 'normal');
if (!$clientId || $workRequested === '' || mb_strlen($workRequested) > 10000 || !in_array($priority, ['low', 'normal', 'high', 'critical'], true)) {
$errors[] = 'Select a client, enter the requested work, and choose a valid priority.';
} else {
@@ -85,7 +90,10 @@ if ($route === 'jobcards') {
if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.';
}
if (!$errors) {
$reference = 'JC-' . date('Ymd') . '-' . strtoupper(bin2hex(random_bytes(3)));
$year = (int)date('Y');
$sequenceStmt = db()->prepare('SELECT COALESCE(MAX(CAST(SUBSTRING(reference_no, 9) AS UNSIGNED)), 0) + 1 FROM jobcards WHERE reference_no LIKE :prefix');
$sequenceStmt->execute(['prefix' => 'JC-' . $year . '-%']);
$reference = \App\Domain\Jobcard\JobcardReference::generate((int)$sequenceStmt->fetchColumn(), $year);
$stmt = db()->prepare('INSERT INTO jobcards (reference_no, client_id, created_by, priority, status, work_requested) VALUES (:reference, :client, :created_by, :priority, \'new\', :requested)');
$stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]);
$jobcardId = (int)db()->lastInsertId();
@@ -111,7 +119,7 @@ if ($route === 'jobcards') {
if ($route === 'client') {
require_permission('clients.view');
$clientId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
$clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
if (!$clientId) { http_response_code(400); exit('Invalid client'); }
$stmt = db()->prepare('SELECT * FROM clients WHERE id = :id');
$stmt->execute(['id' => $clientId]);
@@ -148,7 +156,7 @@ if ($route === 'clients') {
exit;
}
}
$search = trim((string)($_GET['q'] ?? ''));
$search = trim(scalar_input($_GET['q'] ?? null));
$stmt = db()->prepare('SELECT id, name, status, support_email, support_phone, created_at FROM clients WHERE (:search = \'\' OR name LIKE :like_name OR support_email LIKE :like_email) ORDER BY name LIMIT 100');
$stmt->execute(['search' => $search, 'like_name' => "%{$search}%", 'like_email' => "%{$search}%"]);
$clients = $stmt->fetchAll();