Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1621dde
Convert state-changing actions from GET to POST
anonymoususer72041 Jan 6, 2026
9558f3c
fix: correct contact delete confirmation message
anonymoususer72041 Jan 6, 2026
9f73dd0
Provide GET launcher pages for installer AJAX maintenance endpoints
anonymoususer72041 Jan 6, 2026
51e3c07
Add session-based CSRF token helpers
anonymoususer72041 Jan 7, 2026
a15b654
Expose CSRF token to client and include in AJAX POSTs
anonymoususer72041 Jan 7, 2026
840bdd4
Enforce CSRF token for secure AJAX POST requests
anonymoususer72041 Jan 7, 2026
78dfacb
Enforce CSRF token for authenticated POST requests
anonymoususer72041 Jan 7, 2026
c8ee380
fix: include CSRF token in dynamic POST requests
anonymoususer72041 Jan 8, 2026
af1f1b9
fix: include CSRF token in auto-submitted POST forms
anonymoususer72041 Jan 8, 2026
c2b288f
fix: include CSRF token in test POST requests
anonymoususer72041 Jan 8, 2026
01f3c0f
Limit CSRF token leakage via Referrer-Policy
anonymoususer72041 Jan 8, 2026
fefc950
Add SameSite=Lax to session_cookie
anonymoususer72041 Jan 8, 2026
7ef847a
Merge branch 'opencats:master' into security/csrf-protection
anonymoususer72041 Jan 11, 2026
c6ec576
Fix job order delete Behat step
anonymoususer72041 Jan 11, 2026
46b04ef
Make logout submit without JavaScript
anonymoususer72041 Jan 12, 2026
170f50e
Fix GET_POST_requestsSecurity.feature for POST-only delete actions
anonymoususer72041 Jan 12, 2026
37b790f
Add unit tests for CSRF token handling
anonymoususer72041 Jan 15, 2026
a8e822a
Fix careers portal settings template POST action parameters
anonymoususer72041 Jan 25, 2026
c0510a4
Merge remote-tracking branch 'upstream/master' into security/csrf-pro…
anonymoususer72041 Feb 2, 2026
79ae693
Merge remote-tracking branch 'upstream/master' into security/csrf-pro…
anonymoususer72041 Feb 3, 2026
a8dfd96
Fix bulk action selected IDs parsing in DataGrid
anonymoususer72041 Feb 3, 2026
49982a7
Send selected DataGrid IDs as JSON
anonymoususer72041 Feb 3, 2026
db7c055
Make DataGrid getFromRequest JSON-only
anonymoususer72041 Feb 3, 2026
22adfad
Use JSON for DataGrid quick-action parameters
anonymoususer72041 Feb 3, 2026
af3f56a
Enforce CSRF token for all logged-in POST requests
anonymoususer72041 Feb 3, 2026
f899410
Remove legacy toolbar feature
anonymoususer72041 Feb 3, 2026
233418f
Merge branch 'master' into security/csrf-protection
RussH Feb 16, 2026
c442d0b
Merge remote-tracking branch 'upstream/master' into security/csrf-pro…
anonymoususer72041 Feb 17, 2026
24152c3
ci: retrigger
anonymoususer72041 Feb 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .htaccess
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ IndexIgnore *

Options -Indexes

# Security headers (requires mod_headers; AllowOverride FileInfo or All).
# Basic security headers.
# These defaults are intentionally conservative to avoid breaking common customizations.
<IfModule mod_headers.c>
Expand Down
32 changes: 32 additions & 0 deletions ajax.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');

/* Only start a session for POST requests so CSRF validation can determine logged-in state. */
if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST')
{
@session_name(CATS_SESSION_NAME);
session_start();
}

/* Make sure we aren't getting screwed over by magic quotes. */
if (get_magic_quotes_runtime())
{
Expand All @@ -60,6 +67,31 @@
$_REQUEST = array_map('stripslashes', $_REQUEST);
}

if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST' &&
isset($_SESSION['CATS']) && $_SESSION['CATS']->isLoggedIn())
{
$token = null;

if (isset($_POST['csrfToken']))
{
$token = $_POST['csrfToken'];
}

if (!$_SESSION['CATS']->isCSRFTokenValid($token))
{
header('Content-type: text/xml');
echo '<?xml version="1.0" encoding="', AJAX_ENCODING, '"?>', "\n";
echo(
"<data>\n" .
" <errorcode>-1</errorcode>\n" .
" <errormessage>Invalid request.</errormessage>\n" .
"</data>\n"
);

die();
}
}

