implemented csrf

This commit is contained in:
tp_dhu 2025-05-10 08:50:41 +01:00
parent 2d6cd5e48d
commit 4a421564c2
23 changed files with 219 additions and 125 deletions

View File

@ -2,8 +2,13 @@
namespace Admin; namespace Admin;
use CheckCSRF;
class TicketOptionsController extends \BaseController class TicketOptionsController extends \BaseController
{ {
use CheckCSRF;
public function listPriorities() public function listPriorities()
{ {
$this->requireLogin(); $this->requireLogin();
@ -24,10 +29,12 @@ class TicketOptionsController extends \BaseController
$this->renderView('views/admin/priorities/create.html'); $this->renderView('views/admin/priorities/create.html');
} }
public function createPriority() public function createPriority($f3)
{ {
$this->requireLogin(); $this->requireLogin();
$this->requireAdmin(); // Added admin check $this->requireAdmin(); // Added admin check
$this->checkCSRF($f3, '/admin/priority/create');
$p = new \TicketPriority($this->getDB()); $p = new \TicketPriority($this->getDB());
$p->name = $this->f3->get('POST.name'); $p->name = $this->f3->get('POST.name');
$p->sort_order = $this->f3->get('POST.sort_order'); $p->sort_order = $this->f3->get('POST.sort_order');
@ -60,6 +67,7 @@ class TicketOptionsController extends \BaseController
{ {
$this->requireLogin(); $this->requireLogin();
$this->requireAdmin(); $this->requireAdmin();
$this->checkCSRF($f3, '/admin/priority/', $params['id'] . '/edit');
$priorityId = $params['id']; $priorityId = $params['id'];
$model = new \TicketPriority($this->getDB()); $model = new \TicketPriority($this->getDB());

View File

@ -2,7 +2,7 @@
class AttachmentController { class AttachmentController {
use RequiresAuth; use RequiresAuth, CheckCSRF;
// list attachments // list attachments
public function index($f3){ public function index($f3){
@ -33,6 +33,7 @@ class AttachmentController {
// handle file upload // handle file upload
public function upload($f3){ public function upload($f3){
$this->check_access($f3); $this->check_access($f3);
$this->checkCSRF($f3, '/ticket/'.$f3->get('PARAMS.id')); // not ideal for AJAX
$ticket_id = (int) $f3->get('PARAMS.id'); $ticket_id = (int) $f3->get('PARAMS.id');
$uploaded_by = $f3->get('SESSION.user.id'); $uploaded_by = $f3->get('SESSION.user.id');
@ -80,6 +81,9 @@ class AttachmentController {
); );
$f3->reroute('/ticket/'.$ticket_id.''); $f3->reroute('/ticket/'.$ticket_id.'');
// ideal ajax response:
// $f3->json(['success' => true, 'message' => 'file upload success.', 'filename' => $original_name, 'version' => $new_version]);
} }
// download attachment // download attachment

View File

@ -2,6 +2,7 @@
class AuthController { class AuthController {
use CheckCSRF;
public function showLoginForm($f3){ public function showLoginForm($f3){
@ -16,6 +17,9 @@ class AuthController {
} }
public function login($f3){ public function login($f3){
// CSRF
$this->checkCSRF($f3, '/login');
$username = $f3->get('POST.username'); $username = $f3->get('POST.username');
$password = $f3->get('POST.password'); $password = $f3->get('POST.password');

View File

@ -2,6 +2,8 @@
class CommentController { class CommentController {
use CheckCSRF;
/** /**
* Add a new comment to a ticket. * Add a new comment to a ticket.
* Expects POST data: comment (text) * Expects POST data: comment (text)
@ -13,6 +15,8 @@ class CommentController {
$f3->reroute('/login'); $f3->reroute('/login');
} }
$this->checkCSRF($f3, '/ticket/' . $f3->get('PARAMS.id'));
$ticket_id = (int) $f3->get('PARAMS.id'); $ticket_id = (int) $f3->get('PARAMS.id');
$comment_text = $f3->get('POST.comment'); $comment_text = $f3->get('POST.comment');
$current_user_id = $f3->get('SESSION.user.id'); $current_user_id = $f3->get('SESSION.user.id');

View File

@ -2,7 +2,7 @@
class KBController implements CRUD { class KBController implements CRUD {
use RequiresAuth; use RequiresAuth, CheckCSRF;
public function index($f3){ public function index($f3){
@ -66,6 +66,8 @@ class KBController implements CRUD {
public function create($f3){ public function create($f3){
$this->check_access($f3); $this->check_access($f3);
$this->checkCSRF($f3, '/kb/create');
$title = $f3->get('POST.title'); $title = $f3->get('POST.title');
$content = $f3->get('POST.content'); $content = $f3->get('POST.content');
$created_by = $f3->get('SESSION.user.id'); $created_by = $f3->get('SESSION.user.id');
@ -160,7 +162,10 @@ class KBController implements CRUD {
* Handle POST to edit existing article * Handle POST to edit existing article
*/ */
public function update($f3){ public function update($f3){
$this->check_access($f3); $this->check_access($f3);
$this->checkCSRF($f3, '/kb/' . $f3->get('PARAMS.id') . '/edit');
$article_id = $f3->get('PARAMS.id'); $article_id = $f3->get('PARAMS.id');
$db = $f3->get('DB'); $db = $f3->get('DB');

View File

@ -2,7 +2,7 @@
class TagController implements CRUD { class TagController implements CRUD {
use RequiresAuth; use RequiresAuth, CheckCSRF;
/** /**
* List all tags * List all tags
@ -27,6 +27,7 @@ class TagController implements CRUD {
public function create($f3){ public function create($f3){
$this->check_access($f3); $this->check_access($f3);
$this->checkCSRF($f3, '/tag/create');
$name = $f3->get('POST.name'); $name = $f3->get('POST.name');
$color = $f3->get('POST.color'); $color = $f3->get('POST.color');

View File

@ -2,8 +2,12 @@
class ThemeController class ThemeController
{ {
use CheckCSRF;
function toggle($f3) function toggle($f3)
{ {
$this->checkCSRF($f3, $f3->get('HEADERS.Referer') ?: '/');
$current = $f3->get('SESSION.theme') ?: 'light'; $current = $f3->get('SESSION.theme') ?: 'light';
$new_theme = ($current === 'light') ? 'dark' : 'light'; $new_theme = ($current === 'light') ? 'dark' : 'light';
$f3->set('SESSION.theme', $new_theme); $f3->set('SESSION.theme', $new_theme);

View File

@ -2,7 +2,7 @@
class TicketController extends BaseController implements CRUD { class TicketController extends BaseController implements CRUD {
use RequiresAuth; use RequiresAuth, CheckCSRF;
// list all tickts // list all tickts
public function index($f3){ public function index($f3){
@ -58,10 +58,15 @@ class TicketController extends BaseController implements CRUD {
$priorities = (new TicketPriority($db))->findAll(); $priorities = (new TicketPriority($db))->findAll();
$statuses = (new TicketStatus($db))->findAll(); $statuses = (new TicketStatus($db))->findAll();
// TODO: this needs moving into a model?
$users = $this->getDB()->exec('SELECT id, username, display_name FROM users ORDER BY display_name ASC');
$users = array_merge([['id'=>'-1', 'display_name'=>'--']], $users);
$this->requireLogin(); $this->requireLogin();
$this->renderView('views/ticket/create.html',[ $this->renderView('views/ticket/create.html',[
'priorities' => $priorities, 'priorities' => $priorities,
'statuses' => $statuses 'statuses' => $statuses,
'users' => $users
]); ]);
} }
@ -70,6 +75,7 @@ class TicketController extends BaseController implements CRUD {
public function create($f3){ public function create($f3){
$this->requireLogin(); $this->requireLogin();
$this->checkCSRF($f3, '/ticket/create');
$data = [ $data = [
'title' => $this->f3->get('POST.title'), 'title' => $this->f3->get('POST.title'),
@ -114,12 +120,16 @@ class TicketController extends BaseController implements CRUD {
// dropdowns // dropdowns
$priorities = (new TicketPriority($this->getDB()))->findAll(); $priorities = (new TicketPriority($this->getDB()))->findAll();
$statuses = (new TicketStatus($this->getDB()))->findAll(); $statuses = (new TicketStatus($this->getDB()))->findAll();
// TODO: this needs moving into a model?
$users = $this->getDB()->exec('SELECT id, username, display_name FROM users ORDER BY display_name ASC');
$users = array_merge([['id'=>'-1', 'display_name'=>'--']], $users);
$this->renderView('views/ticket/edit.html',[ $this->renderView('views/ticket/edit.html',[
'ticket' => $ticket, 'ticket' => $ticket,
'ticket_meta' => $ticket->getMeta(), 'ticket_meta' => $ticket->getMeta(),
'priorities' => $priorities, 'priorities' => $priorities,
'statuses' => $statuses 'statuses' => $statuses,
'users' => $users
] ]
); );
return; return;
@ -130,6 +140,7 @@ class TicketController extends BaseController implements CRUD {
{ {
$this->requireLogin(); $this->requireLogin();
$this->checkCSRF($f3, '/ticket/create');
$ticket_id = $this->f3->get('PARAMS.id'); $ticket_id = $this->f3->get('PARAMS.id');
$ticket_mapper = new Ticket($this->getDB()); $ticket_mapper = new Ticket($this->getDB());
@ -146,7 +157,8 @@ class TicketController extends BaseController implements CRUD {
'description' => $this->f3->get('POST.description'), 'description' => $this->f3->get('POST.description'),
'priority_id' => $this->f3->get('POST.priority_id'), 'priority_id' => $this->f3->get('POST.priority_id'),
'status_id' => $this->f3->get('POST.status_id'), 'status_id' => $this->f3->get('POST.status_id'),
'updated_by' => $this->f3->get('SESSION.user.id') 'updated_by' => $this->f3->get('SESSION.user.id') ,
'assigned_to' => $this->f3->get('POST.assigned_to') ?: null
]; ];
$ticket->updateTicket($data); $ticket->updateTicket($data);
@ -162,6 +174,7 @@ class TicketController extends BaseController implements CRUD {
// subtask // subtask
public function addSubtask($f3){ public function addSubtask($f3){
$this->requireLogin(); $this->requireLogin();
$this->checkCSRF($f3, '/ticket/create');
$parent_id = (int) $f3->get('PARAMS.id'); $parent_id = (int) $f3->get('PARAMS.id');
$child_id = (int) $f3->get('POST.child_ticket_id'); $child_id = (int) $f3->get('POST.child_ticket_id');

View File

@ -2,7 +2,7 @@
class UserController implements CRUD { class UserController implements CRUD {
use RequiresAuth; use RequiresAuth, CheckCSRF;
// list all users (admin only) // list all users (admin only)
@ -44,6 +44,7 @@ class UserController implements CRUD {
public function update($f3){ public function update($f3){
$this->check_access($f3); $this->check_access($f3);
$this->checkCSRF($f3, '/user/' . $f3->get('PARAMS.id') . '/edit');
$user_id = (int) $f3->get('PARAMS.id'); $user_id = (int) $f3->get('PARAMS.id');
$new_username = $f3->get('POST.username'); $new_username = $f3->get('POST.username');

View File

@ -0,0 +1,36 @@
<?php
class CSRFHelper {
const TOKEN_NAME = 'csrf_token';
public static function token():string {
$f3 = \Base::instance();
if(!$f3->exists('SESSION.' . self::TOKEN_NAME)) {
$token = bin2hex(random_bytes(32));
$f3->set('SESSION.' . self::TOKEN_NAME, $token);
}
return $f3->get('SESSION.' . self::TOKEN_NAME);
}
public static function verify(?string $submitted_token): bool {
$f3 = \Base::instance();
$session_token = $f3->get('SESSION.' . self::TOKEN_NAME);
if(empty($submitted_token) || empty($session_token)){
return false;
}
if(hash_equals($session_token, $submitted_token)){
$f3->clear('SESSION.' . self::TOKEN_NAME);
return true;
}
return false;
}
public static function field(): string {
return '<input type="hidden" name="'.self::TOKEN_NAME.'" value="'.self::token().'">';
}
}

13
app/traits/CheckCSRF.php Normal file
View File

@ -0,0 +1,13 @@
<?php
trait CheckCSRF {
public function checkCSRF($f3, $reroute){
if(!\CSRFHelper::verify($f3->get('POST.' . \CSRFHelper::TOKEN_NAME))){
$f3->set('SESSION.error', 'CSRF token validation failed.');
$f3->reroute($reroute);
return;
}
}
}

View File

@ -1,89 +1,98 @@
<div class="block"> <div class="block">
<style> <input type="hidden" id="csrf-token-clipboard" value="{{ \CSRFHelper::token() }}">
#upload-area { <style>
height: 250px; #upload-area {
border: 2px dashed #aaa; height: 250px;
display: flex; border: 2px dashed #aaa;
align-items: center; display: flex;
margin-top: 10px; align-items: center;
color: #555; margin-top: 10px;
font-family: sans-serif; color: #555;
text-align: center; font-family: sans-serif;
} text-align: center;
#upload-area.hover {
background-color: #f0f0f0;
border-color: #444;
}
#preview {
max-width:100%;
margin-top: 10px;
}
</style>
<div id="upload-area" contenteditable="true">
Paste or drag an image here
</div>
<img id="preview" alt="image preview" hidden>
<p id="status"></p>
<script>
const area = document.getElementById('upload-area');
const preview = document.getElementById('preview');
const status = document.getElementById('status');
async function uploadImage(file){
const reader = new FileReader();
reader.onload = () => {
preview.src = reader.result;
preview.hidden = false;
};
reader.readAsDataURL(file);
const formData = new FormData();
formData.append('attachment', file);
try {
const res = await fetch('/ticket/{{@ticket->id}}/attachments/upload', {
method: 'POST',
body: formData
});
const text = await res.text();
status.textContent = text;
} catch (e) {
status.textContent = 'upload failed.'
} }
}
// paste #upload-area.hover {
area.addEventListener('paste', (e) => { background-color: #f0f0f0;
for(let item of e.clipboardData.items){ border-color: #444;
if(item.type.startsWith('image/')){ }
uploadImage(item.getAsFile());
#preview {
max-width: 100%;
margin-top: 10px;
}
</style>
<div id="upload-area" contenteditable="true">
Paste or drag an image here
</div>
<img id="preview" alt="image preview" hidden>
<p id="status"></p>
<script>
const area = document.getElementById('upload-area');
const preview = document.getElementById('preview');
const status = document.getElementById('status');
const csrfTokenClipboard = document.getElementById('csrf-token-clipboard').value;
async function uploadImage(file) {
const reader = new FileReader();
reader.onload = () => {
preview.src = reader.result;
preview.hidden = false;
};
reader.readAsDataURL(file);
const formData = new FormData();
formData.append('attachment', file);
formData.append('csrf_token', csrfTokenClipboard);
try {
const res = await fetch('/ticket/{{@ticket->id}}/attachments/upload', {
method: 'POST',
body: formData
});
if(res.ok){
const responseData = await res.json(); // assuming json returned
statusMsg.textContent = responseData || 'Upload success, no message';
window.location.reload();
} else {
const errorData = await res.json();
statusMsg.textContent = 'Upload failed: ' + (errorData.error || res.statusText);
}
} catch (e) {
status.textContent = 'upload failed.'
} }
} }
});
// drag and drop // paste
area.addEventListener('dragover', (e) => { area.addEventListener('paste', (e) => {
e.preventDefault(); for (let item of e.clipboardData.items) {
area.classList.add('hover'); if (item.type.startsWith('image/')) {
}); uploadImage(item.getAsFile());
}
area.addEventListener('dragLeave', ()=> {
area.classList.remove('hover');
});
area.addEventListener('drop', (e) => {
e.preventDefault();
area.classList.remove('hover');
const files = e.dataTransfer.files;
for(let file of files){
if(file.type.startsWith('image/')){
uploadImage(file);
} }
} });
});
</script> // drag and drop
area.addEventListener('dragover', (e) => {
e.preventDefault();
area.classList.add('hover');
});
area.addEventListener('dragLeave', () => {
area.classList.remove('hover');
});
area.addEventListener('drop', (e) => {
e.preventDefault();
area.classList.remove('hover');
const files = e.dataTransfer.files;
for (let file of files) {
if (file.type.startsWith('image/')) {
uploadImage(file);
}
}
});
</script>
</div> </div>

View File

@ -55,7 +55,8 @@
<div class="navbar-end"> <div class="navbar-end">
<div class="navbar-item"> <div class="navbar-item">
<div class="buttons"> <div class="buttons">
<form id="theme-toggle-form" method="post" action="/toggle-theme" style="display:inline"> <form id="theme-toggle-form" method="POST" action="/toggle-theme" style="display:inline">
{{ \CSRFHelper::field() | raw }}
<button type="submit" id="theme-toggle-button" class="button is-small" aria-label="Toggle Theme"> <button type="submit" id="theme-toggle-button" class="button is-small" aria-label="Toggle Theme">
<span class="icon"> <span class="icon">
<i class="fas fa-circle-half-stroke" id="theme-icon"></i> <i class="fas fa-circle-half-stroke" id="theme-icon"></i>

View File

@ -1,2 +1,6 @@
<h1 class="title">Create Ticket Priority</h1> <h1 class="title">Create Ticket Priority</h1>
<p>TODO:</p> <p>TODO:</p>
<form method="POST">
{{ \CSRFHelper::field() | raw }}
</form>

View File

@ -37,6 +37,7 @@
</check> </check>
<div class="block"> <div class="block">
<form action="/ticket/{{@PARAMS.id}}/attachments/upload" method="POST" enctype="multipart/form-data"> <form action="/ticket/{{@PARAMS.id}}/attachments/upload" method="POST" enctype="multipart/form-data">
{{ \CSRFHelper::field() | raw }}
<div class="field has-addons"> <div class="field has-addons">
<div class="control has-icons-left"><!-- is-expanded --> <div class="control has-icons-left"><!-- is-expanded -->
<input class="input" type="file" name="attachment" required> <input class="input" type="file" name="attachment" required>

View File

@ -27,6 +27,7 @@
</check> </check>
<div class="block"> <div class="block">
<form action="/ticket/{{@PARAMS.id}}/comment" method="POST"> <form action="/ticket/{{@PARAMS.id}}/comment" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="field"> <div class="field">
<label class="label">Add comment:</label> <label class="label">Add comment:</label>
<div class="control"> <div class="control">

View File

@ -3,6 +3,7 @@
<div class="content"> <div class="content">
<form action="/kb/create" method="POST"> <form action="/kb/create" method="POST">
{{ \CSRFHelper::field() | raw }}
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma> <bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma>

View File

@ -2,6 +2,7 @@
<form action="/kb/{{@article.id}}/update" method="POST"> <form action="/kb/{{@article.id}}/update" method="POST">
{{ \CSRFHelper::field() | raw }}
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value="{{@article.title}}"></bulma> <bulma type="H_FIELD_INPUT" label="Title:" name="title" value="{{@article.title}}"></bulma>

View File

@ -7,7 +7,7 @@
</check> </check>
<form action="/login" method="POST"> <form action="/login" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="field"> <div class="field">
<p class="control has-icons-left has-icons-right"> <p class="control has-icons-left has-icons-right">
<input name="username" class="input" type="text" placeholder="Username"> <input name="username" class="input" type="text" placeholder="Username">

View File

@ -2,6 +2,7 @@
<div class="content"> <div class="content">
<form action="/tag/create" method="POST"> <form action="/tag/create" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="field"> <div class="field">
<label class="label">Name</label> <label class="label">Name</label>

View File

@ -1,32 +1,10 @@
<h1 class="title">Create Ticket Form</h1> <h1 class="title">Create Ticket Form</h1>
<form action="/ticket/create" method="POST"> <form action="/ticket/create" method="POST">
{{ \CSRFHelper::field() | raw }}
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma> <div class="is-flex">
<bulma type="H_FIELD_INPUT" label="Created At:" name="created_at" value=""></bulma> <div class="is-flex-grow-1">
<bulma type="H_FIELD_TEXTAREA" label="Description:" name="description" value=""></bulma> <bulma type="FIELD_INPUT" name="title" value="" class="mr-3"></bulma>
<!-- priority and status -->
<bulma type="H_FIELD_SELECT_NEW" label="Priority:" name="priority_id"
options="priorities" option_value="id" option_name="name"
selected="2"></bulma>
<bulma type="H_FIELD_SELECT_NEW" label="Status:" name="status_id"
options="statuses" option_value="id" option_name="name"
selected="1"></bulma>
<!-- custom fields -->
<hr>
<div class="block">
<div class="field is-grouped is-grouped-right">
<div class="control">
<label class="label">Key:</label>
<input class="input" type="text" name="meta_key[]" placeholder="eg. Department">
</div>
<div class="control">
<label class="label">Value:</label>
<input class="input" type="text" name="meta_value[]" placeholder="eg. Finance">
</div>
</div> </div>
<div class="field is-grouped is-grouped-right"> <div class="field is-grouped is-grouped-right">
<div class="control"> <div class="control">

View File

@ -1,13 +1,12 @@
<!-- Ticket - Edit --> <!-- Ticket - Edit -->
<!-- made to look more in line with view--> <!-- made to look more in line with view-->
<form action="/ticket/{{ @PARAMS.id }}/update" method="POST"> <form action="/ticket/{{ @PARAMS.id }}/update" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="is-flex"> <div class="is-flex">
<div class="is-flex-grow-1"> <div class="is-flex-grow-1">
<bulma type="FIELD_INPUT" name="title" value="{{@ticket.title}}" class="mr-3"></bulma> <bulma type="FIELD_INPUT" name="title" value="{{@ticket.title}}" class="mr-3"></bulma>
</div> </div>
<div class="field is-grouped"> <div class="field is-grouped">
<!-- <p class="control"><a class="button" href="/ticket/{{ @ticket.id}}/edit">edit ticket</a></p>
<p class="control"><a class="button is-primary" href="/ticket/create">new ticket</a></p>-->
<div class="control"> <div class="control">
<a class="button is-secondary" href="/ticket/{{ @ticket.id }}">Cancel</a> <a class="button is-secondary" href="/ticket/{{ @ticket.id }}">Cancel</a>
</div> </div>
@ -57,10 +56,16 @@
options="{{@priorities}}" option_value="id" option_name="name" options="{{@priorities}}" option_value="id" option_name="name"
selected="{{@ticket.priority_id}}"></bulma> selected="{{@ticket.priority_id}}"></bulma>
<bulma type="FIELD_SELECT" label="Status:" name="status_id" <bulma type="FIELD_SELECT" label="Status:" name="status_id"
options="{{@statuses}}" option_value="id" option_name="name" options="{{@statuses}}" option_value="id" option_name="name"
selected="{{@ticket.status_id}}"></bulma> selected="{{@ticket.status_id}}"></bulma>
</div>
<bulma type="FIELD_SELECT" label="Assigned User:" name="assigned_to"
options="{{@users}}" option_value="id" option_name="display_name"
selected="{{@ticket.assigned_to}}"></bulma>
</div>
<!-- additional data --> <!-- additional data -->
<div class="block"> <div class="block">
</div> </div>

View File

@ -1,6 +1,5 @@
<form method="POST" action="/user/{{@edit_user.id}}/update"> <form method="POST" action="/user/{{@edit_user.id}}/update">
{{ \CSRFHelper::field() | raw }}
<div class="field"> <div class="field">
<label class="label">Username</label> <label class="label">Username</label>
<div class="control"> <div class="control">