xref: /plugin/deletepageguard/action.php (revision 0da697856a21f6e79666d7fe1c6a3ae059c82150)
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        foreach ($patternLines as $rawPattern) {
101            $pattern = trim($rawPattern);
102            if ($pattern === '') {
103                continue;
104            }
105
106            // Validate and secure the regex pattern
107            if (!$this->validateRegexPattern($pattern)) {
108                continue;
109            }
110
111            // Apply the regex with timeout protection
112            if ($this->matchesPattern($pattern, $matchTarget)) {
113                // Match found – prevent deletion
114                $event->preventDefault();
115                $event->stopPropagation();
116                msg($this->getLang('deny_msg'), -1);
117                return;
118            }
119        }
120    }
121
122    /**
123     * Convert an absolute file path into a relative one below the data directory
124     *
125     * The COMMON_WIKIPAGE_SAVE event provides the absolute file path. When
126     * matching against the file path, we want a path relative to the base
127     * pages directory so users can write concise regular expressions.
128     *
129     * @param string $fullPath Absolute path to the file
130     * @param string $dataDir  Base data directory (usually $conf['datadir'])
131     * @return string Relative file path
132     */
133    protected function getRelativeFilePath($fullPath, $dataDir) {
134        $base = rtrim($dataDir, '/');
135        // DokuWiki stores pages in $datadir/pages
136        $pagesDir = $base . '/pages/';
137        if (strpos($fullPath, $pagesDir) === 0) {
138            return substr($fullPath, strlen($pagesDir));
139        }
140        // Fallback: attempt to strip base datadir
141        if (strpos($fullPath, $base . '/') === 0) {
142            return substr($fullPath, strlen($base) + 1);
143        }
144        return $fullPath;
145    }
146
147    /**
148     * Validate a regular expression pattern for security and correctness
149     *
150     * Performs basic validation to prevent ReDoS attacks and ensure the
151     * pattern is syntactically correct. Logs warnings for invalid patterns.
152     *
153     * @param string $pattern The regex pattern to validate
154     * @return bool True if the pattern is valid and safe, false otherwise
155     */
156    protected function validateRegexPattern($pattern) {
157        // Check for obviously malicious patterns (basic ReDoS protection)
158        if (preg_match('/(\(.*\).*\+.*\(.*\).*\+)|(\(.*\).*\*.*\(.*\).*\*)/', $pattern)) {
159            // Pattern looks like it could cause catastrophic backtracking
160            return false;
161        }
162
163        // Limit pattern length to prevent extremely complex patterns
164        if (strlen($pattern) > 1000) {
165            return false;
166        }
167
168        // Test if the pattern is syntactically valid
169        $test = @preg_match('/' . str_replace('/', '\/', $pattern) . '/u', '');
170        if ($test === false) {
171            return false;
172        }
173
174        return true;
175    }
176
177    /**
178     * Safely match a pattern against a target string with timeout protection
179     *
180     * Applies the regex pattern with error handling and basic timeout protection
181     * to prevent ReDoS attacks.
182     *
183     * @param string $pattern The validated regex pattern
184     * @param string $target  The string to match against
185     * @return bool True if the pattern matches, false otherwise
186     */
187    protected function matchesPattern($pattern, $target) {
188        // Escape forward slashes in pattern to use with / delimiters
189        $escapedPattern = '/' . str_replace('/', '\/', $pattern) . '/u';
190
191        // Set a reasonable time limit for regex execution (basic ReDoS protection)
192        $oldTimeLimit = ini_get('max_execution_time');
193        if ($oldTimeLimit > 5) {
194            @set_time_limit(5);
195        }
196
197        $result = @preg_match($escapedPattern, $target);
198
199        // Restore original time limit
200        if ($oldTimeLimit > 5) {
201            @set_time_limit($oldTimeLimit);
202        }
203
204        return $result === 1;
205    }
206}
207