diff --git a/.env_sample b/.env_sample index 4288c46ec..81172d708 100644 --- a/.env_sample +++ b/.env_sample @@ -10,4 +10,13 @@ SMTP_USER=your_smtp_username SMTP_PASSWORD=your_smtp_password SMTP_SENDER=your_smtp_sender_email FEEDBACK_ADDRESS=feedback@example.com -XML_SUBMIT_ADDRESS=xmlsubmit@example.com \ No newline at end of file +XML_SUBMIT_ADDRESS=xmlsubmit@example.com +# 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 \ No newline at end of file diff --git a/api/csrf_token.php b/api/csrf_token.php index df0c3e9f5..7efd27bdd 100644 --- a/api/csrf_token.php +++ b/api/csrf_token.php @@ -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, diff --git a/api/security.php b/api/security.php new file mode 100644 index 000000000..5fb25ffc4 --- /dev/null +++ b/api/security.php @@ -0,0 +1,203 @@ + 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; +} +?> diff --git a/formgroups/honeypot.html b/formgroups/honeypot.html new file mode 100644 index 000000000..00c4495aa --- /dev/null +++ b/formgroups/honeypot.html @@ -0,0 +1,4 @@ + +
diff --git a/index.php b/index.php index 7ed1a8513..4ccfad3c8 100644 --- a/index.php +++ b/index.php @@ -51,6 +51,7 @@ $mslLogoHtml = '
';
$baseDir = __DIR__ . '/';
include $baseDir . 'header.php';
+include $baseDir . 'formgroups/honeypot.html';
include $baseDir . 'formgroups/resourceInformation.html';
if ($showLicense) {
diff --git a/install.php b/install.php
index 09cbea0ed..0f7182bab 100644
--- a/install.php
+++ b/install.php
@@ -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',
@@ -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`)
);",
];
diff --git a/js/saveHandler.js b/js/saveHandler.js
index a2f6458a9..6ba0cfdcd 100644
--- a/js/saveHandler.js
+++ b/js/saveHandler.js
@@ -19,9 +19,31 @@ 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