PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.2.0
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.2.0
4.2.0 4.1.0 4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / form / class-form-file-handler.php
superb-blocks / src / gutenberg / form Last commit date
class-form-access-control.php 3 weeks ago class-form-captcha-handler.php 2 weeks ago class-form-controller.php 3 days ago class-form-email-config-check.php 3 weeks ago class-form-email-handler.php 3 days ago class-form-encryption.php 3 weeks ago class-form-exporter.php 3 days ago class-form-field-validator.php 3 weeks ago class-form-file-handler.php 3 days ago class-form-google-auth.php 3 weeks ago class-form-integration-handler.php 3 weeks ago class-form-math-parser.php 3 weeks ago class-form-permissions.php 3 weeks ago class-form-registry.php 3 weeks ago class-form-settings.php 3 weeks ago class-form-submission-cpt.php 3 weeks ago class-form-submission-handler.php 3 days ago class-form-zip-exporter.php 3 days ago
class-form-file-handler.php
849 lines
1 <?php
2
3 namespace SuperbAddons\Gutenberg\Form;
4
5 defined('ABSPATH') || exit();
6
7 class FormFileHandler
8 {
9 const UPLOAD_SUBDIR = 'superb-addons-forms';
10 const UPLOAD_DIR_TOKEN_OPTION = 'superbaddons_form_upload_dir_token';
11
12 /**
13 * Name of the upload directory for form files: UPLOAD_SUBDIR plus a
14 * random per-site token, e.g. "superb-addons-forms-k2m9q4p1n8x7c3v5".
15 *
16 * @return string
17 */
18 public static function GetUploadSubdir()
19 {
20 $token = get_option(self::UPLOAD_DIR_TOKEN_OPTION);
21 // Regenerate when missing or not a plain token, so a tampered option
22 // value can never become a path segment.
23 if (!is_string($token) || !preg_match('/^[a-z0-9]{8,64}$/', $token)) {
24 $token = self::MintUploadDirToken();
25 self::MigrateLegacyUploadDir(self::UPLOAD_SUBDIR . '-' . $token);
26 }
27 return self::UPLOAD_SUBDIR . '-' . $token;
28 }
29
30 /**
31 * Store a fresh upload directory token, or adopt the one a concurrent
32 * request stored first.
33 *
34 * @return string Valid token, stored except in the pathological case
35 * where an invalid value is being re-written concurrently.
36 */
37 private static function MintUploadDirToken()
38 {
39 $token = strtolower(wp_generate_password(16, false, false));
40 for ($attempt = 0; $attempt < 2; $attempt++) {
41 if (add_option(self::UPLOAD_DIR_TOKEN_OPTION, $token, '', 'yes')) {
42 return $token;
43 }
44 $stored = get_option(self::UPLOAD_DIR_TOKEN_OPTION);
45 if (is_string($stored) && preg_match('/^[a-z0-9]{8,64}$/', $stored)) {
46 return $stored;
47 }
48 delete_option(self::UPLOAD_DIR_TOKEN_OPTION);
49 }
50 return $token;
51 }
52
53 private static function MigrateLegacyUploadDir($target_subdir)
54 {
55 $upload_dir = wp_upload_dir();
56 $legacy_base = $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR;
57 $target_base = $upload_dir['basedir'] . '/' . $target_subdir;
58 // A freshly minted random directory cannot pre-exist; if it somehow
59 // does (concurrent mint already moved the tree), there is nothing to do
60 if (!is_dir($legacy_base) || is_dir($target_base)) {
61 return;
62 }
63
64 global $wp_filesystem;
65 if (empty($wp_filesystem)) {
66 require_once(ABSPATH . 'wp-admin/includes/file.php');
67 WP_Filesystem();
68 }
69 if (!empty($wp_filesystem)) {
70 $wp_filesystem->move($legacy_base, $target_base);
71 }
72 }
73
74 /**
75 * Resolve a stored absolute file path
76 *
77 * @param string $path Stored absolute path
78 * @return string Resolved path, '' when the input is not a usable string
79 */
80 public static function ResolveStoredPath($path)
81 {
82 if (!is_string($path) || $path === '') {
83 return '';
84 }
85 if (file_exists($path)) {
86 return $path;
87 }
88 $upload_dir = wp_upload_dir();
89 $legacy_base = $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR . '/';
90 if (strpos($path, $legacy_base) === 0) {
91 $candidate = $upload_dir['basedir'] . '/' . self::GetUploadSubdir() . '/' . substr($path, strlen($legacy_base));
92 if (file_exists($candidate)) {
93 return $candidate;
94 }
95 }
96 return $path;
97 }
98
99 /**
100 * Master list of file types available to form file upload fields.
101 * Feeds both the editor's Accepted File Types picker and server-side
102 * upload validation, so the two cannot drift apart.
103 *
104 * Extensions on the HasDangerousExtension deny-list are stripped from
105 * the filtered result unconditionally, so the filter cannot be used to
106 * allow executable or server-interpreted uploads.
107 *
108 * @return array Entries of array('ext' => '.mp4', 'label' => 'MP4', 'mime' => 'video/mp4')
109 */
110 public static function GetAllowedFileTypes()
111 {
112 $types = array(
113 // Images. SVG is excluded because it is XML and can host inline scripts.
114 array('ext' => '.jpg', 'label' => 'JPG', 'mime' => 'image/jpeg'),
115 array('ext' => '.jpeg', 'label' => 'JPEG', 'mime' => 'image/jpeg'),
116 array('ext' => '.png', 'label' => 'PNG', 'mime' => 'image/png'),
117 array('ext' => '.gif', 'label' => 'GIF', 'mime' => 'image/gif'),
118 array('ext' => '.webp', 'label' => 'WebP', 'mime' => 'image/webp'),
119 // Documents
120 array('ext' => '.pdf', 'label' => 'PDF', 'mime' => 'application/pdf'),
121 array('ext' => '.doc', 'label' => 'DOC', 'mime' => 'application/msword'),
122 array('ext' => '.docx', 'label' => 'DOCX', 'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
123 array('ext' => '.txt', 'label' => 'TXT', 'mime' => 'text/plain'),
124 // Spreadsheets
125 array('ext' => '.xls', 'label' => 'XLS', 'mime' => 'application/vnd.ms-excel'),
126 array('ext' => '.xlsx', 'label' => 'XLSX', 'mime' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
127 array('ext' => '.csv', 'label' => 'CSV', 'mime' => 'text/csv'),
128 // Archives
129 array('ext' => '.zip', 'label' => 'ZIP', 'mime' => 'application/zip'),
130 // Video
131 array('ext' => '.mp4', 'label' => 'MP4', 'mime' => 'video/mp4'),
132 array('ext' => '.m4v', 'label' => 'M4V', 'mime' => 'video/mp4'),
133 array('ext' => '.mov', 'label' => 'MOV', 'mime' => 'video/quicktime'),
134 array('ext' => '.webm', 'label' => 'WebM', 'mime' => 'video/webm'),
135 array('ext' => '.avi', 'label' => 'AVI', 'mime' => 'video/avi'),
136 array('ext' => '.mkv', 'label' => 'MKV', 'mime' => 'video/x-matroska'),
137 array('ext' => '.mpg', 'label' => 'MPG', 'mime' => 'video/mpeg'),
138 array('ext' => '.wmv', 'label' => 'WMV', 'mime' => 'video/x-ms-wmv'),
139 // Audio
140 array('ext' => '.mp3', 'label' => 'MP3', 'mime' => 'audio/mpeg'),
141 array('ext' => '.m4a', 'label' => 'M4A', 'mime' => 'audio/mpeg'),
142 array('ext' => '.wav', 'label' => 'WAV', 'mime' => 'audio/wav'),
143 array('ext' => '.ogg', 'label' => 'OGG', 'mime' => 'audio/ogg'),
144 array('ext' => '.flac', 'label' => 'FLAC', 'mime' => 'audio/flac'),
145 );
146
147 /**
148 * Filters the file types visitors can upload through form file upload fields.
149 *
150 * Each entry needs an 'ext' (extension with leading dot), 'label' (shown in
151 * the editor's Accepted File Types picker), and 'mime' (enforced during
152 * upload validation). Entries whose extension is on the dangerous-extension
153 * deny-list (php, html, svg, exe, ...) are discarded. Types WordPress does
154 * not allow by default do not need a separate upload_mimes filter; this
155 * list is authoritative for form uploads.
156 *
157 * @param array $types Entries of array('ext' => ..., 'label' => ..., 'mime' => ...).
158 */
159 $types = apply_filters('superbaddons_form_allowed_file_types', $types);
160
161 if (!is_array($types)) {
162 return array();
163 }
164
165 $sanitized = array();
166 $seen = array();
167 foreach ($types as $type) {
168 if (!is_array($type) || empty($type['ext']) || !is_string($type['ext']) || empty($type['mime']) || !is_string($type['mime'])) {
169 continue;
170 }
171 $ext = strtolower(trim($type['ext']));
172 if (strpos($ext, '.') !== 0) {
173 $ext = '.' . $ext;
174 }
175 // Single alphanumeric segment only — multi-segment entries like
176 // .tar.gz can never match pathinfo(PATHINFO_EXTENSION) in validation
177 if (!preg_match('/^\.[a-z0-9]+$/', $ext)) {
178 continue;
179 }
180 // Deny-list wins over the filter
181 if (self::HasDangerousExtension('file' . $ext)) {
182 continue;
183 }
184 if (isset($seen[$ext])) {
185 continue;
186 }
187 $seen[$ext] = true;
188 $sanitized[] = array(
189 'ext' => $ext,
190 'label' => isset($type['label']) && is_string($type['label']) && $type['label'] !== '' ? $type['label'] : strtoupper(substr($ext, 1)),
191 'mime' => $type['mime'],
192 );
193 }
194 return $sanitized;
195 }
196
197 /**
198 * Default accepted extensions for file fields whose server-side config
199 * carries no explicit accept list. Gutenberg omits attributes that still
200 * equal their block.json defaults when serializing, so a file field whose
201 * settings were never touched reaches the server without fileSettings at
202 * all; this list makes validation enforce exactly the types the editor UI
203 * displays for that state. Must be kept in sync with the fileSettings.accept
204 * default in /form-field/block.json.
205 *
206 * @return array Extensions with leading dot
207 */
208 public static function GetDefaultAccept()
209 {
210 return array('.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx', '.txt', '.xls', '.xlsx', '.csv');
211 }
212
213 /**
214 * Extension => MIME map derived from GetAllowedFileTypes, in the format
215 * wp_check_filetype() and wp_handle_upload() expect.
216 *
217 * @return array e.g. array('mp4' => 'video/mp4', ...)
218 */
219 public static function GetAllowedMimes()
220 {
221 $mimes = array();
222 foreach (self::GetAllowedFileTypes() as $type) {
223 $mimes[substr($type['ext'], 1)] = $type['mime'];
224 }
225 return $mimes;
226 }
227
228 /**
229 * Validate uploaded files for a field against its config.
230 * Called by FormFieldValidator before files are processed.
231 *
232 * @param array $field_config Field configuration from server-side config
233 * @param string $default_required_message Form-wide message for empty required fields, '' for the localized default
234 * @return string Error message, empty if valid
235 */
236 public static function ValidateFiles($field_config, $default_required_message = '')
237 {
238 $field_id = isset($field_config['fieldId']) ? $field_config['fieldId'] : '';
239 $required = !empty($field_config['required']);
240 $fs = isset($field_config['fileSettings']) && is_array($field_config['fileSettings']) ? $field_config['fileSettings'] : array();
241 $max_file_size = isset($fs['maxFileSize']) ? floatval($fs['maxFileSize']) : 5;
242 $multiple = !empty($fs['multiple']);
243 $max_files = isset($fs['maxFiles']) ? intval($fs['maxFiles']) : 5;
244 // Like the other fileSettings keys above, a missing accept list means
245 // the field was left at its block.json defaults (default-valued
246 // attributes are omitted from serialized markup), so enforce the
247 // default list the editor UI shows rather than the wider master list.
248 // A present-but-empty list (legacy content saved before the editor
249 // refused to empty the picker) gets the same defaults.
250 $accept = isset($fs['accept']) && is_array($fs['accept']) && !empty($fs['accept']) ? $fs['accept'] : self::GetDefaultAccept();
251
252 // Check if files were submitted for this field
253 $files = self::GetUploadedFiles($field_id);
254
255 if (empty($files)) {
256 // Conditional logic: if field has active rules, skip required check
257 // (handled by FormFieldValidator before calling us, but guard here too)
258 if ($required) {
259 $logic = isset($field_config['conditionalLogic']) ? $field_config['conditionalLogic'] : null;
260 if ($logic && isset($logic['ruleGroups']) && is_array($logic['ruleGroups'])) {
261 foreach ($logic['ruleGroups'] as $group) {
262 if (isset($group['conditions']) && is_array($group['conditions'])) {
263 foreach ($group['conditions'] as $cond) {
264 if (!empty($cond['field'])) {
265 return '';
266 }
267 }
268 }
269 }
270 }
271 return FormFieldValidator::GetRequiredMessage($field_config, $default_required_message);
272 }
273 return '';
274 }
275
276 // Validate file count
277 if (!$multiple && count($files) > 1) {
278 return __('Only one file is allowed.', 'superb-blocks');
279 }
280 if ($multiple && count($files) > $max_files) {
281 /* translators: %d: maximum number of files allowed for this field */
282 return sprintf(__('Maximum %d files allowed.', 'superb-blocks'), $max_files);
283 }
284
285 // Validate each file
286 $max_bytes = $max_file_size * 1024 * 1024;
287 $allowed_mimes = self::GetAllowedMimes();
288 foreach ($files as $file) {
289 // Check for upload errors
290 if (!empty($file['error']) && intval($file['error']) !== UPLOAD_ERR_OK) {
291 $upload_error = intval($file['error']);
292 if ($upload_error === UPLOAD_ERR_INI_SIZE || $upload_error === UPLOAD_ERR_FORM_SIZE) {
293 return __('File exceeds the maximum upload size for this site.', 'superb-blocks');
294 }
295 return __('File upload failed.', 'superb-blocks');
296 }
297
298 // Unconditional deny-list: reject dangerous extensions regardless of the
299 // per-field accept whitelist. Catches misconfigured accept[] entries and
300 // double-extension filenames (e.g. shell.php.jpg) before any further check.
301 if (!empty($file['name']) && self::HasDangerousExtension($file['name'])) {
302 return __('File type is not allowed.', 'superb-blocks');
303 }
304
305 // Validate size
306 if (isset($file['size']) && $file['size'] > $max_bytes) {
307 /* translators: %s: maximum file size in megabytes */
308 return sprintf(__('File size exceeds %sMB.', 'superb-blocks'), $max_file_size);
309 }
310
311 // Validate extension against the master list and the field whitelist
312 if (!empty($file['name'])) {
313 $ext = '.' . strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
314
315 // Master list: extension must be a known allowed type
316 // (filterable via superbaddons_form_allowed_file_types)
317 if (!isset($allowed_mimes[substr($ext, 1)])) {
318 return __('File type is not allowed.', 'superb-blocks');
319 }
320
321 if (!empty($accept)) {
322 $allowed = false;
323 foreach ($accept as $accepted) {
324 // Accept list entries are like ".jpg", ".pdf", etc.
325 if (strtolower(trim($accepted)) === $ext) {
326 $allowed = true;
327 break;
328 }
329 }
330 if (!$allowed) {
331 return __('File type is not allowed.', 'superb-blocks');
332 }
333 }
334 }
335
336 // Additional MIME validation using WordPress, restricted to the master list
337 if (!empty($file['tmp_name']) && !empty($file['name'])) {
338 $wp_filetype = wp_check_filetype($file['name'], $allowed_mimes);
339 if (empty($wp_filetype['ext']) || empty($wp_filetype['type'])) {
340 return __('File type is not allowed.', 'superb-blocks');
341 }
342 }
343 }
344
345 return '';
346 }
347
348 /**
349 * Process and store uploaded files for a submission.
350 *
351 * @param array $form_fields_config Array of field config arrays
352 * @return array fieldId => array of file metadata arrays
353 */
354 public static function ProcessUploads($form_fields_config)
355 {
356 // Nonce verified upstream by FormController::SubmitCallback before this method runs; presence check only, no value processed here.
357 // phpcs:ignore WordPress.Security.NonceVerification.Missing
358 if (empty($_FILES['files'])) {
359 return array();
360 }
361
362 require_once(ABSPATH . 'wp-admin/includes/file.php');
363
364 $result = array();
365
366 // Build lookup for file field configs
367 $file_field_configs = array();
368 foreach ($form_fields_config as $fc) {
369 $ftype = isset($fc['fieldType']) ? $fc['fieldType'] : '';
370 $fid = isset($fc['fieldId']) ? $fc['fieldId'] : '';
371 if ($ftype === 'file' && $fid !== '') {
372 $file_field_configs[$fid] = $fc;
373 }
374 }
375
376 $allowed_mimes = self::GetAllowedMimes();
377 $upload_subdir = self::GetUploadSubdir();
378
379 foreach ($file_field_configs as $field_id => $config) {
380 $files = self::GetUploadedFiles($field_id);
381 if (empty($files)) {
382 continue;
383 }
384
385 $field_files = array();
386 foreach ($files as $file) {
387 if (empty($file['tmp_name']) || intval($file['error']) !== UPLOAD_ERR_OK) {
388 continue;
389 }
390
391 // Hook into upload_dir to redirect to our protected directory
392 $dir_filter = function ($dirs) use ($upload_subdir) {
393 $subdir = '/' . $upload_subdir . $dirs['subdir'];
394 $dirs['subdir'] = $subdir;
395 $dirs['path'] = $dirs['basedir'] . $subdir;
396 $dirs['url'] = $dirs['baseurl'] . $subdir;
397 return $dirs;
398 };
399 add_filter('upload_dir', $dir_filter);
400
401 // Ensure protected directory exists
402 self::EnsureUploadDir();
403
404 $uploaded = wp_handle_upload($file, array(
405 'test_form' => false,
406 'action' => 'superb_form_upload',
407 'mimes' => $allowed_mimes,
408 // Random per-file suffix: makes stored URLs unguessable on
409 // servers where the directory deny rules do not apply
410 // (Nginx), and removes wp_unique_filename()'s
411 // check-then-move race where two concurrent submissions
412 // uploading the same filename could silently overwrite
413 // each other. The original name is kept in the submission
414 // metadata and restored on download via Content-Disposition.
415 'unique_filename_callback' => array(__CLASS__, 'GenerateStoredFilename'),
416 ));
417
418 remove_filter('upload_dir', $dir_filter);
419
420 if (!empty($uploaded['file'])) {
421 $field_files[] = array(
422 'name' => sanitize_file_name($file['name']),
423 'path' => $uploaded['file'],
424 'url' => isset($uploaded['url']) ? $uploaded['url'] : '',
425 'type' => isset($uploaded['type']) ? $uploaded['type'] : '',
426 'size' => $file['size'],
427 );
428 }
429 }
430
431 if (!empty($field_files)) {
432 $result[$field_id] = $field_files;
433
434 /**
435 * Fires after files are uploaded for a form field.
436 *
437 * @param string $field_id The field ID.
438 * @param array $field_files Array of file metadata.
439 * @param array $config Field configuration.
440 */
441 do_action('superbaddons_form_after_upload', $field_id, $field_files, $config);
442 }
443 }
444
445 return $result;
446 }
447
448 /**
449 * unique_filename_callback for wp_handle_upload: append a random suffix
450 * to the stored filename. wp_unique_filename passes $name as the full
451 * sanitized basename including the extension, and $ext as '.pdf' style.
452 *
453 * @param string $dir Target directory
454 * @param string $name Sanitized basename including extension
455 * @param string $ext Extension with leading dot, '' when none
456 * @return string
457 */
458 public static function GenerateStoredFilename($dir, $name, $ext)
459 {
460 $base = $name;
461 if ($ext !== '' && substr($name, strlen($name) - strlen($ext)) === $ext) {
462 $base = substr($name, 0, strlen($name) - strlen($ext));
463 }
464 if ($base === '') {
465 $base = 'file';
466 }
467
468 // Keep the stored name inside every real-world limit: 255 bytes per
469 // path component on ext4/XFS/NTFS, ~143 bytes on eCryptfs, and
470 // Windows' 260-character full-path cap. Neither sanitize_file_name()
471 // nor wp_unique_filename() truncates, and an overlong name would make
472 // the move inside wp_handle_upload fail after validation has already
473 // passed. 48 chars is at most 192 bytes of UTF-8;
474 $base = mb_substr($base, 0, 48);
475
476 $attempts = 0;
477 do {
478 $suffix = strtolower(wp_generate_password(12, false, false));
479 $filename = $base . '-' . $suffix . $ext;
480 $attempts++;
481 } while ($attempts < 3 && file_exists(trailingslashit($dir) . $filename));
482
483 return $filename;
484 }
485
486 /**
487 * Resolve a stored upload path and confirm it lives inside the plugin's
488 * form upload directory (legacy or tokenized): the same confinement
489 * ServeFile() applies before streaming a single file, for callers that
490 * read many files in one request (ZIP downloads).
491 *
492 * @param string $path Absolute path from submission file metadata.
493 * @return string Real path when the file exists, is a readable regular
494 * file and is confined; '' otherwise.
495 */
496 public static function ConfineUploadPath($path)
497 {
498 // Resolve the current directory first: on the very first touch this
499 // mints the token and migrates the legacy directory, which would
500 // otherwise move the file out from under a path resolved earlier.
501 self::GetUploadSubdir();
502 $upload_dir = wp_upload_dir();
503
504 $path = self::ResolveStoredPath($path);
505 if ($path === '' || !is_file($path) || !is_readable($path)) {
506 return '';
507 }
508 $real_path = realpath($path);
509 $real_uploads = realpath($upload_dir['basedir']);
510 if ($real_path === false || $real_uploads === false || strpos($real_path, $real_uploads . DIRECTORY_SEPARATOR) !== 0) {
511 return '';
512 }
513 $segments = explode(DIRECTORY_SEPARATOR, substr($real_path, strlen($real_uploads) + 1));
514 if (count($segments) < 2 || !preg_match('/^' . preg_quote(self::UPLOAD_SUBDIR, '/') . '(-[a-z0-9]{8,64})?$/', $segments[0])) {
515 return '';
516 }
517 return $real_path;
518 }
519
520 /**
521 * Delete all files associated with a submission's field data.
522 *
523 * @param array $fields Submission fields (field_id => value)
524 */
525 public static function DeleteSubmissionFiles($fields)
526 {
527 if (!is_array($fields)) {
528 return;
529 }
530
531 foreach ($fields as $value) {
532 // File fields store an array of file metadata
533 if (!is_array($value)) {
534 continue;
535 }
536
537 foreach ($value as $file) {
538 if (!is_array($file) || !isset($file['path'])) {
539 continue;
540 }
541 $path = self::ResolveStoredPath($file['path']);
542 if ($path !== '' && file_exists($path)) {
543 wp_delete_file($path);
544 }
545 }
546 }
547 }
548
549 /**
550 * Delete the plugin's form upload directories entirely: all remaining
551 * files (including any orphaned by crashed requests), the
552 * .htaccess/web.config/index.php scaffolding, and the empty year/month
553 * subdirectories. Called from plugin reset after every stored submission
554 * has been deleted; both the current tokenized directory and the legacy
555 * unhashed directory are removed. Reads the token option directly so no
556 * new token is minted when none exists.
557 */
558 public static function DeleteUploadDirectories()
559 {
560 global $wp_filesystem;
561 if (empty($wp_filesystem)) {
562 require_once(ABSPATH . 'wp-admin/includes/file.php');
563 WP_Filesystem();
564 }
565 if (empty($wp_filesystem)) {
566 return;
567 }
568
569 $upload_dir = wp_upload_dir();
570 $bases = array($upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR);
571 $token = get_option(self::UPLOAD_DIR_TOKEN_OPTION);
572 if (is_string($token) && preg_match('/^[a-z0-9]{8,64}$/', $token)) {
573 $bases[] = $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR . '-' . $token;
574 }
575
576 foreach ($bases as $base) {
577 if (is_dir($base)) {
578 $wp_filesystem->delete($base, true);
579 }
580 }
581 }
582
583 /**
584 * Serve a file from the protected upload directory.
585 * Streams the file with appropriate headers and exits.
586 *
587 * @param string $file_path Absolute path to the file
588 * @param string $original_name Original file name for download
589 * @param string $mime_type MIME type
590 */
591 public static function ServeFile($file_path, $original_name, $mime_type)
592 {
593 // Resolve the current directory before touching the stored path: on
594 // the very first touch this mints the token and migrates the legacy
595 // directory, which would otherwise move the file out from under a
596 // path resolved earlier in this request.
597 $upload_dir = wp_upload_dir();
598 $current_base = $upload_dir['basedir'] . '/' . self::GetUploadSubdir();
599
600 // Stored paths may predate the randomized upload directory
601 $file_path = self::ResolveStoredPath($file_path);
602 if ($file_path === '' || !file_exists($file_path) || !is_readable($file_path)) {
603 return new \WP_REST_Response(array(
604 'success' => false,
605 'message' => __('File not found.', 'superb-blocks'),
606 ), 404);
607 }
608
609 // Ensure file is within one of our upload directories directly under
610 // the uploads basedir: the unhashed directory for files stored before
611 // the randomized directory token was introduced, or any tokenized
612 // variant. Accepting the token pattern instead of only the current
613 // token keeps files servable even if the token option is ever lost
614 // and re-minted, which would otherwise strand every earlier upload
615 // under the retired directory. Paths come exclusively from submission
616 // meta, so this does not widen what a request can reach. Segment-wise
617 // comparison keeps the check exact, so a sibling directory that
618 // merely starts with the base name can never pass.
619 $allowed = false;
620 $real_path = realpath($file_path);
621 $real_uploads = realpath($upload_dir['basedir']);
622 if ($real_path !== false && $real_uploads !== false && strpos($real_path, $real_uploads . DIRECTORY_SEPARATOR) === 0) {
623 $segments = explode(DIRECTORY_SEPARATOR, substr($real_path, strlen($real_uploads) + 1));
624 if (count($segments) > 1 && preg_match('/^' . preg_quote(self::UPLOAD_SUBDIR, '/') . '(-[a-z0-9]{8,64})?$/', $segments[0])) {
625 $allowed = true;
626 }
627 }
628
629 if (!$allowed) {
630 return new \WP_REST_Response(array(
631 'success' => false,
632 'message' => __('Access denied.', 'superb-blocks'),
633 ), 403);
634 }
635
636 // A concurrent request performing the one-time legacy-directory
637 // migration can move the file between the checks above and the
638 // stream below. Re-resolve and re-confine (the migrated location can
639 // only be the current randomized directory) instead of streaming a
640 // dead path.
641 if (!file_exists($file_path)) {
642 $file_path = self::ResolveStoredPath($file_path);
643 $real_path = $file_path !== '' ? realpath($file_path) : false;
644 $real_base = realpath($current_base);
645 if ($real_path === false || $real_base === false || strpos($real_path, $real_base . DIRECTORY_SEPARATOR) !== 0) {
646 return new \WP_REST_Response(array(
647 'success' => false,
648 'message' => __('File not found.', 'superb-blocks'),
649 ), 404);
650 }
651 }
652
653 $safe_name = str_replace(array('"', "\r", "\n"), '', sanitize_file_name($original_name));
654 header('Content-Type: ' . $mime_type);
655 header('X-Content-Type-Options: nosniff');
656 header('Content-Disposition: attachment; filename="' . $safe_name . '"');
657 header('Content-Length: ' . filesize($file_path));
658 header('Cache-Control: no-store, no-cache, must-revalidate');
659
660 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
661 readfile($file_path);
662 // Plain exit, not wp_die(): the die handler appends an HTML error
663 // page skeleton after the streamed bytes.
664 exit;
665 }
666
667 /**
668 * Check if a filename has a dangerous extension in any dot-separated segment.
669 * Catches single (shell.php), double-extension (shell.php.jpg), and dotfile
670 * (.htaccess) cases. Server-config and active-content extensions are denied
671 * because the upload subdir is web-reachable on IIS/Nginx where .htaccess is
672 * ignored, so a stored .html or .svg could host an XSS payload even though
673 * wp_handle_upload would not save a .php file under an unauthenticated request.
674 *
675 * @param string $filename
676 * @return bool
677 */
678 private static function HasDangerousExtension($filename)
679 {
680 static $deny = array(
681 // PHP and PHP-handler variants
682 'php',
683 'php3',
684 'php4',
685 'php5',
686 'php7',
687 'php8',
688 'phtml',
689 'pht',
690 'phar',
691 'phps',
692 // Other server-side scripting
693 'cgi',
694 'pl',
695 'py',
696 'rb',
697 'jsp',
698 'jspx',
699 'asp',
700 'aspx',
701 'cer',
702 'cfm',
703 'shtml',
704 // Executables and shells
705 'exe',
706 'msi',
707 'sh',
708 'bat',
709 'cmd',
710 'com',
711 'vb',
712 'vbs',
713 'wsh',
714 // Server config
715 'htaccess',
716 'htpasswd',
717 'ini',
718 'env',
719 // Active web content (inline XSS if directly accessible)
720 'html',
721 'htm',
722 'xhtml',
723 'svg',
724 'svgz',
725 'js',
726 'mjs',
727 'xml',
728 );
729
730 $parts = explode('.', strtolower($filename));
731 array_shift($parts); // skip basename, only inspect dot-segments
732 foreach ($parts as $part) {
733 if ($part !== '' && in_array($part, $deny, true)) {
734 return true;
735 }
736 }
737 return false;
738 }
739
740 /**
741 * Get uploaded files for a specific field ID from $_FILES.
742 * Normalizes the PHP $_FILES array for multiple files.
743 *
744 * @param string $field_id
745 * @return array Array of file arrays (name, type, tmp_name, error, size)
746 */
747 private static function GetUploadedFiles($field_id)
748 {
749 // Nonce verified upstream by FormController::SubmitCallback before any caller of this method runs.
750 // PHP guarantees the parallel name/type/tmp_name/error/size keys exist together when $_FILES['files']['name'][$field_id] is set, so checking the others would be redundant.
751 // Raw $_FILES values are consumed downstream by wp_handle_upload() (which applies WP's standard upload sanitization) and sanitize_file_name() at storage time; running sanitize_text_field on tmp_name/size/error here would corrupt the values wp_handle_upload expects.
752 // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
753 if (empty($_FILES['files']) || !isset($_FILES['files']['name'][$field_id])) {
754 return array();
755 }
756
757 $files = array();
758 $names = $_FILES['files']['name'][$field_id];
759 $types = $_FILES['files']['type'][$field_id];
760 $tmp_names = $_FILES['files']['tmp_name'][$field_id];
761 $errors = $_FILES['files']['error'][$field_id];
762 $sizes = $_FILES['files']['size'][$field_id];
763 // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
764
765 // Normalize: could be a single file or array of files
766 if (is_array($names)) {
767 for ($i = 0; $i < count($names); $i++) {
768 if (empty($names[$i])) {
769 continue;
770 }
771 $files[] = array(
772 'name' => $names[$i],
773 'type' => $types[$i],
774 'tmp_name' => $tmp_names[$i],
775 'error' => $errors[$i],
776 'size' => $sizes[$i],
777 );
778 }
779 } else {
780 if (!empty($names)) {
781 $files[] = array(
782 'name' => $names,
783 'type' => $types,
784 'tmp_name' => $tmp_names,
785 'error' => $errors,
786 'size' => $sizes,
787 );
788 }
789 }
790
791 return $files;
792 }
793
794 /**
795 * Ensure the protected upload directory exists with .htaccess and index.php.
796 */
797 private static function EnsureUploadDir()
798 {
799 $upload_dir = wp_upload_dir();
800 $base_path = $upload_dir['basedir'] . '/' . self::GetUploadSubdir();
801
802 // Create base directory if needed
803 if (!is_dir($base_path)) {
804 wp_mkdir_p($base_path);
805 }
806
807 // Use WP_Filesystem for file writes (required by plugin review guidelines)
808 global $wp_filesystem;
809 if (empty($wp_filesystem)) {
810 require_once(ABSPATH . 'wp-admin/includes/file.php');
811 WP_Filesystem();
812 }
813
814 // WP_Filesystem() can fail to initialize (e.g. a non-direct method
815 // needing credentials that are not stored); this runs during visitor
816 // form submissions, so skip the scaffolding instead of fataling on a
817 // null object. FS_CHMOD_FILE is also only defined after successful
818 // initialization. The deny rules are defense-in-depth on top of the
819 // randomized directory name and filenames.
820 if (!empty($wp_filesystem)) {
821 // Create .htaccess to deny direct access (Apache)
822 $htaccess = $base_path . '/.htaccess';
823 if (!file_exists($htaccess)) {
824 $wp_filesystem->put_contents($htaccess, "# Deny direct access to uploaded form files\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n", FS_CHMOD_FILE);
825 }
826
827 // Create web.config to deny direct access (IIS). accessPolicy="None"
828 // strips Read/Script/Execute so any request to this dir returns 403,
829 // mirroring the Apache "Require all denied" posture above.
830 $webconfig = $base_path . '/web.config';
831 if (!file_exists($webconfig)) {
832 $wp_filesystem->put_contents($webconfig, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n <system.webServer>\n <handlers accessPolicy=\"None\" />\n </system.webServer>\n</configuration>\n", FS_CHMOD_FILE);
833 }
834
835 // Create index.php to prevent directory listing
836 $index = $base_path . '/index.php';
837 if (!file_exists($index)) {
838 $wp_filesystem->put_contents($index, "<?php\n// Silence is golden.\n", FS_CHMOD_FILE);
839 }
840 }
841
842 // Also protect the current year/month subdirectory
843 $current_path = $upload_dir['path'];
844 if (strpos($current_path, $base_path) === 0 && !is_dir($current_path)) {
845 wp_mkdir_p($current_path);
846 }
847 }
848 }
849