1e39ccd63SJonny Dee<?php 2e39ccd63SJonny Dee/** 3994617c9SJohann Duscher * Delete Page Guard for DokuWiki 4e39ccd63SJonny Dee * 5e39ccd63SJonny Dee * This action plugin prevents the deletion of pages by blocking "empty save" 6e39ccd63SJonny Dee * operations on pages whose IDs or file paths match a set of user‑defined 7e39ccd63SJonny Dee * regular expressions. Administrators (superusers) and optionally configured 8e39ccd63SJonny Dee * exempt groups are allowed to delete pages regardless of these patterns. 9e39ccd63SJonny Dee * 100da69785SJohann Duscher * @license GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html) - see LICENSE.md 11994617c9SJohann Duscher * @author Johann Duscher <jonny.dee@posteo.net> 120da69785SJohann Duscher * @copyright 2025 Johann Duscher 13e39ccd63SJonny Dee */ 14e39ccd63SJonny Dee 15e39ccd63SJonny Deeuse dokuwiki\Extension\ActionPlugin; 16e39ccd63SJonny Deeuse dokuwiki\Extension\Event; 17e39ccd63SJonny Deeuse dokuwiki\Extension\EventHandler; 18e39ccd63SJonny Dee 19e39ccd63SJonny Dee// Protect against direct call 20e39ccd63SJonny Deeif (!defined('DOKU_INC')) die(); 21e39ccd63SJonny Dee 22e39ccd63SJonny Dee/** 23994617c9SJohann Duscher * Class action_plugin_deletepageguard 24e39ccd63SJonny Dee * 25e39ccd63SJonny Dee * Registers a handler on COMMON_WIKIPAGE_SAVE to intercept page save 26e39ccd63SJonny Dee * operations. When a deletion (empty save) is attempted on a protected page 27e39ccd63SJonny Dee * by a non‑admin user, the save is prevented and an error message is shown. 28e39ccd63SJonny Dee */ 29994617c9SJohann Duscherclass action_plugin_deletepageguard extends ActionPlugin { 30e39ccd63SJonny Dee 31e39ccd63SJonny Dee /** 32e39ccd63SJonny Dee * Register the plugin events 33e39ccd63SJonny Dee * 34e39ccd63SJonny Dee * @param EventHandler $controller 35e39ccd63SJonny Dee * @return void 36e39ccd63SJonny Dee */ 37e39ccd63SJonny Dee public function register(EventHandler $controller) { 38e39ccd63SJonny Dee // Run before the page is saved so we can abort the delete 39e39ccd63SJonny Dee $controller->register_hook('COMMON_WIKIPAGE_SAVE', 'BEFORE', $this, 'handle_common_wikipage_save'); 40e39ccd63SJonny Dee } 41e39ccd63SJonny Dee 42e39ccd63SJonny Dee /** 43e39ccd63SJonny Dee * Handler for the COMMON_WIKIPAGE_SAVE event 44e39ccd63SJonny Dee * 45e39ccd63SJonny Dee * This method checks whether the save operation represents a deletion 46e39ccd63SJonny Dee * (i.e. the new content is empty) and whether the page matches one of 47e39ccd63SJonny Dee * the configured regular expressions. If so, and the current user is 48e39ccd63SJonny Dee * neither an administrator nor in one of the exempt groups, the 49e39ccd63SJonny Dee * deletion is prevented. 50e39ccd63SJonny Dee * 51e39ccd63SJonny Dee * @param Event $event The event object 52e39ccd63SJonny Dee * @param mixed $param Additional parameters (unused) 53e39ccd63SJonny Dee * @return void 54e39ccd63SJonny Dee */ 55e39ccd63SJonny Dee public function handle_common_wikipage_save(Event $event, $param) { 56e39ccd63SJonny Dee global $USERINFO, $conf; 57e39ccd63SJonny Dee 58e39ccd63SJonny Dee // Only take action when the event is preventable 59e39ccd63SJonny Dee if (!$event->canPreventDefault) { 60e39ccd63SJonny Dee return; 61e39ccd63SJonny Dee } 62e39ccd63SJonny Dee 63e39ccd63SJonny Dee // Allow administrators to delete pages 64e39ccd63SJonny Dee if (function_exists('auth_isadmin') && auth_isadmin()) { 65e39ccd63SJonny Dee return; 66e39ccd63SJonny Dee } 67e39ccd63SJonny Dee 68e39ccd63SJonny Dee // Check for exempt groups configuration 69e39ccd63SJonny Dee $exemptSetting = (string)$this->getConf('exempt_groups'); 70e39ccd63SJonny Dee $exemptGroups = array_filter(array_map('trim', explode(',', $exemptSetting))); 71e39ccd63SJonny Dee 72e39ccd63SJonny Dee if (!empty($exemptGroups) && isset($USERINFO['grps']) && is_array($USERINFO['grps'])) { 73e39ccd63SJonny Dee foreach ($USERINFO['grps'] as $group) { 74e39ccd63SJonny Dee if (in_array($group, $exemptGroups, true)) { 75e39ccd63SJonny Dee // User is in an exempt group, allow deletion 76e39ccd63SJonny Dee return; 77e39ccd63SJonny Dee } 78e39ccd63SJonny Dee } 79e39ccd63SJonny Dee } 80e39ccd63SJonny Dee 81e39ccd63SJonny Dee // Determine if the save represents a deletion 82e39ccd63SJonny Dee $newContent = isset($event->data['newContent']) ? $event->data['newContent'] : ''; 83e39ccd63SJonny Dee $trimMode = (bool)$this->getConf('trim_mode'); 84e39ccd63SJonny Dee $isEmpty = $trimMode ? trim($newContent) === '' : $newContent === ''; 85e39ccd63SJonny Dee 86e39ccd63SJonny Dee if (!$isEmpty) { 87e39ccd63SJonny Dee // Not empty – normal edit, allow saving 88e39ccd63SJonny Dee return; 89e39ccd63SJonny Dee } 90e39ccd63SJonny Dee 91e39ccd63SJonny Dee // Determine the matching target: page ID or relative file path 92e39ccd63SJonny Dee $matchTarget = $this->getConf('match_target') === 'filepath' ? 93e39ccd63SJonny Dee $this->getRelativeFilePath($event->data['file'], $conf['datadir']) : 94e39ccd63SJonny Dee $event->data['id']; 95e39ccd63SJonny Dee 96e39ccd63SJonny Dee // Retrieve regex patterns from configuration 97e39ccd63SJonny Dee $patternsSetting = (string)$this->getConf('patterns'); 98e39ccd63SJonny Dee $patternLines = preg_split('/\R+/', $patternsSetting, -1, PREG_SPLIT_NO_EMPTY); 99e39ccd63SJonny Dee 1001a97af9eSJohann Duscher $hasValidationErrors = false; 1011a97af9eSJohann Duscher foreach ($patternLines as $lineNumber => $rawPattern) { 102e39ccd63SJonny Dee $pattern = trim($rawPattern); 103e39ccd63SJonny Dee if ($pattern === '') { 104e39ccd63SJonny Dee continue; 105e39ccd63SJonny Dee } 1060da69785SJohann Duscher 1070da69785SJohann Duscher // Validate and secure the regex pattern 1081a97af9eSJohann Duscher $validationResult = $this->validateRegexPattern($pattern, $lineNumber + 1); 1091a97af9eSJohann Duscher if ($validationResult !== true) { 1101a97af9eSJohann Duscher // Show validation error to administrators 1111a97af9eSJohann Duscher if (function_exists('auth_isadmin') && auth_isadmin()) { 1121a97af9eSJohann Duscher msg($validationResult, 2); // Warning level 1131a97af9eSJohann Duscher } 1141a97af9eSJohann Duscher $hasValidationErrors = true; 115e39ccd63SJonny Dee continue; 116e39ccd63SJonny Dee } 1170da69785SJohann Duscher 1180da69785SJohann Duscher // Apply the regex with timeout protection 1190da69785SJohann Duscher if ($this->matchesPattern($pattern, $matchTarget)) { 120e39ccd63SJonny Dee // Match found – prevent deletion 121e39ccd63SJonny Dee $event->preventDefault(); 122e39ccd63SJonny Dee $event->stopPropagation(); 123e39ccd63SJonny Dee msg($this->getLang('deny_msg'), -1); 124e39ccd63SJonny Dee return; 125e39ccd63SJonny Dee } 126e39ccd63SJonny Dee } 1271a97af9eSJohann Duscher 1281a97af9eSJohann Duscher // If there were validation errors, show a summary message to admins 1291a97af9eSJohann Duscher if ($hasValidationErrors && function_exists('auth_isadmin') && auth_isadmin()) { 1301a97af9eSJohann Duscher msg($this->getLang('config_validation_errors'), 2); 1311a97af9eSJohann Duscher } 132e39ccd63SJonny Dee } 133e39ccd63SJonny Dee 134e39ccd63SJonny Dee /** 135e39ccd63SJonny Dee * Convert an absolute file path into a relative one below the data directory 136e39ccd63SJonny Dee * 137e39ccd63SJonny Dee * The COMMON_WIKIPAGE_SAVE event provides the absolute file path. When 138e39ccd63SJonny Dee * matching against the file path, we want a path relative to the base 139e39ccd63SJonny Dee * pages directory so users can write concise regular expressions. 140e39ccd63SJonny Dee * 141e39ccd63SJonny Dee * @param string $fullPath Absolute path to the file 142e39ccd63SJonny Dee * @param string $dataDir Base data directory (usually $conf['datadir']) 143e39ccd63SJonny Dee * @return string Relative file path 144e39ccd63SJonny Dee */ 145e39ccd63SJonny Dee protected function getRelativeFilePath($fullPath, $dataDir) { 146bbe7181bSJohann Duscher // Normalize path separators to forward slashes for consistency 147bbe7181bSJohann Duscher $fullPath = str_replace('\\', '/', $fullPath); 148bbe7181bSJohann Duscher $dataDir = str_replace('\\', '/', $dataDir); 149bbe7181bSJohann Duscher 150e39ccd63SJonny Dee $base = rtrim($dataDir, '/'); 151e39ccd63SJonny Dee // DokuWiki stores pages in $datadir/pages 152e39ccd63SJonny Dee $pagesDir = $base . '/pages/'; 153e39ccd63SJonny Dee if (strpos($fullPath, $pagesDir) === 0) { 154e39ccd63SJonny Dee return substr($fullPath, strlen($pagesDir)); 155e39ccd63SJonny Dee } 156e39ccd63SJonny Dee // Fallback: attempt to strip base datadir 157e39ccd63SJonny Dee if (strpos($fullPath, $base . '/') === 0) { 158e39ccd63SJonny Dee return substr($fullPath, strlen($base) + 1); 159e39ccd63SJonny Dee } 160e39ccd63SJonny Dee return $fullPath; 161e39ccd63SJonny Dee } 1620da69785SJohann Duscher 1630da69785SJohann Duscher /** 164*9a383d51SJohann Duscher * Validate a regex pattern for security and correctness 1650da69785SJohann Duscher * 1660da69785SJohann Duscher * Performs basic validation to prevent ReDoS attacks and ensure the 1671a97af9eSJohann Duscher * pattern is syntactically correct. Returns detailed error messages. 168*9a383d51SJohann Duscher * This method is public to allow the admin plugin to reuse validation logic. 1690da69785SJohann Duscher * 1700da69785SJohann Duscher * @param string $pattern The regex pattern to validate 1711a97af9eSJohann Duscher * @param int $lineNumber The line number for error reporting 1721a97af9eSJohann Duscher * @return string|true True if valid, error message string if invalid 1730da69785SJohann Duscher */ 174*9a383d51SJohann Duscher public function validateRegexPattern($pattern, $lineNumber = 0) { 1751a97af9eSJohann Duscher $linePrefix = $lineNumber > 0 ? "Line $lineNumber: " : ""; 1761a97af9eSJohann Duscher 177bbe7181bSJohann Duscher // Empty patterns are not valid 178bbe7181bSJohann Duscher if (trim($pattern) === '') { 179bbe7181bSJohann Duscher return $linePrefix . 'Empty pattern is not allowed'; 180bbe7181bSJohann Duscher } 181bbe7181bSJohann Duscher 1820da69785SJohann Duscher // Check for obviously malicious patterns (basic ReDoS protection) 183bbe7181bSJohann Duscher // Detect patterns like (a+)+, (x+)*, (a+)+b, etc. 184*9a383d51SJohann Duscher // Look for nested quantifiers: (anything with + or *) followed by + or * 185*9a383d51SJohann Duscher if (preg_match('/\([^)]*[\+\*][^)]*\)[\+\*]/', $pattern) || 186*9a383d51SJohann Duscher preg_match('/\(.+[\+\*]\)[\+\*]/', $pattern)) { 1871a97af9eSJohann Duscher return $linePrefix . sprintf($this->getLang('pattern_redos_warning'), $pattern); 1880da69785SJohann Duscher } 1890da69785SJohann Duscher 1900da69785SJohann Duscher // Limit pattern length to prevent extremely complex patterns 1910da69785SJohann Duscher if (strlen($pattern) > 1000) { 1921a97af9eSJohann Duscher return $linePrefix . sprintf($this->getLang('pattern_too_long'), $pattern); 1930da69785SJohann Duscher } 1940da69785SJohann Duscher 1950da69785SJohann Duscher // Test if the pattern is syntactically valid 1961a97af9eSJohann Duscher $escapedPattern = '/' . str_replace('/', '\/', $pattern) . '/u'; 1971a97af9eSJohann Duscher $test = @preg_match($escapedPattern, ''); 1980da69785SJohann Duscher if ($test === false) { 1991a97af9eSJohann Duscher $error = error_get_last(); 2001a97af9eSJohann Duscher $errorMsg = $error && isset($error['message']) ? $error['message'] : 'Unknown regex error'; 2011a97af9eSJohann Duscher return $linePrefix . sprintf($this->getLang('pattern_invalid_syntax'), $pattern, $errorMsg); 2020da69785SJohann Duscher } 2030da69785SJohann Duscher 2040da69785SJohann Duscher return true; 2050da69785SJohann Duscher } 2060da69785SJohann Duscher 2070da69785SJohann Duscher /** 2080da69785SJohann Duscher * Safely match a pattern against a target string with timeout protection 2090da69785SJohann Duscher * 2100da69785SJohann Duscher * Applies the regex pattern with error handling and basic timeout protection 2110da69785SJohann Duscher * to prevent ReDoS attacks. 2120da69785SJohann Duscher * 2130da69785SJohann Duscher * @param string $pattern The validated regex pattern 2140da69785SJohann Duscher * @param string $target The string to match against 2150da69785SJohann Duscher * @return bool True if the pattern matches, false otherwise 2160da69785SJohann Duscher */ 2170da69785SJohann Duscher protected function matchesPattern($pattern, $target) { 2180da69785SJohann Duscher // Escape forward slashes in pattern to use with / delimiters 2190da69785SJohann Duscher $escapedPattern = '/' . str_replace('/', '\/', $pattern) . '/u'; 2200da69785SJohann Duscher 2210da69785SJohann Duscher // Set a reasonable time limit for regex execution (basic ReDoS protection) 2220da69785SJohann Duscher $oldTimeLimit = ini_get('max_execution_time'); 2230da69785SJohann Duscher if ($oldTimeLimit > 5) { 2240da69785SJohann Duscher @set_time_limit(5); 2250da69785SJohann Duscher } 2260da69785SJohann Duscher 2270da69785SJohann Duscher $result = @preg_match($escapedPattern, $target); 2280da69785SJohann Duscher 2290da69785SJohann Duscher // Restore original time limit 2300da69785SJohann Duscher if ($oldTimeLimit > 5) { 2310da69785SJohann Duscher @set_time_limit($oldTimeLimit); 2320da69785SJohann Duscher } 2330da69785SJohann Duscher 2340da69785SJohann Duscher return $result === 1; 2350da69785SJohann Duscher } 236e39ccd63SJonny Dee} 237