Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 10 additions & 1 deletion .env_sample
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,13 @@ SMTP_USER=your_smtp_username
SMTP_PASSWORD=your_smtp_password
SMTP_SENDER=your_smtp_sender_email
[email protected]
[email protected]
[email protected]
# Rate limiting settings
# Maximum number of feedback submissions allowed per IP address within the time window
FEEDBACK_MAX_SUBMISSIONS=3
# Time window in seconds for rate limiting (e.g., 3600 seconds = 1 hour). Works for all kinds of events
RATE_LIMIT_TIME_WINDOW=3600
# Maximum number of save operations per window
SAVE_RATE_LIMIT = 300
#Maximum number of submissions per window
SUBMIT_RATE_LIMIT = 10
16 changes: 6 additions & 10 deletions api/csrf_token.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,17 @@
* The token is stored in the session and must be validated on form submission.
*/

// Start session if not already started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Start session BEFORE any output
session_start();

require_once __DIR__ . '/security.php';

header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');

// Generate a new CSRF token
$token = bin2hex(random_bytes(32));

// Store in session
$_SESSION['csrf_token'] = $token;
$_SESSION['csrf_token_time'] = time();
// Generate a new CSRF token using shared security utility
$token = generateCsrfToken();

echo json_encode([
'success' => true,
Expand Down
203 changes: 203 additions & 0 deletions api/security.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<?php
/**
* Shared Security Utilities
*
* Central location for security functions used across the application:
* - CSRF token generation and validation
* - Honeypot validation
* - Rate limiting for feedback, save, and submit operations
* - Client IP detection
*/

// Load environment variables from .env file
$envFile = dirname(__DIR__) . '/.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '=') === false || strpos($line, '#') === 0) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
if (!isset($_ENV[$key])) {
putenv("{$key}={$value}");
}
}
} else {
error_log("Security.php: .env file not found at expected path: {$envFile}");
}

// Rate limiting configuration (loaded from .env or defaults)
define('RATE_LIMIT_FEEDBACK_MAX', (int) getenv('FEEDBACK_MAX_SUBMISSIONS') ?: 3);
define('RATE_LIMIT_SAVE_MAX', (int) getenv('SAVE_RATE_LIMIT') ?: 100);
define('RATE_LIMIT_SUBMIT_MAX', (int) getenv('SUBMIT_RATE_LIMIT') ?: 5);
define('RATE_LIMIT_WINDOW_SECONDS', (int) getenv('RATE_LIMIT_TIME_WINDOW') ?: 3600);

/**
* Initializes session if not already started.
* Must be called before any session operations.
*
* @return void
*/
function initializeCsrfSession(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}

/**
* Generates a new CSRF token and stores it in the session.
* The token is valid for 1 hour.
*
* @return string The generated CSRF token
*/
function generateCsrfToken(): string
{
initializeCsrfSession();

$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
$_SESSION['csrf_token_time'] = time();

return $token;
}

/**
* Validates a CSRF token from a POST request.
* Checks for token existence, validity, and expiration (1 hour).
*
* @param string $submittedToken The token from the form submission
* @return bool True if token is valid, false otherwise
*/
function validateCsrfToken(string $submittedToken): bool
{
initializeCsrfSession();

$sessionToken = $_SESSION['csrf_token'] ?? '';
$tokenTime = $_SESSION['csrf_token_time'] ?? 0;

// Token must exist in session and submitted form
if (empty($submittedToken) || empty($sessionToken)) {
return false;
}

// Token must match (timing-safe comparison)
if (!hash_equals($sessionToken, $submittedToken)) {
return false;
}

// Token must not be older than 1 hour
if (time() - $tokenTime > 3600) {
return false;
}

return true;
}

/**
* Invalidates the current CSRF token by removing it from session.
* Should be called after a successful form submission.
*
* @return void
*/
function invalidateCsrfToken(): void
{
initializeCsrfSession();
unset($_SESSION['csrf_token']);
unset($_SESSION['csrf_token_time']);
}

/**
* Validates the honeypot field to detect bots.
* Honeypot should be empty for legitimate users.
*
* @param string $honeypotValue The value from the honeypot field
* @return bool True if honeypot is empty (legitimate), false if filled (bot)
*/
function validateHoneypot(string $honeypotValue): bool
{
return empty($honeypotValue);
}

/**
* Gets the client IP address, considering proxies and forwarding headers.
*
* @return string The client IP address
*/
function getClientIp(): string
{
// Check for forwarded IP (behind proxy/load balancer)
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($ips[0]);
}

if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
return $_SERVER['HTTP_X_REAL_IP'];
}

return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}

/**
* Checks if an IP address has exceeded rate limit for a given action type.
*
* @param mysqli $connection Database connection
* @param string $ipAddress The client IP address
* @param string $actionType The action type ('feedback', 'save', 'submit')
* @param int $maxRequests Maximum allowed requests in the time window
* @param int $windowSeconds Time window in seconds (default 3600 = 1 hour)
* @return bool True if within rate limit, false if exceeded
*/
function checkRateLimit(
$connection,
string $ipAddress,
string $actionType,
int $maxRequests = 3,
int $windowSeconds = 3600
): bool
{
// Clean up old entries (older than 24 hours)
$cleanupSql = "DELETE FROM Rate_Limit WHERE submitted_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)";
mysqli_query($connection, $cleanupSql);

// Count recent submissions from this IP for this action type
$stmt = $connection->prepare(
"SELECT COUNT(*) as count FROM Rate_Limit
WHERE ip_address = ? AND action = ? AND submitted_at > DATE_SUB(NOW(), INTERVAL ? SECOND)"
);
$stmt->bind_param("ssi", $ipAddress, $actionType, $windowSeconds);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();

return ($row['count'] ?? 0) < $maxRequests;
}

