PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.0
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.0
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 2 months ago class-form-captcha-handler.php 2 months ago class-form-controller.php 2 months ago class-form-email-config-check.php 2 months ago class-form-email-handler.php 2 months ago class-form-encryption.php 2 months ago class-form-exporter.php 2 months ago class-form-field-validator.php 2 months ago class-form-file-handler.php 2 months ago class-form-google-auth.php 2 months ago class-form-integration-handler.php 2 months ago class-form-math-parser.php 2 months ago class-form-permissions.php 2 months ago class-form-registry.php 2 months ago class-form-settings.php 2 months ago class-form-submission-cpt.php 2 months ago class-form-submission-handler.php 2 months ago
class-form-file-handler.php
401 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
11 /**
12 * Validate uploaded files for a field against its config.
13 * Called by FormFieldValidator before files are processed.
14 *
15 * @param array $field_config Field configuration from server-side config
16 * @return string Error message, empty if valid
17 */
18 public static function ValidateFiles($field_config)
19 {
20 $field_id = isset($field_config['fieldId']) ? $field_config['fieldId'] : '';
21 $required = !empty($field_config['required']);
22 $fs = isset($field_config['fileSettings']) && is_array($field_config['fileSettings']) ? $field_config['fileSettings'] : array();
23 $max_file_size = isset($fs['maxFileSize']) ? floatval($fs['maxFileSize']) : 5;
24 $multiple = !empty($fs['multiple']);
25 $max_files = isset($fs['maxFiles']) ? intval($fs['maxFiles']) : 5;
26 $accept = isset($fs['accept']) && is_array($fs['accept']) ? $fs['accept'] : array();
27
28 // Check if files were submitted for this field
29 $files = self::GetUploadedFiles($field_id);
30
31 if (empty($files)) {
32 // Conditional logic: if field has active rules, skip required check
33 // (handled by FormFieldValidator before calling us, but guard here too)
34 if ($required) {
35 $logic = isset($field_config['conditionalLogic']) ? $field_config['conditionalLogic'] : null;
36 if ($logic && isset($logic['ruleGroups']) && is_array($logic['ruleGroups'])) {
37 foreach ($logic['ruleGroups'] as $group) {
38 if (isset($group['conditions']) && is_array($group['conditions'])) {
39 foreach ($group['conditions'] as $cond) {
40 if (!empty($cond['field'])) {
41 return '';
42 }
43 }
44 }
45 }
46 }
47 return __('This field is required.', 'superb-blocks');
48 }
49 return '';
50 }
51
52 // Validate file count
53 if (!$multiple && count($files) > 1) {
54 return __('Only one file is allowed.', 'superb-blocks');
55 }
56 if ($multiple && count($files) > $max_files) {
57 /* translators: %d: maximum number of files allowed for this field */
58 return sprintf(__('Maximum %d files allowed.', 'superb-blocks'), $max_files);
59 }
60
61 // Validate each file
62 $max_bytes = $max_file_size * 1024 * 1024;
63 foreach ($files as $file) {
64 // Check for upload errors
65 if (!empty($file['error']) && intval($file['error']) !== UPLOAD_ERR_OK) {
66 return __('File upload failed.', 'superb-blocks');
67 }
68
69 // Unconditional deny-list: reject dangerous extensions regardless of the
70 // per-field accept whitelist. Catches misconfigured accept[] entries and
71 // double-extension filenames (e.g. shell.php.jpg) before any further check.
72 if (!empty($file['name']) && self::HasDangerousExtension($file['name'])) {
73 return __('File type is not allowed.', 'superb-blocks');
74 }
75
76 // Validate size
77 if (isset($file['size']) && $file['size'] > $max_bytes) {
78 /* translators: %s: maximum file size in megabytes */
79 return sprintf(__('File size exceeds %sMB.', 'superb-blocks'), $max_file_size);
80 }
81
82 // Validate MIME type / extension
83 if (!empty($accept) && !empty($file['name'])) {
84 $ext = '.' . strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
85 $allowed = false;
86 foreach ($accept as $accepted) {
87 // Accept list entries are like ".jpg", ".pdf", etc.
88 if (strtolower(trim($accepted)) === $ext) {
89 $allowed = true;
90 break;
91 }
92 }
93 if (!$allowed) {
94 return __('File type is not allowed.', 'superb-blocks');
95 }
96 }
97
98 // Additional MIME validation using WordPress
99 if (!empty($file['tmp_name']) && !empty($file['name'])) {
100 $wp_filetype = wp_check_filetype($file['name']);
101 if (empty($wp_filetype['ext']) || empty($wp_filetype['type'])) {
102 return __('File type is not allowed.', 'superb-blocks');
103 }
104 }
105 }
106
107 return '';
108 }
109
110 /**
111 * Process and store uploaded files for a submission.
112 *
113 * @param array $form_fields_config Array of field config arrays
114 * @return array fieldId => array of file metadata arrays
115 */
116 public static function ProcessUploads($form_fields_config)
117 {
118 // Nonce verified upstream by FormController::SubmitCallback before this method runs; presence check only, no value processed here.
119 // phpcs:ignore WordPress.Security.NonceVerification.Missing
120 if (empty($_FILES['files'])) {
121 return array();
122 }
123
124 require_once(ABSPATH . 'wp-admin/includes/file.php');
125
126 $result = array();
127
128 // Build lookup for file field configs
129 $file_field_configs = array();
130 foreach ($form_fields_config as $fc) {
131 $ftype = isset($fc['fieldType']) ? $fc['fieldType'] : '';
132 $fid = isset($fc['fieldId']) ? $fc['fieldId'] : '';
133 if ($ftype === 'file' && $fid !== '') {
134 $file_field_configs[$fid] = $fc;
135 }
136 }
137
138 foreach ($file_field_configs as $field_id => $config) {
139 $files = self::GetUploadedFiles($field_id);
140 if (empty($files)) {
141 continue;
142 }
143
144 $field_files = array();
145 foreach ($files as $file) {
146 if (empty($file['tmp_name']) || intval($file['error']) !== UPLOAD_ERR_OK) {
147 continue;
148 }
149
150 // Hook into upload_dir to redirect to our protected directory
151 $dir_filter = function ($dirs) {
152 $subdir = '/' . self::UPLOAD_SUBDIR . $dirs['subdir'];
153 $dirs['subdir'] = $subdir;
154 $dirs['path'] = $dirs['basedir'] . $subdir;
155 $dirs['url'] = $dirs['baseurl'] . $subdir;
156 return $dirs;
157 };
158 add_filter('upload_dir', $dir_filter);
159
160 // Ensure protected directory exists
161 self::EnsureUploadDir();
162
163 $uploaded = wp_handle_upload($file, array(
164 'test_form' => false,
165 'action' => 'superb_form_upload',
166 ));
167
168 remove_filter('upload_dir', $dir_filter);
169
170 if (!empty($uploaded['file'])) {
171 $field_files[] = array(
172 'name' => sanitize_file_name($file['name']),
173 'path' => $uploaded['file'],
174 'url' => isset($uploaded['url']) ? $uploaded['url'] : '',
175 'type' => isset($uploaded['type']) ? $uploaded['type'] : '',
176 'size' => $file['size'],
177 );
178 }
179 }
180
181 if (!empty($field_files)) {
182 $result[$field_id] = $field_files;
183
184 /**
185 * Fires after files are uploaded for a form field.
186 *
187 * @param string $field_id The field ID.
188 * @param array $field_files Array of file metadata.
189 * @param array $config Field configuration.
190 */
191 do_action('superbaddons_form_after_upload', $field_id, $field_files, $config);
192 }
193 }
194
195 return $result;
196 }
197
198 /**
199 * Delete all files associated with a submission's field data.
200 *
201 * @param array $fields Submission fields (field_id => value)
202 */
203 public static function DeleteSubmissionFiles($fields)
204 {
205 if (!is_array($fields)) {
206 return;
207 }
208
209 foreach ($fields as $value) {
210 // File fields store an array of file metadata
211 if (!is_array($value)) {
212 continue;
213 }
214
215 foreach ($value as $file) {
216 if (is_array($file) && isset($file['path']) && file_exists($file['path'])) {
217 wp_delete_file($file['path']);
218 }
219 }
220 }
221 }
222
223 /**
224 * Serve a file from the protected upload directory.
225 * Streams the file with appropriate headers and exits.
226 *
227 * @param string $file_path Absolute path to the file
228 * @param string $original_name Original file name for download
229 * @param string $mime_type MIME type
230 */
231 public static function ServeFile($file_path, $original_name, $mime_type)
232 {
233 if (!file_exists($file_path) || !is_readable($file_path)) {
234 return new \WP_REST_Response(array(
235 'success' => false,
236 'message' => __('File not found.', 'superb-blocks'),
237 ), 404);
238 }
239
240 // Ensure file is within our upload directory
241 $upload_dir = wp_upload_dir();
242 $allowed_base = $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR;
243 $real_path = realpath($file_path);
244 $real_base = realpath($allowed_base);
245
246 if ($real_path === false || $real_base === false || strpos($real_path, $real_base) !== 0) {
247 return new \WP_REST_Response(array(
248 'success' => false,
249 'message' => __('Access denied.', 'superb-blocks'),
250 ), 403);
251 }
252
253 $safe_name = str_replace(array('"', "\r", "\n"), '', sanitize_file_name($original_name));
254 header('Content-Type: ' . $mime_type);
255 header('Content-Disposition: attachment; filename="' . $safe_name . '"');
256 header('Content-Length: ' . filesize($file_path));
257 header('Cache-Control: no-store, no-cache, must-revalidate');
258
259 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
260 readfile($file_path);
261 wp_die();
262 }
263
264 /**
265 * Check if a filename has a dangerous extension in any dot-separated segment.
266 * Catches single (shell.php), double-extension (shell.php.jpg), and dotfile
267 * (.htaccess) cases. Server-config and active-content extensions are denied
268 * because the upload subdir is web-reachable on IIS/Nginx where .htaccess is
269 * ignored, so a stored .html or .svg could host an XSS payload even though
270 * wp_handle_upload would not save a .php file under an unauthenticated request.
271 *
272 * @param string $filename
273 * @return bool
274 */
275 private static function HasDangerousExtension($filename)
276 {
277 static $deny = array(
278 // PHP and PHP-handler variants
279 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'pht', 'phar', 'phps',
280 // Other server-side scripting
281 'cgi', 'pl', 'py', 'rb', 'jsp', 'jspx', 'asp', 'aspx', 'cer', 'cfm', 'shtml',
282 // Executables and shells
283 'exe', 'msi', 'sh', 'bat', 'cmd', 'com', 'vb', 'vbs', 'wsh',
284 // Server config
285 'htaccess', 'htpasswd', 'ini', 'env',
286 // Active web content (inline XSS if directly accessible)
287 'html', 'htm', 'xhtml', 'svg', 'svgz', 'js', 'mjs', 'xml',
288 );
289
290 $parts = explode('.', strtolower($filename));
291 array_shift($parts); // skip basename, only inspect dot-segments
292 foreach ($parts as $part) {
293 if ($part !== '' && in_array($part, $deny, true)) {
294 return true;
295 }
296 }
297 return false;
298 }
299
300 /**
301 * Get uploaded files for a specific field ID from $_FILES.
302 * Normalizes the PHP $_FILES array for multiple files.
303 *
304 * @param string $field_id
305 * @return array Array of file arrays (name, type, tmp_name, error, size)
306 */
307 private static function GetUploadedFiles($field_id)
308 {
309 // Nonce verified upstream by FormController::SubmitCallback before any caller of this method runs.
310 // 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.
311 // 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.
312 // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
313 if (empty($_FILES['files']) || !isset($_FILES['files']['name'][$field_id])) {
314 return array();
315 }
316
317 $files = array();
318 $names = $_FILES['files']['name'][$field_id];
319 $types = $_FILES['files']['type'][$field_id];
320 $tmp_names = $_FILES['files']['tmp_name'][$field_id];
321 $errors = $_FILES['files']['error'][$field_id];
322 $sizes = $_FILES['files']['size'][$field_id];
323 // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
324
325 // Normalize: could be a single file or array of files
326 if (is_array($names)) {
327 for ($i = 0; $i < count($names); $i++) {
328 if (empty($names[$i])) {
329 continue;
330 }
331 $files[] = array(
332 'name' => $names[$i],
333 'type' => $types[$i],
334 'tmp_name' => $tmp_names[$i],
335 'error' => $errors[$i],
336 'size' => $sizes[$i],
337 );
338 }
339 } else {
340 if (!empty($names)) {
341 $files[] = array(
342 'name' => $names,
343 'type' => $types,
344 'tmp_name' => $tmp_names,
345 'error' => $errors,
346 'size' => $sizes,
347 );
348 }
349 }
350
351 return $files;
352 }
353
354 /**
355 * Ensure the protected upload directory exists with .htaccess and index.php.
356 */
357 private static function EnsureUploadDir()
358 {
359 $upload_dir = wp_upload_dir();
360 $base_path = $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR;
361
362 // Create base directory if needed
363 if (!is_dir($base_path)) {
364 wp_mkdir_p($base_path);
365 }
366
367 // Use WP_Filesystem for file writes (required by plugin review guidelines)
368 global $wp_filesystem;
369 if (empty($wp_filesystem)) {
370 require_once(ABSPATH . 'wp-admin/includes/file.php');
371 WP_Filesystem();
372 }
373
374 // Create .htaccess to deny direct access (Apache)
375 $htaccess = $base_path . '/.htaccess';
376 if (!file_exists($htaccess)) {
377 $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);
378 }
379
380 // Create web.config to deny direct access (IIS). accessPolicy="None"
381 // strips Read/Script/Execute so any request to this dir returns 403,
382 // mirroring the Apache "Require all denied" posture above.
383 $webconfig = $base_path . '/web.config';
384 if (!file_exists($webconfig)) {
385 $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);
386 }
387
388 // Create index.php to prevent directory listing
389 $index = $base_path . '/index.php';
390 if (!file_exists($index)) {
391 $wp_filesystem->put_contents($index, "<?php\n// Silence is golden.\n", FS_CHMOD_FILE);
392 }
393
394 // Also protect the current year/month subdirectory
395 $current_path = $upload_dir['path'];
396 if (strpos($current_path, $base_path) === 0 && !is_dir($current_path)) {
397 wp_mkdir_p($current_path);
398 }
399 }
400 }
401