1<?php 2/** 3 * Delete Page Guard for DokuWiki 4 * 5 * This action plugin prevents the deletion of pages by blocking "empty save" 6 * operations on pages whose IDs or file paths match a set of user‑defined 7 * regular expressions. Administrators (superusers) and optionally configured 8 * exempt groups are allowed to delete pages regardless of these patterns. 9 * 10 * @license GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html) - see LICENSE.md 11 * @author Johann Duscher <jonny.dee@posteo.net> 12 * @copyright 2025 Johann Duscher 13 */ 14 15use dokuwiki\Extension\ActionPlugin; 16use dokuwiki\Extension\Event; 17use dokuwiki\Extension\EventHandler; 18 19// Protect against direct call 20if (!defined('DOKU_INC')) die(); 21 22/** 23 * Class action_plugin_deletepageguard 24 * 25 * Registers a handler on COMMON_WIKIPAGE_SAVE to intercept page save 26 * operations. When a deletion (empty save) is attempted on a protected page 27 * by a non‑admin user, the save is prevented and an error message is shown. 28 */ 29class action_plugin_deletepageguard extends ActionPlugin { 30 31 /** 32 * Register the plugin events 33 * 34 * @param EventHandler $controller 35 * @return void 36 */ 37 public function register(EventHandler $controller) { 38 // Run before the page is saved so we can abort the delete 39 $controller->register_hook('COMMON_WIKIPAGE_SAVE', 'BEFORE', $this, 'handle_common_wikipage_save'); 40 } 41 42 /** 43 * Handler for the COMMON_WIKIPAGE_SAVE event 44 * 45 * This method checks whether the save operation represents a deletion 46 * (i.e. the new content is empty) and whether the page matches one of 47 * the configured regular expressions. If so, and the current user is 48 * neither an administrator nor in one of the exempt groups, the 49 * deletion is prevented. 50 * 51 * @param Event $event The event object 52 * @param mixed $param Additional parameters (unused) 53 * @return void 54 */ 55 public function handle_common_wikipage_save(Event $event, $param) { 56 global $USERINFO, $conf; 57 58 // Only take action when the event is preventable 59 if (!$event->canPreventDefault) { 60 return; 61 } 62 63 // Allow administrators to delete pages 64 if (function_exists('auth_isadmin') && auth_isadmin()) { 65 return; 66 } 67 68 // Check for exempt groups configuration 69 $exemptSetting = (string)$this->getConf('exempt_groups'); 70 $exemptGroups = array_filter(array_map('trim', explode(',', $exemptSetting))); 71 72 if (!empty($exemptGroups) && isset($USERINFO['grps']) && is_array($USERINFO['grps'])) { 73 foreach ($USERINFO['grps'] as $group) { 74 if (in_array($group, $exemptGroups, true)) { 75 // User is in an exempt group, allow deletion 76 return; 77 } 78 } 79 } 80 81 // Determine if the save represents a deletion 82 $newContent = isset($event->data['newContent']) ? $event->data['newContent'] : ''; 83 $trimMode = (bool)$this->getConf('trim_mode'); 84 $isEmpty = $trimMode ? trim($newContent) === '' : $newContent === ''; 85 86 if (!$isEmpty) { 87 // Not empty – normal edit, allow saving 88 return; 89 } 90 91 // Determine the matching target: page ID or relative file path 92 $matchTarget = $this->getConf('match_target') === 'filepath' ? 93 $this->getRelativeFilePath($event->data['file'], $conf['datadir']) : 94 $event->data['id']; 95 96 // Retrieve regex patterns from configuration 97 $patternsSetting = (string)$this->getConf('patterns'); 98 $patternLines = preg_split('/\R+/', $patternsSetting, -1, PREG_SPLIT_NO_EMPTY); 99 100 $hasValidationErrors = false; 101 foreach ($patternLines as $lineNumber => $rawPattern) { 102 $pattern = trim($rawPattern); 103 if ($pattern === '') { 104 continue; 105 } 106 107 // Validate and secure the regex pattern 108 $validationResult = $this->validateRegexPattern($pattern, $lineNumber + 1); 109 if ($validationResult !== true) { 110 // Show validation error to administrators 111 if (function_exists('auth_isadmin') && auth_isadmin()) { 112 msg($validationResult, 2); // Warning level 113 } 114 $hasValidationErrors = true; 115 continue; 116 } 117 118 // Apply the regex with timeout protection 119 if ($this->matchesPattern($pattern, $matchTarget)) { 120 // Match found – prevent deletion 121 $event->preventDefault(); 122 $event->stopPropagation(); 123 msg($this->getLang('deny_msg'), -1); 124 return; 125 } 126 } 127 128 // If there were validation errors, show a summary message to admins 129 if ($hasValidationErrors && function_exists('auth_isadmin') && auth_isadmin()) { 130 msg($this->getLang('config_validation_errors'), 2); 131 } 132 } 133 134 /** 135 * Convert an absolute file path into a relative one below the data directory 136 * 137 * The COMMON_WIKIPAGE_SAVE event provides the absolute file path. When 138 * matching against the file path, we want a path relative to the base 139 * pages directory so users can write concise regular expressions. 140 * 141 * @param string $fullPath Absolute path to the file 142 * @param string $dataDir Base data directory (usually $conf['datadir']) 143 * @return string Relative file path 144 */ 145 protected function getRelativeFilePath($fullPath, $dataDir) { 146 // Normalize path separators to forward slashes for consistency 147 $fullPath = str_replace('\\', '/', $fullPath); 148 $dataDir = str_replace('\\', '/', $dataDir); 149 150 $base = rtrim($dataDir, '/'); 151 // DokuWiki stores pages in $datadir/pages 152 $pagesDir = $base . '/pages/'; 153 if (strpos($fullPath, $pagesDir) === 0) { 154 return substr($fullPath, strlen($pagesDir)); 155 } 156 // Fallback: attempt to strip base datadir 157 if (strpos($fullPath, $base . '/') === 0) { 158 return substr($fullPath, strlen($base) + 1); 159 } 160 return $fullPath; 161 } 162 163 /** 164 * Validate a regex pattern for security and correctness 165 * 166 * Performs basic validation to prevent ReDoS attacks and ensure the 167 * pattern is syntactically correct. Returns detailed error messages. 168 * This method is public to allow the admin plugin to reuse validation logic. 169 * 170 * @param string $pattern The regex pattern to validate 171 * @param int $lineNumber The line number for error reporting 172 * @return string|true True if valid, error message string if invalid 173 */ 174 public function validateRegexPattern($pattern, $lineNumber = 0) { 175 $linePrefix = $lineNumber > 0 ? "Line $lineNumber: " : ""; 176 177 // Empty patterns are not valid 178 if (trim($pattern) === '') { 179 return $linePrefix . 'Empty pattern is not allowed'; 180 } 181 182 // Check for obviously malicious patterns (basic ReDoS protection) 183 // Detect patterns like (a+)+, (x+)*, (a+)+b, etc. 184 // Look for nested quantifiers: (anything with + or *) followed by + or * 185 if (preg_match('/\([^)]*[\+\*][^)]*\)[\+\*]/', $pattern) || 186 preg_match('/\(.+[\+\*]\)[\+\*]/', $pattern)) { 187 return $linePrefix . sprintf($this->getLang('pattern_redos_warning'), $pattern); 188 } 189 190 // Limit pattern length to prevent extremely complex patterns 191 if (strlen($pattern) > 1000) { 192 return $linePrefix . sprintf($this->getLang('pattern_too_long'), $pattern); 193 } 194 195 // Test if the pattern is syntactically valid 196 $escapedPattern = '/' . str_replace('/', '\/', $pattern) . '/u'; 197 $test = @preg_match($escapedPattern, ''); 198 if ($test === false) { 199 $error = error_get_last(); 200 $errorMsg = $error && isset($error['message']) ? $error['message'] : 'Unknown regex error'; 201 return $linePrefix . sprintf($this->getLang('pattern_invalid_syntax'), $pattern, $errorMsg); 202 } 203 204 return true; 205 } 206 207 /** 208 * Safely match a pattern against a target string with timeout protection 209 * 210 * Applies the regex pattern with error handling and basic timeout protection 211 * to prevent ReDoS attacks. 212 * 213 * @param string $pattern The validated regex pattern 214 * @param string $target The string to match against 215 * @return bool True if the pattern matches, false otherwise 216 */ 217 protected function matchesPattern($pattern, $target) { 218 // Escape forward slashes in pattern to use with / delimiters 219 $escapedPattern = '/' . str_replace('/', '\/', $pattern) . '/u'; 220 221 // Set a reasonable time limit for regex execution (basic ReDoS protection) 222 $oldTimeLimit = ini_get('max_execution_time'); 223 if ($oldTimeLimit > 5) { 224 @set_time_limit(5); 225 } 226 227 $result = @preg_match($escapedPattern, $target); 228 229 // Restore original time limit 230 if ($oldTimeLimit > 5) { 231 @set_time_limit($oldTimeLimit); 232 } 233 234 return $result === 1; 235 } 236} 237