PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / redirects / RegexDestinationTemplateValidator.php

RegexDestinationTemplateValidator.php in 404 Solution trunk, at includes/redirects/RegexDestinationTemplateValidator.php

227 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Validates root-relative replacement templates used by regex redirects.
9 *
10 * The runtime supports numbered `$N` substitutions. This validator keeps the
11 * admin write boundary aligned with that contract so a relative destination
12 * cannot be stored with syntax the matcher will leave unresolved.
13 */
14 class ABJ_404_Solution_RegexDestinationTemplateValidator {
15
16 /** @var ABJ_404_Solution_RegexSourcePatternValidator */
17 private $sourceValidator;
18
19 /**
20 * @param ABJ_404_Solution_Functions $functions
21 */
22 public function __construct(
23 $functions,
24 ?ABJ_404_Solution_RegexSourcePatternValidator $sourceValidator = null
25 ) {
26 $this->sourceValidator = $sourceValidator !== null
27 ? $sourceValidator
28 : new ABJ_404_Solution_RegexSourcePatternValidator($functions);
29 }
30
31 /**
32 * @return array{valid: bool, message: string, detail: string}
33 */
34 public function validate(string $sourcePattern, string $destination): array {
35 if ($this->containsControlCharacters($destination)) {
36 return $this->invalid(
37 __('Error: Regex destination contains control characters.', '404-solution')
38 );
39 }
40
41 if ($destination === '' || $destination[0] !== '/') {
42 return $this->invalid(
43 __('Error: Regex destination must be an HTTP(S) URL or a site-relative path starting with /.', '404-solution')
44 );
45 }
46
47 if (isset($destination[1]) && $destination[1] === '/') {
48 return $this->invalid(
49 __('Error: A site-relative regex destination must start with a single /. URLs beginning with // are not allowed.', '404-solution')
50 );
51 }
52
53 if (preg_match('/\s/u', $destination) === 1) {
54 return $this->invalid(
55 __('Error: Regex destination must not contain spaces. Use URL encoding such as %20 instead.', '404-solution')
56 );
57 }
58
59 if (preg_match('/%(?![0-9A-Fa-f]{2})/', $destination) === 1) {
60 return $this->invalid(
61 __('Error: Regex destination contains invalid percent encoding.', '404-solution')
62 );
63 }
64
65 if (preg_match('/[<>"\'`]/u', $destination) === 1) {
66 return $this->invalid(
67 __('Error: Regex destination contains characters that are not safe in a redirect URL.', '404-solution')
68 );
69 }
70
71 if (preg_match('#(?:^|/)\.{1,2}(?:/|$|\?|\\#)#', $destination) === 1) {
72 return $this->invalid(
73 __('Error: Regex destination must not contain . or .. path segments.', '404-solution')
74 );
75 }
76
77 return $this->validateReplacement($sourcePattern, $destination);
78 }
79
80 /**
81 * Validate the regex-specific portion of either an absolute or relative
82 * destination after its URL shape has been validated by the caller.
83 *
84 * @return array{valid: bool, message: string, detail: string}
85 */
86 public function validateReplacement(string $sourcePattern, string $destination): array {
87 if ($this->containsControlCharacters($destination)) {
88 return $this->invalid(
89 __('Error: Regex destination contains control characters.', '404-solution')
90 );
91 }
92
93 $sourceValidation = $this->validateSourcePattern($sourcePattern);
94 if (!$sourceValidation['valid']) {
95 return $sourceValidation;
96 }
97
98 $tokenResult = $this->replacementTokens($destination);
99 if (!$tokenResult['valid']) {
100 return $this->invalid(
101 __('Error: Regex destination contains unsupported replacement syntax. Use $1, $2, and so on.', '404-solution')
102 );
103 }
104
105 if (!empty($tokenResult['tokens']) && $this->usesUnsupportedCapturingSyntax($sourcePattern)) {
106 return $this->invalid(
107 __('Error: Relative regex destinations support numbered capture groups written as (...). Named and branch-reset groups are not supported.', '404-solution')
108 );
109 }
110
111 $captureCount = $this->countNumberedCaptureGroups($sourcePattern);
112 foreach ($tokenResult['tokens'] as $token) {
113 if ($token > $captureCount) {
114 return $this->invalid(sprintf(
115 __('Error: Regex destination references $%1$d, but the source pattern defines %2$d capture group(s).', '404-solution'),
116 $token,
117 $captureCount
118 ));
119 }
120 }
121
122 return array('valid' => true, 'message' => '', 'detail' => '');
123 }
124
125 /**
126 * Validate the source independently of destination type so selecting an
127 * internal page cannot bypass regex compilation checks.
128 *
129 * @return array{valid: bool, message: string, detail: string}
130 */
131 public function validateSourcePattern(string $sourcePattern): array {
132 $validation = $this->sourceValidator->validate($sourcePattern);
133 if (!$validation['valid']) {
134 return $this->invalid(
135 __('Error: Source regular expression is invalid.', '404-solution'),
136 $validation['detail']
137 );
138 }
139 return array('valid' => true, 'message' => '', 'detail' => '');
140 }
141
142 private function containsControlCharacters(string $value): bool {
143 return preg_match('/[\x00-\x1F\x7F]/', $value) === 1;
144 }
145
146 /**
147 * @return array{valid: bool, tokens: array<int, int>}
148 */
149 private function replacementTokens(string $destination): array {
150 $tokens = array();
151 $length = strlen($destination);
152
153 for ($index = 0; $index < $length; $index++) {
154 $character = $destination[$index];
155 if ($character === '\\' && isset($destination[$index + 1])
156 && (ctype_digit($destination[$index + 1]) || $destination[$index + 1] === '$')) {
157 return array('valid' => false, 'tokens' => array());
158 }
159 if ($character !== '$') {
160 continue;
161 }
162
163 $nextIndex = $index + 1;
164 if ($nextIndex >= $length || $destination[$nextIndex] < '1' || $destination[$nextIndex] > '9') {
165 return array('valid' => false, 'tokens' => array());
166 }
167
168 $digits = '';
169 while ($nextIndex < $length && ctype_digit($destination[$nextIndex])) {
170 $digits .= $destination[$nextIndex];
171 $nextIndex++;
172 }
173 $tokens[] = (int)$digits;
174 $index = $nextIndex - 1;
175 }
176
177 return array('valid' => true, 'tokens' => array_values(array_unique($tokens)));
178 }
179
180 private function usesUnsupportedCapturingSyntax(string $pattern): bool {
181 return preg_match('/\(\?(?:P<|<(?!!|=)|\'|\||\()/', $pattern) === 1;
182 }
183
184 private function countNumberedCaptureGroups(string $pattern): int {
185 $count = 0;
186 $escaped = false;
187 $inCharacterClass = false;
188 $length = strlen($pattern);
189
190 for ($index = 0; $index < $length; $index++) {
191 $character = $pattern[$index];
192 if ($escaped) {
193 $escaped = false;
194 continue;
195 }
196 if ($character === '\\') {
197 $escaped = true;
198 continue;
199 }
200 if ($character === '[' && !$inCharacterClass) {
201 $inCharacterClass = true;
202 continue;
203 }
204 if ($character === ']' && $inCharacterClass) {
205 $inCharacterClass = false;
206 continue;
207 }
208 if ($inCharacterClass || $character !== '(') {
209 continue;
210 }
211
212 if (!isset($pattern[$index + 1]) || $pattern[$index + 1] !== '?') {
213 $count++;
214 }
215 }
216
217 return $count;
218 }
219
220 /**
221 * @return array{valid: bool, message: string, detail: string}
222 */
223 private function invalid(string $message, string $detail = ''): array {
224 return array('valid' => false, 'message' => $message, 'detail' => $detail);
225 }
226 }
227