/**
* Records a submission for rate limiting purposes.
*
* @param mysqli $connection Database connection
* @param string $ipAddress The client IP address
* @param string $actionType The action type ('feedback', 'save', 'submit')
* @return bool True if successfully recorded, false on error
*/
function recordRateLimit(
$connection,
string $ipAddress,
string $actionType
): bool
{
$stmt = $connection->prepare(
"INSERT INTO Rate_Limit (action, ip_address, submitted_at) VALUES (?, ?, NOW())"
);
$stmt->bind_param("ss", $actionType, $ipAddress);
$success = $stmt->execute();
$stmt->close();

return $success;
}
?>
4 changes: 4 additions & 0 deletions formgroups/honeypot.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- Honeypot field for bot detection - hidden from human users -->
<div style="position: absolute; left: -9999px;" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</div>
1 change: 1 addition & 0 deletions index.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
$mslLogoHtml = '<a href="https://epos-msl.uu.nl/" target="_blank" rel="noopener noreferrer"> <img src="logos/EPOS_logo.png" alt="MSL Logo" class="logo logo-right"> </a>';
$baseDir = __DIR__ . '/';
include $baseDir . 'header.php';
include $baseDir . 'formgroups/honeypot.html';
include $baseDir . 'formgroups/resourceInformation.html';

if ($showLicense) {
Expand Down
9 changes: 5 additions & 4 deletions install.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function dropTables($connection)
'Resource_has_Related_Work',
'Funding_Reference',
'Resource_has_Funding_Reference',
'Feedback_Rate_Limit',
'Rate_Limit',
// ICGEM-specific variables to describe beautiful GGMs
'GGM_Properties',
'Resource_has_GGM_Properties',
Expand Down Expand Up @@ -655,13 +655,14 @@ function createDatabaseStructure($connection): array
FOREIGN KEY (`data_source_id`) REFERENCES `Data_Sources`(`data_source_id`) ON DELETE CASCADE
);",

// Feedback rate limiting table for spam protection
"Feedback_Rate_Limit" => "CREATE TABLE IF NOT EXISTS `Feedback_Rate_Limit` (
// Unified rate limiting table for spam protection across all actions (feedback, save, submit)
"Rate_Limit" => "CREATE TABLE IF NOT EXISTS `Rate_Limit` (
`id` INT NOT NULL AUTO_INCREMENT,
`action` VARCHAR(50) NOT NULL,
`ip_address` VARCHAR(45) NOT NULL,
`submitted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_ip_time` (`ip_address`, `submitted_at`)
INDEX `idx_action_ip_time` (`action`, `ip_address`, `submitted_at`)
);",
];

Expand Down
49 changes: 46 additions & 3 deletions js/saveHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,51 @@ class SaveHandler {
notification: new bootstrap.Modal($(`#${notificationModalId}`)[0])
};
this.autosaveService = autosaveService;

// Security fields
this.$csrfTokenField = $('#input-save-csrf-token');
this.$timeSpentField = $('#input-save-time-spent');
this.$honeypotField = $('input[name="website"]');
this.modalOpenedAt = null;

this.initializeEventListeners();
}

/**
* Fetches a CSRF token from the server for form protection.
* @returns {Promise<string>} The CSRF token
*/
async fetchCsrfToken() {
try {
const response = await fetch('api/csrf_token.php');
const data = await response.json();
return data.token || '';
} catch (error) {
console.error('Failed to fetch CSRF token:', error);
return '';
}
}

/**
* Initialize event listeners
*/
initializeEventListeners() {
$('#button-saveas-save').on('click', () => this.handleSaveConfirm());
$('#modal-saveas').on('hidden.bs.modal', () => this.modals.notification.hide());

// Focus on input field
$('#modal-saveas').on('shown.bs.modal', () => {
// Focus on input field and fetch CSRF token
$('#modal-saveas').on('shown.bs.modal', async () => {
// Record when modal was opened for time-spent calculation
this.modalOpenedAt = Date.now();

// Fetch fresh CSRF token
const token = await this.fetchCsrfToken();
this.$csrfTokenField.val(token);

// Reset time spent and honeypot
this.$timeSpentField.val('0');
this.$honeypotField.val('');

$('#input-saveas-filename').select();
});
$('#modal-saveas').on('keydown', (e) => {
Expand Down Expand Up @@ -97,6 +130,12 @@ class SaveHandler {
return;
}

// Calculate time spent filling the save modal (in seconds)
if (this.modalOpenedAt) {
const timeSpent = Math.floor((Date.now() - this.modalOpenedAt) / 1000);
this.$timeSpentField.val(timeSpent);
}

this.modals.saveAs.hide();
await this.saveAndDownload(filename);
}
Expand All @@ -116,7 +155,11 @@ class SaveHandler {
try {
const formData = new FormData(this.$form[0]);
formData.append('filename', filename);
formData.append('action', 'save_and_download');

// Append security fields
formData.append('csrf_token', this.$csrfTokenField.val());
formData.append('save_time_spent', this.$timeSpentField.val());
formData.append('website', this.$honeypotField.val());

const response = await fetch('save/save_data.php', {
method: 'POST',
Expand Down
Loading
Loading