if (!isset($_REQUEST['f']) || empty($_REQUEST['f']))
{
header('Content-type: text/xml');
Expand Down
8 changes: 7 additions & 1 deletion ajax/deleteActivity.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@

$interface = new SecureAJAXInterface();

if ($_SERVER['REQUEST_METHOD'] !== 'POST')
{
$interface->outputXMLErrorPage(-1, 'Invalid request.');
die();
}

if (!$interface->isRequiredIDValid('activityID'))
{
$interface->outputXMLErrorPage(-1, 'Invalid activity ID.');
Expand All @@ -40,7 +46,7 @@

$siteID = $interface->getSiteID();

$activityID = $_REQUEST['activityID'];
$activityID = $_POST['activityID'];

/* Delete the activity entry. */
$activityEntries = new ActivityEntries($siteID);
Expand Down
24 changes: 15 additions & 9 deletions ajax/editActivity.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@

$interface = new SecureAJAXInterface();

if ($_SERVER['REQUEST_METHOD'] !== 'POST')
{
$interface->outputXMLErrorPage(-1, 'Invalid request.');
die();
}

if (!$interface->isRequiredIDValid('activityID'))
{
$interface->outputXMLErrorPage(-1, 'Invalid activity ID.');
Expand All @@ -53,24 +59,24 @@
die();
}

if (!isset($_REQUEST['notes']))
if (!isset($_POST['notes']))
{
$interface->outputXMLErrorPage(-1, 'Invalid notes.');
die();
}

$siteID = $interface->getSiteID();

$activityID = $_REQUEST['activityID'];
$type = $_REQUEST['type'];
$jobOrderID = $_REQUEST['jobOrderID'];
$activityID = $_POST['activityID'];
$type = $_POST['type'];
$jobOrderID = $_POST['jobOrderID'];

/* Decode and trim the activity notes from the company. */
$activityNote = trim(urldecode($_REQUEST['notes']));
$activityDate = trim(urldecode($_REQUEST['date']));
$activityHour = trim(urldecode($_REQUEST['hour']));
$activityMinute = trim(urldecode($_REQUEST['minute']));
$activityAMPM = trim(urldecode($_REQUEST['ampm']));
$activityNote = trim(urldecode($_POST['notes']));
$activityDate = trim(urldecode($_POST['date']));
$activityHour = trim(urldecode($_POST['hour']));
$activityMinute = trim(urldecode($_POST['minute']));
$activityAMPM = trim(urldecode($_POST['ampm']));

$dateFormatFlag = $_SESSION['CATS']->isDateDMY()
? DATE_FORMAT_DDMMYY
Expand Down
13 changes: 8 additions & 5 deletions ajax/getPipelineJobOrder.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,11 +305,14 @@ function printSortLink($field, $delimiter = "'", $changeDirection = true)
<img src="images/actions/edit.gif" width="16" height="16" class="absmiddle" alt="" style="border: none;" title="Log an Activity / Change Status" />
</a>
<?php endif; ?>
<?php if ($_SESSION['CATS']->getAccessLevel('pipelines.removeFromPipeline') >= ACCESS_LEVEL_DELETE): ?>
<a href="<?php echo($indexFile); ?>?m=joborders&amp;a=removeFromPipeline&amp;jobOrderID=<?php echo($jobOrderID); ?>&amp;candidateID=<?php echo($pipelinesData['candidateID']); ?>" onclick="javascript:return confirm('Remove <?php echo(str_replace('\'', '\\\'', htmlspecialchars($pipelinesData['firstName']))); ?> <?php echo(str_replace('\'', '\\\'', htmlspecialchars($pipelinesData['lastName']))); ?> from the pipeline?')">
<img src="images/actions/delete.gif" width="16" height="16" class="absmiddle" alt="remove" style="border: none;" title="Remove from Job Order" />
</a>
<?php endif; ?>
<?php if ($_SESSION['CATS']->getAccessLevel('pipelines.removeFromPipeline') >= ACCESS_LEVEL_DELETE): ?>
<form method="post" action="<?php echo($indexFile); ?>?m=joborders&amp;a=removeFromPipeline" style="display:inline;" onsubmit="return confirm('Remove <?php echo(str_replace('\'', '\\\'', htmlspecialchars($pipelinesData['firstName']))); ?> <?php echo(str_replace('\'', '\\\'', htmlspecialchars($pipelinesData['lastName']))); ?> from the pipeline?')">
<input type="hidden" name="postback" value="postback" />
<input type="hidden" name="jobOrderID" value="<?php echo($jobOrderID); ?>" />
<input type="hidden" name="candidateID" value="<?php echo($pipelinesData['candidateID']); ?>" />
<input type="image" src="images/actions/delete.gif" width="16" height="16" class="absmiddle" alt="remove" style="border: none;" title="Remove from Job Order" />
</form>
<?php endif; ?>
<?php endif; ?>
</td>
<?php endif; ?>
Expand Down
12 changes: 9 additions & 3 deletions ajax/setCandidateJobOrderRating.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@

$interface = new SecureAJAXInterface();

if ($_SERVER['REQUEST_METHOD'] !== 'POST')
{
$interface->outputXMLErrorPage(-1, 'Invalid request.');
die();
}

if ($_SESSION['CATS']->getAccessLevel('pipelines.editRating') < ACCESS_LEVEL_EDIT)
{
$interface->outputXMLErrorPage(-1, ERROR_NO_PERMISSION);
Expand All @@ -45,16 +51,16 @@
}

if (!$interface->isRequiredIDValid('rating', true, true) ||
$_REQUEST['rating'] < -6 || $_REQUEST['rating'] > 5)
$_POST['rating'] < -6 || $_POST['rating'] > 5)
{
$interface->outputXMLErrorPage(-1, 'Invalid rating.');
die();
}

$siteID = $interface->getSiteID();

$candidateJobOrderID = $_REQUEST['candidateJobOrderID'];
$rating = $_REQUEST['rating'];
$candidateJobOrderID = $_POST['candidateJobOrderID'];
$rating = $_POST['rating'];

$pipelines = new Pipelines($siteID);
$pipelines->updateRatingValue($candidateJobOrderID, $rating);
Expand Down
18 changes: 15 additions & 3 deletions ajax/setColumnWidth.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,21 @@

$interface = new SecureAJAXInterface();

$instance = $_REQUEST['instance'];
$columnName = $_REQUEST['columnName'];
$columnWidth = $_REQUEST['columnWidth'];
if ($_SERVER['REQUEST_METHOD'] !== 'POST')
{
$interface->outputXMLErrorPage(-1, 'Invalid request.');
die();
}

if (!isset($_POST['instance']) || !isset($_POST['columnName']) || !isset($_POST['columnWidth']))
{
$interface->outputXMLErrorPage(-1, 'Invalid request.');
die();
}

$instance = $_POST['instance'];
$columnName = $_POST['columnName'];
$columnWidth = $_POST['columnWidth'];

$columnPreferences = $_SESSION['CATS']->getColumnPreferences($instance);

Expand Down
18 changes: 12 additions & 6 deletions ajax/testEmailSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,16 @@

$interface = new SecureAJAXInterface();

if ($_SERVER['REQUEST_METHOD'] !== 'POST')
{
$interface->outputXMLErrorPage(-1, 'Invalid request.');
die();
}

$siteID = $interface->getSiteID();

if (!isset($_REQUEST['testEmailAddress']) ||
empty($_REQUEST['testEmailAddress']))
if (!isset($_POST['testEmailAddress']) ||
empty($_POST['testEmailAddress']))
{
$interface->outputXMLErrorPage(
-1, 'Invalid test e-mail address.'
Expand All @@ -44,8 +50,8 @@
die();
}

if (!isset($_REQUEST['fromAddress']) ||
empty($_REQUEST['fromAddress']))
if (!isset($_POST['fromAddress']) ||
empty($_POST['fromAddress']))
{
$interface->outputXMLErrorPage(
-1, 'Invalid from e-mail address.'
Expand All @@ -54,8 +60,8 @@
die();
}

$testEmailAddress = $_REQUEST['testEmailAddress'];
$fromAddress = $_REQUEST['fromAddress'];
$testEmailAddress = $_POST['testEmailAddress'];
$fromAddress = $_POST['fromAddress'];

/* Is the test e-mail address specified valid? */
// FIXME: Validate properly.
Expand Down
7 changes: 3 additions & 4 deletions db/cats_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,7 @@ insert into `module_schema`(`module_schema_id`,`name`,`version`) values (9,'ext
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (10,'graphs',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (11,'home',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (12,'import',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (13,'install',365);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (13,'install',366);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (14,'joborders',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (15,'lists',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (16,'login',0);
Expand All @@ -859,9 +859,8 @@ insert into `module_schema`(`module_schema_id`,`name`,`version`) values (18,'re
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (19,'rss',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (20,'settings',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (21,'tests',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (22,'toolbar',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (23,'wizard',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (24,'xml',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (22,'wizard',0);
insert into `module_schema`(`module_schema_id`,`name`,`version`) values (23,'xml',0);

/*Table structure for table `mru` */

Expand Down
37 changes: 35 additions & 2 deletions index.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,26 @@ function stripslashes_deep($value)
}
}

if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST' &&
$_SESSION['CATS']->isLoggedIn() &&
(!isset($careerPage) || !$careerPage) &&
(!isset($_GET['showCareerPortal']) || $_GET['showCareerPortal'] != '1') &&
(!isset($rssPage) || !$rssPage) &&
(!isset($xmlPage) || !$xmlPage))
{
$token = null;

if (isset($_POST['csrfToken']))
{
$token = $_POST['csrfToken'];
}

if (!$_SESSION['CATS']->isCSRFTokenValid($token))
{
CommonErrors::fatal(COMMONERROR_BADFIELDS, null, 'Invalid request.');
}
}

/* Check to see if we are supposed to display the career page. */
if (((isset($careerPage) && $careerPage) ||
(isset($_GET['showCareerPortal']) && $_GET['showCareerPortal'] == '1')))
Expand Down Expand Up @@ -219,6 +239,11 @@ function stripslashes_deep($value)
{
if ($_GET['m'] == 'logout')
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST')
{
CommonErrors::fatal(COMMONERROR_BADFIELDS, null, 'Invalid request.');
}

/* There isn't really a logout module. It's just a few lines. */
$unixName = $_SESSION['CATS']->getUnixName();

Expand All @@ -233,12 +258,20 @@ function stripslashes_deep($value)
$URI .= '&s=' . $unixName;
}

if (isset($_GET['message']))
if (isset($_POST['message']))
{
$URI .= '&message=' . urlencode($_POST['message']);
}
else if (isset($_GET['message']))
{
$URI .= '&message=' . urlencode($_GET['message']);
}

if (isset($_GET['messageSuccess']))
if (isset($_POST['messageSuccess']))
{
$URI .= '&messageSuccess=' . urlencode($_POST['messageSuccess']);
}
else if (isset($_GET['messageSuccess']))
{
$URI .= '&messageSuccess=' . urlencode($_GET['messageSuccess']);
}
Expand Down
34 changes: 21 additions & 13 deletions js/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,25 +220,19 @@ function urlDecode(text)
}

/**
* Converts a JavaScript array to a seralize()-formatted PHP array in string
* format.
* Converts a JavaScript array to a URL-safe JSON string.
*
* PHP: $myArray = unserialize(urldecode($_POST['myArray']));
* Remember this is unsafe input and it should not be trusted!
*
* Pass this through urlEncode() (above) before adding to a request.
* Used for DataGrid "Selected" bulk actions; the server expects JSON for
* exportIDs in dynamicArgument.
*/
function serializeArray(array)
{
var string = 'a:' + array.length + ':{';

for (var i = 0; i < array.length; ++i)
if (!(array instanceof Array))
{
string += 'i:' + i + ';s:' + String(array[i]).length + ':"'
+ String(array[i]) + '";';
array = [];
}
return string + '}';

return encodeURIComponent(JSON.stringify(array));
}

/**
Expand Down Expand Up @@ -342,6 +336,20 @@ function AJAX_getPOSTSessionID(sessionCookie)
function AJAX_POST(http, url, POSTData, callBack, timeout, sessionCookie,
silentTimeout)
{
if (POSTData == null)
{
POSTData = '';
}

if (typeof CATSCsrfToken != 'undefined' && CATSCsrfToken !== null &&
CATSCsrfToken !== '')
{
if (POSTData.indexOf('csrfToken=') == -1)
{
POSTData += '&csrfToken=' + encodeURIComponent(CATSCsrfToken);
}
}

/* Add a random hash to the POST data to keep IE from caching it. */
POSTData += AJAX_getRandomPOSTHash();

Expand Down
Loading
Loading