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>
This commit is contained in:
2026-07-08 15:00:33 +02:00
parent 268a6a3f65
commit feb5380b28
48 changed files with 946 additions and 127 deletions
+57 -34
View File
@@ -1,66 +1,89 @@
<?php
require_once 'config/config.php';
session_start();
require_once 'includes/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST')
{
csrf_verify_or_die();
$username = filter_input(INPUT_POST, 'username');
$password = filter_input(INPUT_POST, 'password');
$remember = filter_input(INPUT_POST, 'remember');
if (!$username || !$password) {
$_SESSION['login_failure'] = 'Invalid username or password';
header('Location: login.php');
exit;
}
if (qr_is_login_locked_out($username)) {
$_SESSION['login_failure'] = 'Too many failed login attempts. Try again in 15 minutes.';
header('Location: login.php');
exit;
}
// Get DB instance.
$db = getDbInstance();
$db->where('username', $username);
$row = $db->getOne('users');
if ($db->count >= 1)
if ($db->count >= 1 && password_verify($password, $row['password']))
{
$db_password = $row['password'];
qr_record_login_attempt($username, true);
// Voorkom session fixation: nieuwe sessie-id na een geslaagde login.
session_regenerate_id(true);
$_SESSION['user_logged_in'] = TRUE;
$_SESSION['type'] = $row['type'];
$_SESSION['user_id'] = $row['id'];
$_SESSION['username'] = $row['username'];
$_SESSION['must_change_password'] = !empty($row['must_change_password']);
$_SESSION['last_activity'] = time();
audit_log('login_success');
$user_id = $row['id'];
if (password_verify($password, $db_password))
if ($remember)
{
$_SESSION['user_logged_in'] = TRUE;
$_SESSION['type'] = $row['type'];
$_SESSION['user_id'] = $row['id'];
$series_id = randomString(16);
$remember_token = getSecureRandomToken(20);
$encryted_remember_token = password_hash($remember_token,PASSWORD_DEFAULT);
if ($remember)
{
$series_id = randomString(16);
$remember_token = getSecureRandomToken(20);
$encryted_remember_token = password_hash($remember_token,PASSWORD_DEFAULT);
$expiry_time = date('Y-m-d H:i:s', strtotime(' + 30 days'));
$expires = strtotime($expiry_time);
$is_https = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$expiry_time = date('Y-m-d H:i:s', strtotime(' + 30 days'));
$expires = strtotime($expiry_time);
$cookie_options = [
'expires' => $expires,
'path' => '/',
'secure' => $is_https,
'httponly' => true,
'samesite' => 'Lax',
];
setcookie('series_id', $series_id, $expires, '/');
setcookie('remember_token', $remember_token, $expires, '/');
setcookie('series_id', $series_id, $cookie_options);
setcookie('remember_token', $remember_token, $cookie_options);
$db = getDbInstance();
$db->where ('id',$user_id);
$db = getDbInstance();
$db->where ('id',$user_id);
$update_remember = array(
'series_id'=> $series_id,
'remember_token' => $encryted_remember_token,
'expires' =>$expiry_time
);
$db->update('users', $update_remember);
}
// Authentication successfull redirect user
header('Location: index.php');
}
else
{
$_SESSION['login_failure'] = 'Invalid username or password';
header('Location: login.php');
$update_remember = array(
'series_id'=> $series_id,
'remember_token' => $encryted_remember_token,
'expires' =>$expiry_time
);
$db->update('users', $update_remember);
}
// Authentication successfull redirect user
header('Location: index.php');
exit;
}
else
{
qr_record_login_attempt($username, false);
$_SESSION['login_failure'] = 'Invalid username or password';
header('Location: login.php');
exit;
+31 -17
View File
@@ -1,9 +1,14 @@
<?php
session_start();
require_once 'config/config.php';
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
require_once BASE_PATH . '/lib/DynamicQrcode/DynamicQrcode.php';
require_once BASE_PATH . '/lib/StaticQrcode/StaticQrcode.php';
header('Content-Type: application/json');
csrf_verify_header_or_die();
$allowed_types = ['dynamic', 'static'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db = getDbInstance();
$json = json_decode(file_get_contents('php://input'), true);
@@ -12,8 +17,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$params = $json['params'];
$files = [];
if (isset($json['type'])) {
$type = filter_var($json['type'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if (isset($json['type']) && in_array($json['type'], $allowed_types, true)) {
$type = $json['type'];
} else {
echo json_encode([
'data' => 'Type action field in the request.',
@@ -31,9 +36,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
foreach ($params as $param) {
$row = $db->where('id', $param);
$db->where('id', $param);
if ($_SESSION['type'] !== 'super') {
$db->where('id_owner', $_SESSION['user_id']);
$db->orWhere('id_owner', NULL, 'IS');
}
$row = $db->getOne("{$type}_qrcodes");
@$files[] = SAVED_QRCODE_FOLDER . $row['qrcode'];
if ($row !== NULL) {
$files[] = SAVED_QRCODE_FOLDER . $row['qrcode'];
}
}
$zip = new ZipArchive();
@@ -50,6 +61,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$zip->close();
audit_log('bulk_download', $type, implode(',', $params));
echo json_encode([
'data' => $url_path,
'status' => 200
@@ -57,10 +70,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit();
} else if($json["action"] == "delete") {
$params = $json['params'];
$files = [];
if (isset($json['type'])) {
$type = filter_var($json['type'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if (isset($json['type']) && in_array($json['type'], $allowed_types, true)) {
$type = $json['type'];
} else {
echo json_encode([
'data' => 'Type action field in the request.',
@@ -79,16 +91,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if($type == "dynamic")
$instance = new DynamicQrcode();
else if($type == "static")
$instance = new StaticQrcode();
else
die("Type not allowed");
$instance = new StaticQrcode();
foreach ($params as $param) {
$a = 0;
$instance->deleteQrcode($param, true);
}
audit_log('bulk_delete', $type, implode(',', $params));
echo json_encode([
'action' => "delete",
'data' => "Qrcode deleted",
@@ -96,9 +107,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]);
exit();
} else
exit("Action not allowed");
} else {
echo json_encode(['data' => 'Action not allowed', 'status' => 400]);
exit();
}
} else {
exit('Direct access to this script not allowed.');
http_response_code(405);
echo json_encode(['data' => 'Direct access to this script not allowed.', 'status' => 405]);
exit();
}
?>
+99
View File
@@ -0,0 +1,99 @@
<?php
require_once 'includes/bootstrap.php';
if (empty($_SESSION['user_logged_in'])) {
header('Location: login.php');
exit;
}
$forced = !empty($_SESSION['must_change_password']);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify_or_die();
$current_password = $_POST['current_password'] ?? '';
$new_password = $_POST['new_password'] ?? '';
$confirm_password = $_POST['confirm_password'] ?? '';
$db = getDbInstance();
$db->where('id', $_SESSION['user_id']);
$user = $db->getOne('users');
if ($user === NULL || !password_verify($current_password, $user['password'])) {
$_SESSION['failure'] = 'Current password is incorrect.';
} elseif (strlen($new_password) < 10) {
$_SESSION['failure'] = 'New password must be at least 10 characters long.';
} elseif ($new_password !== $confirm_password) {
$_SESSION['failure'] = 'New password and confirmation do not match.';
} elseif ($new_password === $current_password) {
$_SESSION['failure'] = 'New password must be different from the current password.';
} else {
$db = getDbInstance();
$db->where('id', $_SESSION['user_id']);
$db->update('users', [
'password' => password_hash($new_password, PASSWORD_DEFAULT),
'must_change_password' => 0,
'password_changed_at' => date('Y-m-d H:i:s'),
]);
$_SESSION['must_change_password'] = false;
audit_log('password_changed');
$_SESSION['success'] = 'Password updated successfully.';
header('Location: index.php');
exit;
}
}
?>
<!DOCTYPE html>
<html lang="en">
<title>Change password - Qrcode Generator</title>
<?php include './includes/head.php'; ?>
<body class="login-page" style="min-height: 512.391px;">
<div class="login-box">
<div class="login-logo">
<img src="dist/img/DynamicQRCode_Original.png" style="width: 95%; height: 95%">
</div>
<div class="card">
<div class="card-body login-card-body">
<p class="login-box-msg">
<?php echo $forced
? 'You must change your password before continuing.'
: 'Change your password'; ?>
</p>
<?php include './includes/flash_messages.php'; ?>
<form method="POST" action="change_password.php">
<?php echo csrf_field(); ?>
<div class="input-group mb-3">
<input type="password" name="current_password" class="form-control" placeholder="Current password" required="required" autocomplete="current-password">
</div>
<div class="input-group mb-3">
<input type="password" name="new_password" class="form-control" placeholder="New password (min. 10 characters)" required="required" minlength="10" autocomplete="new-password">
</div>
<div class="input-group mb-3">
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm new password" required="required" minlength="10" autocomplete="new-password">
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-primary btn-block">Update password</button>
</div>
</div>
</form>
<?php if (!$forced): ?>
<p class="mt-3 text-center"><a href="index.php">Back to dashboard</a></p>
<?php endif; ?>
</div>
</div>
</div>
<script src="../../plugins/jquery/jquery.min.js"></script>
<script src="../../plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="../../dist/js/adminlte.js"></script>
</body>
</html>
+3
View File
@@ -470,6 +470,9 @@
data: JSON.stringify(data),
dataType: "json",
contentType: 'application/json',
headers: {
'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
},
success: (res) => {
if (res.status == 200) {
if(data["action"] === "download") {
+6 -2
View File
@@ -1,11 +1,14 @@
<?php
session_start();
require_once 'config/config.php';
require_once 'includes/bootstrap.php';
require_once BASE_PATH.'/includes/auth_validate.php';
require_once BASE_PATH . '/lib/DynamicQrcode/DynamicQrcode.php';
$dynamic_qrcode_instance = new DynamicQrcode();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify_or_die();
}
$edit = false;
if($_SERVER["REQUEST_METHOD"] === "GET" && isset($_GET["edit"]) && $_GET["edit"] == "true" && isset($_GET["id"])) {
$edit = true;
@@ -83,6 +86,7 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"])) {
<h3 class="card-title">Enter the requested data</h3>
</div>
<form class="form" action="" method="post" id="dynamic_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<div class="card-body">
<?php
if($edit)
+1 -2
View File
@@ -1,6 +1,5 @@
<?php
session_start();
require_once 'config/config.php';
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
require_once BASE_PATH . '/lib/DynamicQrcode/DynamicQrcode.php';
+1 -1
View File
@@ -20,7 +20,7 @@
<span class="input-group-text"><i class="fa fa-lock"></i></span>
</div>
<input type="password" name="password" placeholder="Password" class="form-control" required="required" autocomplete="off">
<input type="password" name="password" placeholder="<?php echo ($edit) ? 'Leave blank to keep current password' : 'Password'; ?>" class="form-control" <?php echo ($edit) ? '' : 'required="required"'; ?> minlength="10" autocomplete="off">
</div>
</div>
</div>
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=2fa" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=bitcoin" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=bookmark" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=email" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=event" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-4">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=location" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-4">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=paypal" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=phone" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=skype" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-4">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=sms" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=text" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-6">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=vcard" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<!-- First row -->
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=whatsapp" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
+1
View File
@@ -1,4 +1,5 @@
<form class="form" action="static_qrcode.php?type=wifi" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<?php include BASE_PATH.'/forms/qrcode_options.php'; ?>
<!-- Input forms -->
<div class="col-sm-12 mb-2">
+2
View File
@@ -3,6 +3,7 @@
<div id="err-msg"></div>
<div class="bulk-action-wrapper">
<form id="bulk-action" action="bulk_action.php" method="POST">
<?php echo csrf_field(); ?>
<div class="col-sm-12 mb-2" style="margin-left: 10px">
<div class="row">
<div class="col-5 col-md-2">
@@ -100,6 +101,7 @@
<div class="modal fade" id="delete-modal" role="dialog">
<div class="modal-dialog">
<form action="dynamic_qrcode.php" method="POST">
<?php echo csrf_field(); ?>
<!-- Modal content -->
<div class="modal-content">
+2
View File
@@ -3,6 +3,7 @@
<div id="err-msg"></div>
<div class="bulk-action-wrapper">
<form id="bulk-action" action="bulk_action.php" method="POST">
<?php echo csrf_field(); ?>
<div class="col-sm-12 mb-2" style="margin-left: 10px">
<div class="row">
<div class="col-5 col-md-2">
@@ -96,6 +97,7 @@
<div class="modal fade" id="delete-modal" role="dialog">
<div class="modal-dialog">
<form action="static_qrcode.php" method="POST">
<?php echo csrf_field(); ?>
<!-- Modal content -->
<div class="modal-content">
+1
View File
@@ -47,6 +47,7 @@
<div class="modal fade" id="delete-modal" role="dialog">
<div class="modal-dialog">
<form action="user.php" method="POST">
<?php echo csrf_field(); ?>
<!-- Modal content -->
<div class="modal-content">
+14
View File
@@ -0,0 +1,14 @@
<?php
/**
* Centrale bootstrap voor elke entrypoint: config laden, sessie starten met
* verharde instellingen, sessie-timeout en verplichte wachtwoordwijziging afdwingen.
*
* Vervangt de losse "session_start(); require_once 'config/config.php';" aanroepen.
*/
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/security.php';
qr_session_start();
qr_enforce_session_timeout();
qr_enforce_password_change();
+1
View File
@@ -1,6 +1,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="csrf-token" content="<?php echo csrf_token(); ?>">
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="plugins/fontawesome-free/css/all.min.css">
+2 -6
View File
@@ -16,14 +16,10 @@
</a>
<div class="dropdown-menu dropdown-menu-lg dropdown-menu-right">
<div class="dropdown-divider"></div>
<!--<a href="#" class="dropdown-item">
<i class="fas fa-user"></i> Profile
<a href="./change_password.php" class="dropdown-item">
<i class="fas fa-key"></i> Change password
</a>
<div class="dropdown-divider"></div>
<a href="#" class="dropdown-item">
<i class="fa fa-cog"></i> Settings
</a>-->
<div class="dropdown-divider"></div>
<a href="./logout.php" class="dropdown-item">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
+161
View File
@@ -0,0 +1,161 @@
<?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'),
]);
}
+1 -11
View File
@@ -1,15 +1,5 @@
<?php
//Use httponly flag
ini_set('session.cookie_httponly', 1);
//Use only cookies
ini_set('session.use_only_cookies', 1);
//Use secure flag
ini_set('session.cookie_secure', 1);
session_start();
require_once './config/config.php';
require_once 'includes/bootstrap.php';
require_once 'includes/auth_validate.php';
$db = getDbInstance();
+15
View File
@@ -56,6 +56,8 @@ class DynamicQrcode {
* We save into db the url of qrcode image
*/
public function addQrcode($input_data) {
$this->validateLink($input_data['link'] ?? '');
if($input_data['id_owner'] != "")
$data_to_db['id_owner'] = $input_data['id_owner'];
else
@@ -79,6 +81,8 @@ class DynamicQrcode {
*
*/
public function editQrcode($input_data) {
$this->validateLink($input_data['link'] ?? '');
if($input_data['id_owner'] != "")
$data_to_db['id_owner'] = $input_data['id_owner'];
else
@@ -117,6 +121,17 @@ class DynamicQrcode {
}
/**
* Server-side validatie van de redirect-link (verplicht, max. 500 tekens per kolomdefinitie).
*/
private function validateLink($link) {
$link = trim((string) $link);
if ($link === '' || strlen($link) > 500) {
$this->failure('Link is required and must be at most 500 characters.');
}
}
/**
* Flash message Failure process
*/
+44 -5
View File
@@ -20,6 +20,8 @@ class Qrcode {
private string $table;
private string $redirect_url;
const ALLOWED_FORMATS = ['png', 'gif', 'jpeg', 'jpg', 'svg', 'svgbw', 'eps'];
/**
*
*/
@@ -43,6 +45,33 @@ class Qrcode {
{
}
/**
* Voorkomt path traversal / arbitrary file write via een gemanipuleerde bestandsnaam.
*/
private function sanitizeFilename($filename) {
$filename = trim((string) $filename);
if ($filename === '' || strlen($filename) > 45) {
$this->failure('Filename must be between 1 and 45 characters.');
}
if (preg_match('#[\\/\\\\]#', $filename) || strpos($filename, '..') !== false || strpos($filename, "\0") !== false) {
$this->failure('Filename cannot contain path separators.');
}
return $filename;
}
private function validateFormat($format) {
$format = strtolower((string) $format);
if (!in_array($format, self::ALLOWED_FORMATS, true)) {
$this->failure('Invalid qr code format.');
}
return $format;
}
public function getQrcode($id) {
$db = getDbInstance();
@@ -94,6 +123,9 @@ class Qrcode {
public function addQrcode($input_data, $data_to_db, $data_to_qrcode) {
$options = $this->setOptions($input_data);
$data_to_db['filename'] = $this->sanitizeFilename($data_to_db['filename']);
$data_to_db['format'] = $this->validateFormat($data_to_db['format']);
$outputInterface = QRGdImagePNG::class;
$imageFormat = strtolower($data_to_db['format']);
$fileExt = $imageFormat;
@@ -305,21 +337,23 @@ class Qrcode {
$this->failure('You cannot create a new qr code with an existing name on the server!');
if ($last_id){
audit_log('qrcode_created', $this->table, $last_id);
$this->success('Qr code added successfully!');
}
else {
$this->failure('Insert failed: ' . $db->getLastError());
}
}
/**
* Edit qr code
*
*
*/
public function editQrcode($input_data, $data_to_db) {
$db = getDbInstance();
$old_qrcode = $this->getQrcode($input_data["id"]);
$data_to_db['filename'] = $this->sanitizeFilename($data_to_db['filename']);
$data_to_db['qrcode'] = $data_to_db['filename'].'.'.$old_qrcode["format"];
if(!file_exists(SAVED_QRCODE_DIRECTORY.$data_to_db['filename'].'.'.$old_qrcode["format"]) || $data_to_db['filename'] == $input_data["old_filename"]){
@@ -337,6 +371,7 @@ class Qrcode {
$this->failure('You cannot edit a qr code with an existing name on the server!');
if ($stat){
audit_log('qrcode_updated', $this->table, $input_data['id']);
$this->success('Qr code updated successfully!');
}
else {
@@ -344,10 +379,10 @@ class Qrcode {
}
}
/**
* Delete qr code
*
*
*/
public function deleteQrcode($id, $async = false) {
$db = getDbInstance();
@@ -356,7 +391,11 @@ class Qrcode {
$db->where('id', $id);
$status = $db->delete($this->table);
if ($status) {
audit_log('qrcode_deleted', $this->table, $id);
}
try{
unlink(SAVED_QRCODE_DIRECTORY.$qrcode["filename"].'.'.$qrcode["format"]);
}
+40 -1
View File
@@ -5,6 +5,8 @@ class Qrcode {
private string $table;
private string $redirect_url;
const ALLOWED_FORMATS = ['png', 'gif', 'jpeg', 'jpg', 'svg', 'eps'];
/**
*
*/
@@ -28,6 +30,33 @@ class Qrcode {
{
}
/**
* Voorkomt path traversal / arbitrary file write via een gemanipuleerde bestandsnaam.
*/
private function sanitizeFilename($filename) {
$filename = trim((string) $filename);
if ($filename === '' || strlen($filename) > 45) {
$this->failure('Filename must be between 1 and 45 characters.');
}
if (preg_match('#[\\/\\\\]#', $filename) || strpos($filename, '..') !== false || strpos($filename, "\0") !== false) {
$this->failure('Filename cannot contain path separators.');
}
return $filename;
}
private function validateFormat($format) {
$format = strtolower((string) $format);
if (!in_array($format, self::ALLOWED_FORMATS, true)) {
$this->failure('Invalid qr code format.');
}
return $format;
}
public function getQrcode($id) {
$db = getDbInstance();
@@ -79,6 +108,9 @@ class Qrcode {
public function addQrcode($input_data, $data_to_db, $data_to_qrcode) {
$options = $this->setOptions($input_data);
$data_to_db['filename'] = $this->sanitizeFilename($data_to_db['filename']);
$data_to_db['format'] = $this->validateFormat($data_to_db['format']);
if(!file_exists(SAVED_QRCODE_DIRECTORY.$data_to_db['filename'].'.'.$data_to_db['format'])){
$url =
'https://api.qrserver.com/v1/create-qr-code/?data='.
@@ -111,6 +143,7 @@ class Qrcode {
$this->failure('You cannot create a new qr code with an existing name on the server!');
if ($last_id){
audit_log('qrcode_created', $this->table, $last_id);
$this->success('Qr code added successfully!');
}
else {
@@ -126,6 +159,7 @@ class Qrcode {
$db = getDbInstance();
$old_qrcode = $this->getQrcode($input_data["id"]);
$data_to_db['filename'] = $this->sanitizeFilename($data_to_db['filename']);
$data_to_db['qrcode'] = $data_to_db['filename'].'.'.$old_qrcode["format"];
if(!file_exists(SAVED_QRCODE_DIRECTORY.$data_to_db['filename'].'.'.$old_qrcode["format"]) || $data_to_db['filename'] == $input_data["old_filename"]){
@@ -143,6 +177,7 @@ class Qrcode {
$this->failure('You cannot edit a qr code with an existing name on the server!');
if ($stat){
audit_log('qrcode_updated', $this->table, $input_data['id']);
$this->success('Qr code updated successfully!');
}
else {
@@ -162,7 +197,11 @@ class Qrcode {
$db->where('id', $id);
$status = $db->delete($this->table);
if ($status) {
audit_log('qrcode_deleted', $this->table, $id);
}
try{
unlink(SAVED_QRCODE_DIRECTORY.$qrcode["filename"].'.'.$qrcode["format"]);
}
+59 -11
View File
@@ -3,6 +3,8 @@ require_once 'config/config.php';
class Users
{
const ALLOWED_TYPES = ['super', 'admin'];
/**
*
*/
@@ -10,6 +12,25 @@ class Users
{
}
/**
* Server-side validatie van username/type. Geeft een foutmelding terug (string) of null als geldig.
*/
private function validateUsernameAndType($username, $type) {
if (!is_string($username) || strlen($username) < 3 || strlen($username) > 50) {
return 'Username must be between 3 and 50 characters.';
}
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $username)) {
return 'Username may only contain letters, numbers, dots, underscores and hyphens.';
}
if (!in_array($type, self::ALLOWED_TYPES, true)) {
return 'Invalid user type.';
}
return null;
}
/**
*
*/
@@ -54,6 +75,15 @@ class Users
public function addUser($input_data) {
$db = getDbInstance();
$validation_error = $this->validateUsernameAndType($input_data['username'] ?? '', $input_data['type'] ?? '');
if ($validation_error !== null) {
$this->failure($validation_error, 'Location: user.php');
}
if (!isset($input_data['password']) || strlen($input_data['password']) < 10) {
$this->failure('Password must be at least 10 characters long.', 'Location: user.php');
}
$data_to_db["username"] = $input_data["username"];
$data_to_db['password'] = password_hash($input_data['password'], PASSWORD_DEFAULT);
$data_to_db["type"] = $input_data["type"];
@@ -66,8 +96,10 @@ class Users
$last_id = $db->insert('users', $data_to_db);
if ($last_id)
if ($last_id) {
audit_log('user_created', 'user', $last_id);
$this->success('User added successfully');
}
}
/**
@@ -77,28 +109,43 @@ class Users
public function editUser($input_data) {
$db = getDbInstance();
$query_string = http_build_query(array(
'id' => $input_data["id"],
'edit' => "true",
));
$validation_error = $this->validateUsernameAndType($input_data['username'] ?? '', $input_data['type'] ?? '');
if ($validation_error !== null) {
$this->failure($validation_error, 'Location: user.php?'.$query_string);
}
if (isset($input_data['password']) && strlen($input_data['password']) > 0 && strlen($input_data['password']) < 10) {
$this->failure('Password must be at least 10 characters long.', 'Location: user.php?'.$query_string);
}
$db->where('username', $input_data['username']);
$db->where('id', $input_data["id"], '!=');
$row = $db->getOne('users');
if (!empty($row['username'])) {
$query_string = http_build_query(array(
'id' => $input_data["id"],
'edit' => "true",
));
$this->failure('Username already exists', 'Location: user.php?'.$query_string);
}
$data_to_db["username"] = $input_data["username"];
$data_to_db['password'] = password_hash($input_data['password'], PASSWORD_DEFAULT);
$data_to_db["type"] = $input_data["type"];
// Alleen wachtwoord overschrijven als er een nieuwe waarde is opgegeven.
if (!empty($input_data['password'])) {
$data_to_db['password'] = password_hash($input_data['password'], PASSWORD_DEFAULT);
}
$db->where('id', $input_data["id"]);
$stat = $db->update('users', $data_to_db);
if ($stat)
if ($stat) {
audit_log('user_updated', 'user', $input_data['id']);
$this->success('User updated successfully!');
else
} else
$this->failure('Failed to update User: ' . $db->getLastError());
}
@@ -116,9 +163,10 @@ class Users
$db->where('id', $id);
$stat = $db->delete('users');
if ($stat)
if ($stat) {
audit_log('user_deleted', 'user', $id);
$this->info('User deleted successfully!');
else
} else
$this->failure('Unable to delete user');
}
+11 -2
View File
@@ -1,12 +1,12 @@
<?php
session_start();
require_once 'config/config.php';
require_once 'includes/bootstrap.php';
$token = bin2hex(openssl_random_pseudo_bytes(16));
// If User has already logged in, redirect to dashboard page.
if (isset($_SESSION['user_logged_in']) && $_SESSION['user_logged_in'] === TRUE)
{
header('Location: index.php');
exit;
}
// If user has previously selected "remember me option":
@@ -33,9 +33,17 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
exit;
}
session_regenerate_id(true);
$_SESSION['user_logged_in'] = TRUE;
$_SESSION['user_id'] = $row['id'];
$_SESSION['type'] = $row['type'];
$_SESSION['username'] = $row['username'];
$_SESSION['must_change_password'] = !empty($row['must_change_password']);
$_SESSION['last_activity'] = time();
audit_log('login_success_remember');
header('Location: index.php');
exit;
}
@@ -71,6 +79,7 @@ if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token']))
<p class="login-box-msg">Sign in to start your session</p>
<form method="POST" action="authenticate.php">
<?php echo csrf_field(); ?>
<div class="input-group mb-3">
<input type="text" name="username" class="form-control" placeholder="Username" required="required">
<div class="input-group-append">
+14 -4
View File
@@ -1,10 +1,20 @@
<?php
require_once './config/config.php';
session_start();
require_once 'includes/bootstrap.php';
if (!empty($_SESSION['user_logged_in'])) {
audit_log('logout');
}
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
if(isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token'])){
if (isset($_COOKIE['series_id']) && isset($_COOKIE['remember_token'])) {
clearAuthCookie();
}
header('Location:index.php');
+6 -2
View File
@@ -1,11 +1,14 @@
<?php
session_start();
require_once 'config/config.php';
require_once 'includes/bootstrap.php';
require_once BASE_PATH.'/includes/auth_validate.php';
require_once BASE_PATH . '/lib/StaticQrcode/StaticQrcode.php';
$static_qrcode_instance = new StaticQrcode();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify_or_die();
}
$edit = false;
if($_SERVER["REQUEST_METHOD"] === "GET" && isset($_GET["edit"]) && $_GET["edit"] == "true" && isset($_GET["id"])) {
$edit = true;
@@ -115,6 +118,7 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"])) {
</div>
<?php if($edit) {?>
<form class="form" action="" method="post" id="static_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<div class="card-body">
<?php include BASE_PATH . '/forms/form_static_edit.php';?>
</div>
+1 -2
View File
@@ -1,6 +1,5 @@
<?php
session_start();
require_once 'config/config.php';
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
require_once BASE_PATH . '/lib/StaticQrcode/StaticQrcode.php';
+5 -2
View File
@@ -1,6 +1,5 @@
<?php
session_start();
require_once 'config/config.php';
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
require_once BASE_PATH . '/lib/Users/Users.php';
@@ -9,6 +8,9 @@ $user_instance = new Users();
if ($_SESSION['type'] !== 'super')
$user_instance->failure('Only a "super admin" account can access the admin listing page', 'Location: index.php');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify_or_die();
}
$edit = false;
if($_SERVER["REQUEST_METHOD"] === "GET" && isset($_GET["edit"]) && $_GET["edit"] == "true" && isset($_GET["id"])) {
@@ -83,6 +85,7 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"])) {
<h3 class="card-title">Enter the requested data</h3>
</div>
<form class="well form-horizontal" action="" method="post" id="contact_form" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<div class="card-body">
<?php include BASE_PATH . '/forms/form_users.php'; ?>
</div>
+1 -2
View File
@@ -1,6 +1,5 @@
<?php
session_start();
require_once 'config/config.php';
require_once 'includes/bootstrap.php';
require_once BASE_PATH . '/includes/auth_validate.php';
require_once BASE_PATH . '/lib/Users/Users.php';