| @@ -15,8 +15,9 @@ | ||
| 15 | 15 | use FluentForm\Framework\Support\Collection; |
| 16 | 16 | use FluentForm\App\Services\Form\FormService; |
| 17 | 17 | use FluentForm\App\Modules\Form\FormDataParser; |
| 18 | 18 | use FluentForm\App\Modules\Form\FormFieldsParser; |
| 19 | +use FluentForm\App\Services\Manager\FormManagerService; | |
| 19 | 20 | |
| 20 | 21 | class SubmissionService |
| 21 | 22 | { |
| 22 | 23 | /** |
| @@ -190,8 +191,19 @@ | ||
| 190 | 191 | |
| 191 | 192 | $formId = Arr::get($attributes, 'form_id'); |
| 192 | 193 | $submissionId = Arr::get($attributes, 'entry_id'); |
| 193 | 194 | |
| 195 | + // When an entry is targeted, the authoritative form is the one stored on | |
| 196 | + // that submission — never a separately supplied request form_id, which | |
| 197 | + // could point counts/labels/fields/metadata reads at another form the | |
| 198 | + // caller was not authorized for (authorization resolved from the entry). | |
| 199 | + if ($submissionId) { | |
| 200 | + $submission = $this->model->find($submissionId); | |
| 201 | + if ($submission) { | |
| 202 | + $formId = (int) $submission->form_id; | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 194 | 206 | if (Arr::get($attributes, 'counts')) { |
| 195 | 207 | $resources['counts'] = $this->model->countByGroup($formId); |
| 196 | 208 | } |
| 197 | 209 | |
| @@ -278,8 +290,20 @@ | ||
| 278 | 290 | $submissionId = intval(Arr::get($attributes, 'entry_id')); |
| 279 | 291 | |
| 280 | 292 | $status = sanitize_text_field(Arr::get($attributes, 'status')); |
| 281 | 293 | |
| 294 | + $submission = $this->model->find($submissionId); | |
| 295 | + | |
| 296 | + if (!$submission) { | |
| 297 | + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output | |
| 298 | + throw new Exception(__('Submission not found', 'fluentform')); | |
| 299 | + } | |
| 300 | + | |
| 301 | + if (!isset(Helper::getMutableEntryStatuses($submission->form_id, $submissionId)[$status])) { | |
| 302 | + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output | |
| 303 | + throw new Exception(__('Invalid entry status', 'fluentform')); | |
| 304 | + } | |
| 305 | + | |
| 282 | 306 | $this->model->amend($submissionId, ['status' => $status]); |
| 283 | 307 | |
| 284 | 308 | do_action('fluentform/after_submission_status_update', $submissionId, $status); |
| 285 | 309 | |
| @@ -336,15 +360,26 @@ | ||
| 336 | 360 | // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output |
| 337 | 361 | throw new Exception(__('Please select entries first', 'fluentform')); |
| 338 | 362 | } |
| 339 | 363 | |
| 364 | + // Re-verify against the form actually mutated; the policy scopes on a request entry_id. | |
| 365 | + if (!FormManagerService::hasFormPermission($formId)) { | |
| 366 | + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output | |
| 367 | + throw new Exception(__('You do not have permission to modify this form\'s entries.', 'fluentform')); | |
| 368 | + } | |
| 369 | + | |
| 340 | 370 | $query = $this->model->where('form_id', $formId)->whereIn('id', $submissionIds); |
| 341 | 371 | |
| 342 | - $statuses = Helper::getEntryStatuses($formId); | |
| 372 | + $statuses = Helper::getMutableEntryStatuses($formId); | |
| 343 | 373 | |
| 344 | 374 | $message = ''; |
| 345 | 375 | |
| 346 | 376 | if (isset($statuses[$actionType])) { |
| 377 | + // Hook only ids this form owns; array_flip keeps the caller's own id values and order. | |
| 378 | + $ownedIds = array_flip( | |
| 379 | + $this->model->where('form_id', $formId)->whereIn('id', $submissionIds)->pluck('id')->all() | |
| 380 | + ); | |
| 381 | + | |
| 347 | 382 | $query->update([ |
| 348 | 383 | 'status' => $actionType, |
| 349 | 384 | 'updated_at' => current_time('mysql'), |
| 350 | 385 | ]); |
| @@ -349,9 +384,11 @@ | ||
| 349 | 384 | 'updated_at' => current_time('mysql'), |
| 350 | 385 | ]); |
| 351 | 386 | |
| 352 | 387 | foreach ($submissionIds as $submissionId) { |
| 353 | - do_action('fluentform/after_submission_status_update', $submissionId, $actionType); | |
| 388 | + if (isset($ownedIds[$submissionId])) { | |
| 389 | + do_action('fluentform/after_submission_status_update', $submissionId, $actionType); | |
| 390 | + } | |
| 354 | 391 | } |
| 355 | 392 | |
| 356 | 393 | $message = 'Selected entries successfully marked as ' . $statuses[$actionType]; |
| 357 | 394 | } elseif ('other.delete_permanently' == $actionType) { |
| @@ -435,10 +472,18 @@ | ||
| 435 | 472 | if ($shouldDelete) { |
| 436 | 473 | $deletables = $this->getAttachments($submissionIds, $formId); |
| 437 | 474 | |
| 438 | 475 | foreach ($deletables as $file) { |
| 439 | - $file = wp_upload_dir()['basedir'] . FLUENTFORM_UPLOAD_DIR . '/' . basename($file); | |
| 476 | + $fileName = basename($file); | |
| 440 | 477 | |
| 478 | + // Plugin uploads are ff-prefixed, so guard files and dot-files can only be crafted. | |
| 479 | + if ('' === $fileName || '.' === $fileName[0] | |
| 480 | + || in_array(strtolower($fileName), ['index.php', 'web.config'], true)) { | |
| 481 | + continue; | |
| 482 | + } | |
| 483 | + | |
| 484 | + $file = wp_upload_dir()['basedir'] . FLUENTFORM_UPLOAD_DIR . '/' . $fileName; | |
| 485 | + | |
| 441 | 486 | if (is_readable($file) && !is_dir($file)) { |
| 442 | 487 | wp_delete_file($file); |
| 443 | 488 | } |
| 444 | 489 | } |
| @@ -446,10 +491,12 @@ | ||
| 446 | 491 | if (defined('FLUENTFORMPRO')) { |
| 447 | 492 | $tempDir = wp_upload_dir()['basedir'] . FLUENTFORM_UPLOAD_DIR . '/temp/'; |
| 448 | 493 | $files = glob($tempDir . '*'); |
| 449 | 494 | if(!empty($files)){ |
| 495 | + // Shared across forms/visitors: only aged files, never an in-flight upload. | |
| 496 | + $cutoff = time() - apply_filters('fluentform/temp_file_delete_time', 172800); | |
| 450 | 497 | foreach ($files as $file) { |
| 451 | - if (basename($file) !== 'index.php') { | |
| 498 | + if (basename($file) !== 'index.php' && is_file($file) && filemtime($file) < $cutoff) { | |
| 452 | 499 | wp_delete_file($file); |
| 453 | 500 | } |
| 454 | 501 | } |
| 455 | 502 | } |
| @@ -553,9 +600,14 @@ | ||
| 553 | 600 | } |
| 554 | 601 | |
| 555 | 602 | public function storeNote($submissionId, $attributes = []) |
| 556 | 603 | { |
| 557 | - $formId = intval(Arr::get($attributes, 'form_id')); | |
| 604 | + // SECURITY (FINDING-20): derive form_id from the route-authorized submission, not the | |
| 605 | + // request body. Authorization is computed from the entry_id, but the note row's form_id | |
| 606 | + // came straight from the body — letting a manager scoped to one form write a note row into | |
| 607 | + // another form's meta namespace (cross-form integrity pollution). | |
| 608 | + $submission = Submission::find($submissionId); | |
| 609 | + $formId = $submission ? intval($submission->form_id) : intval(Arr::get($attributes, 'form_id')); | |
| 558 | 610 | |
| 559 | 611 | $content = sanitize_textarea_field($attributes['note']['content']); |
| 560 | 612 | $status = sanitize_text_field($attributes['note']['status']); |
| 561 | 613 | $user = get_user_by('ID', get_current_user_id()); |
| @@ -653,16 +705,21 @@ | ||
| 653 | 705 | ); |
| 654 | 706 | |
| 655 | 707 | do_action('fluentform/submission_user_changed', $submission, $user); |
| 656 | 708 | |
| 709 | + // Email is roster PII; gate it on list_users (permalink self-gates). FF-SEC-45. | |
| 710 | + $responseUser = [ | |
| 711 | + 'name' => $user->display_name, | |
| 712 | + 'ID' => $user->ID, | |
| 713 | + 'permalink' => get_edit_user_link($user->ID), | |
| 714 | + ]; | |
| 715 | + if (current_user_can('list_users')) { | |
| 716 | + $responseUser['email'] = $user->user_email; | |
| 717 | + } | |
| 718 | + | |
| 657 | 719 | return ([ |
| 658 | 720 | 'message' => __('Selected user has been successfully assigned to this submission', 'fluentform'), |
| 659 | - 'user' => [ | |
| 660 | - 'name' => $user->display_name, | |
| 661 | - 'email' => $user->user_email, | |
| 662 | - 'ID' => $user->ID, | |
| 663 | - 'permalink' => get_edit_user_link($user->ID), | |
| 664 | - ], | |
| 721 | + 'user' => $responseUser, | |
| 665 | 722 | 'user_id' => $userId, |
| 666 | 723 | ]); |
| 667 | 724 | } |
| 668 | 725 | |