xref: /plugin/deletepageguard/action.php (revision 9a383d51b90310842e2a3f0f9d693178d0875b32)
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