xref: /plugin/deletepageguard/action.php (revision c176b8b34e4ca9181ac383002f75ffe5441c68b3)
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        $base = rtrim($dataDir, '/');
147        // DokuWiki stores pages in $datadir/pages
148        $pagesDir = $base . '/pages/';
149        if (strpos($fullPath, $pagesDir) === 0) {
150            return substr($fullPath, strlen($pagesDir));
151        }
152        // Fallback: attempt to strip base datadir
153        if (strpos($fullPath, $base . '/') === 0) {
154            return substr($fullPath, strlen($base) + 1);
155        }
156        return $fullPath;
157    }
158
159    /**
160     * Validate a regular expression pattern for security and correctness
161     *
162     * Performs basic validation to prevent ReDoS attacks and ensure the
163     * pattern is syntactically correct. Returns detailed error messages.
164     *
165     * @param string $pattern The regex pattern to validate
166     * @param int $lineNumber The line number for error reporting
167     * @return string|true True if valid, error message string if invalid
168     */
169    protected function validateRegexPattern($pattern, $lineNumber = 0) {
170        $linePrefix = $lineNumber > 0 ? "Line $lineNumber: " : "";
171
172        // Check for obviously malicious patterns (basic ReDoS protection)
173        if (preg_match('/(\(.*\).*\+.*\(.*\).*\+)|(\(.*\).*\*.*\(.*\).*\*)/', $pattern)) {
174            return $linePrefix . sprintf($this->getLang('pattern_redos_warning'), $pattern);
175        }
176
177        // Limit pattern length to prevent extremely complex patterns
178        if (strlen($pattern) > 1000) {
179            return $linePrefix . sprintf($this->getLang('pattern_too_long'), $pattern);
180        }
181
182        // Test if the pattern is syntactically valid
183        $escapedPattern = '/' . str_replace('/', '\/', $pattern) . '/u';
184        $test = @preg_match($escapedPattern, '');
185        if ($test === false) {
186            $error = error_get_last();
187            $errorMsg = $error && isset($error['message']) ? $error['message'] : 'Unknown regex error';
188            return $linePrefix . sprintf($this->getLang('pattern_invalid_syntax'), $pattern, $errorMsg);
189        }
190
191        return true;
192    }
193
194    /**
195     * Safely match a pattern against a target string with timeout protection
196     *
197     * Applies the regex pattern with error handling and basic timeout protection
198     * to prevent ReDoS attacks.
199     *
200     * @param string $pattern The validated regex pattern
201     * @param string $target  The string to match against
202     * @return bool True if the pattern matches, false otherwise
203     */
204    protected function matchesPattern($pattern, $target) {
205        // Escape forward slashes in pattern to use with / delimiters
206        $escapedPattern = '/' . str_replace('/', '\/', $pattern) . '/u';
207
208        // Set a reasonable time limit for regex execution (basic ReDoS protection)
209        $oldTimeLimit = ini_get('max_execution_time');
210        if ($oldTimeLimit > 5) {
211            @set_time_limit(5);
212        }
213
214        $result = @preg_match($escapedPattern, $target);
215
216        // Restore original time limit
217        if ($oldTimeLimit > 5) {
218            @set_time_limit($oldTimeLimit);
219        }
220
221        return $result === 1;
222    }
223}
224