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} 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 */ @@ -29,8 +51,19 @@ class SaveHandler { $('#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) => { @@ -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); } @@ -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', diff --git a/js/submitHandler.js b/js/submitHandler.js index a00fb1215..341dc997c 100644 --- a/js/submitHandler.js +++ b/js/submitHandler.js @@ -139,6 +139,12 @@ class SubmitHandler { this.$selectedFileName = $('#selected-file-name'); this.autosaveService = autosaveService; + // Security field references + this.$csrfTokenField = $('#input-submit-csrf-token'); + this.$timeSpentField = $('#input-submit-time-spent'); + this.$honeypotField = $('input[name="website"]'); + this.modalOpenedAt = null; + this.initializeEventListeners(); this.initializeFileHandlers(); this.$removeFileBtn.hide(); @@ -152,10 +158,15 @@ class SubmitHandler { $('#button-submit-submit').on('click', () => this.handleModalSubmit()); this.$form.on('change', 'input[name="contacts[]"]', validateContactPerson); - // Focus on input field - $('#modal-submit').on('shown.bs.modal', () => { + // Fetch CSRF token and reset security fields on modal open + $('#modal-submit').on('shown.bs.modal', async () => { + await this.fetchCsrfToken(); + this.modalOpenedAt = Date.now(); + this.$honeypotField.val(''); // Ensure honeypot is empty + this.$timeSpentField.val('0'); $('#input-submit-dataurl').select(); }); + $('#modal-submit').on('keydown', (e) => { // KeyCode 13? (Enter) if (e.which === 13 || e.keyCode === 13) { @@ -174,6 +185,23 @@ class SubmitHandler { }); } + /** + * Fetch fresh CSRF token from the server + */ + async fetchCsrfToken() { + try { + const response = await fetch('api/csrf_token.php'); + const data = await response.json(); + if (data.token) { + this.$csrfTokenField.val(data.token); + } else { + console.error('No token in response:', data); + } + } catch (error) { + console.error('Error fetching CSRF token:', error); + } + } + /** * Initialize file input handlers */ @@ -241,8 +269,21 @@ class SubmitHandler { if (this.autosaveService) { await this.autosaveService.flushPending(); } + + // Calculate time spent in modal + if (this.modalOpenedAt) { + const timeSpent = Math.floor((Date.now() - this.modalOpenedAt) / 1000); + this.$timeSpentField.val(timeSpent); + } + const submitData = new FormData(this.$form[0]); + // Explicitly add CSRF token (it's in the modal, not in the main form) + const csrfToken = this.$csrfTokenField.val(); + if (csrfToken) { + submitData.set('csrf_token', csrfToken); + } + submitData.append('urgency', $('#input-submit-urgency').val()); submitData.append('dataUrl', $('#input-submit-dataurl').val()); diff --git a/modals.html b/modals.html index 898a8397e..0fda8fb76 100644 --- a/modals.html +++ b/modals.html @@ -139,6 +139,15 @@