Connect CMS has Stored Cross-site Scripting (XSS) in the File Field of its Form Plugin
Description
Connect-CMS is a content management system. In versions on the 1.x series up to and including 1.41.0 and versions on the 2.x series up to and including 2.41.0, a Stored Cross-site Scripting (XSS) issue exists in the file field of the Form Plugin. Versions 1.41.1 and 2.41.1 contain a patch.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Connect-CMS Form Plugin file field is vulnerable to stored XSS, allowing attackers to inject malicious scripts via file uploads.
Vulnerability
Overview
Connect-CMS versions 1.x up to 1.41.0 and 2.x up to 2.41.0 contain a stored cross-site scripting (XSS) vulnerability in the file field of the Form Plugin [1][2]. The root cause is insufficient validation of uploaded file content, allowing an attacker to upload a file containing malicious JavaScript that is later executed when the file is accessed or rendered by other users [1].
Exploitation
An attacker with the ability to submit forms that include file uploads can exploit this vulnerability by uploading a crafted file containing XSS payloads [2]. No special authentication is required beyond normal form submission access; the attack is stored on the server and triggers when any user views the uploaded file or its metadata [1].
Impact
Successful exploitation allows the attacker to execute arbitrary JavaScript in the context of the victim's browser session [2]. This can lead to session hijacking to session hijacking, defacement, or theft of sensitive data displayed in the CMS [2].
Mitigation
The vulnerability is patched in Connect-CMS versions 1.41.1 and 2.41.1 [1][4]. Users are strongly advised to upgrade immediately. The patch adds validation for allowed file extensions and MIME types to prevent malicious file uploads [1].
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
opensource-workshop/connect-cmsPackagist | < 1.41.1 | 1.41.1 |
opensource-workshop/connect-cmsPackagist | >= 2.0.0, < 2.41.1 | 2.41.1 |
Affected products
2- Range: 1.x <=1.41.0, 2.x <=2.41.0
- opensource-workshop/connect-cmsv5Range: < 1.41.1
Patches
19d87fe8ecf7fFix: GHSA-mv3p-7p89-wq9p
17 files changed · +1621 −20
app/Http/Controllers/Core/UploadController.php+0 −1 modified@@ -162,7 +162,6 @@ public function getFile(Request $request, $id = null) 'jpe', 'jpeg', 'gif', - 'html', ]; // サムネイル指定の場合は、キャッシュを使ってファイルを返す。
app/Models/User/Forms/FormsColumns.php+1 −1 modified@@ -13,7 +13,7 @@ class FormsColumns extends Model use UserableNohistory; // 更新する項目の定義 - protected $fillable = ['forms_id', 'column_type', 'column_name', 'required', 'frame_col', 'caption', 'caption_color', 'place_holder', 'minutes_increments', 'minutes_increments_from', 'minutes_increments_to', 'rule_allowed_numeric', 'rule_allowed_alpha_numeric', 'rule_digits_or_less', 'rule_max', 'rule_min', 'rule_word_count', 'rule_date_after_equal', 'display_sequence']; + protected $fillable = ['forms_id', 'column_type', 'column_name', 'required', 'frame_col', 'caption', 'caption_color', 'place_holder', 'minutes_increments', 'minutes_increments_from', 'minutes_increments_to', 'rule_allowed_numeric', 'rule_allowed_alpha_numeric', 'rule_digits_or_less', 'rule_max', 'rule_min', 'rule_word_count', 'rule_date_after_equal', 'rule_file_extensions', 'rule_file_max_kb', 'display_sequence']; /** * ファイルタイプのカラム型か
app/Plugins/User/Forms/FormsPlugin.php+216 −5 modified@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; +use Illuminate\Http\UploadedFile; use Carbon\Carbon; @@ -30,6 +31,8 @@ use App\Rules\CustomValiBothRequired; use App\Rules\CustomValiTokenExists; use App\Rules\CustomValiEmails; +use App\Rules\CustomValiUploadExtensions; +use App\Rules\CustomValiUploadMimetypes; use App\Rules\CustomValiWysiwygMax; use App\Plugins\User\UserPluginBase; @@ -47,6 +50,7 @@ use App\Enums\SpamBlockType; use App\Enums\Required; use App\Enums\StatusType; +use App\Enums\UploadMaxSize; use App\Models\User\Bbses\Bbs; use App\Models\User\Bbses\BbsPost; use App\Models\User\Blogs\Blogs; @@ -577,6 +581,142 @@ private function isValidRegex($pattern): bool return $result !== false; } + /** + * フォームファイルアップロードの許可拡張子(既定値) + * + * @return array<int, string> + */ + private function getFormsUploadAllowedExtensions(): array + { + $extensions = config('forms.upload.allowed_extensions', []); + return FormsUploadHelper::normalizeExtensions($extensions); + } + + /** + * 拡張子ごとの許可MIMEタイプ + * + * @return array<string, array<int, string>> + */ + private function getFormsUploadMimetypeMap(): array + { + $mimetype_map = config('forms.upload.mimetype_map', []); + if (! is_array($mimetype_map)) { + return []; + } + + $normalized_map = []; + foreach ($mimetype_map as $extension => $mimetypes) { + $extension = FormsUploadHelper::normalizeExtension($extension); + if ($extension === '') { + continue; + } + + if (! is_array($mimetypes)) { + $mimetypes = [$mimetypes]; + } + + $normalized_mimetypes = array_map(function ($mimetype) { + return mb_strtolower((string) $mimetype); + }, $mimetypes); + + $normalized_mimetypes = array_values(array_unique(array_filter($normalized_mimetypes))); + if (! empty($normalized_mimetypes)) { + $normalized_map[$extension] = $normalized_mimetypes; + } + } + + return $normalized_map; + } + + /** + * フォームファイルアップロードの最大サイズ(KB・PHP設定値) + */ + private function getFormsUploadMaxKb(): int + { + $php_max_bytes = UploadedFile::getMaxFilesize(); + if (! is_numeric($php_max_bytes) || (float) $php_max_bytes <= 0) { + return 0; + } + + return max(1, (int) floor(((float) $php_max_bytes) / 1024)); + } + + /** + * 項目設定で選択できる最大アップロードサイズ一覧(KB) + * + * @return array<int, int> + */ + private function getFormsSelectableUploadMaxKb(): array + { + $php_upload_max_kb = $this->getFormsUploadMaxKb(); + $selectable_upload_max_kb = []; + + foreach (UploadMaxSize::getMemberKeys() as $size_kb) { + if (! is_numeric($size_kb)) { + continue; + } + $size_kb = (int) $size_kb; + if ($size_kb <= 0) { + continue; + } + if ($php_upload_max_kb > 0 && $size_kb > $php_upload_max_kb) { + continue; + } + $selectable_upload_max_kb[] = $size_kb; + } + + $selectable_upload_max_kb = array_values(array_unique($selectable_upload_max_kb)); + sort($selectable_upload_max_kb); + + return $selectable_upload_max_kb; + } + + /** + * 項目設定を反映した許可拡張子を取得 + * + * @return array<int, string> + */ + private function getFormsColumnUploadExtensions($forms_column): array + { + $default_extensions = $this->getFormsUploadAllowedExtensions(); + return FormsUploadHelper::resolveAllowedExtensions($default_extensions, $forms_column->rule_file_extensions ?? null); + } + + /** + * 項目設定を反映した拡張子ごとの許可MIMEタイプを取得 + * + * @return array<string, array<int, string>> + */ + private function getFormsColumnUploadMimetypeMap($forms_column): array + { + $extensions = $this->getFormsColumnUploadExtensions($forms_column); + $mimetype_map = $this->getFormsUploadMimetypeMap(); + + $column_mimetype_map = []; + foreach ($extensions as $extension) { + if (isset($mimetype_map[$extension]) && ! empty($mimetype_map[$extension])) { + $column_mimetype_map[$extension] = $mimetype_map[$extension]; + } + } + return $column_mimetype_map; + } + + /** + * 項目設定を反映した最大アップロードサイズ(KB)を取得 + */ + private function getFormsColumnUploadMaxKb($forms_column): int + { + $php_upload_max_kb = $this->getFormsUploadMaxKb(); + $column_max_kb = $forms_column->rule_file_max_kb ?? null; + if (is_numeric($column_max_kb) && (int) $column_max_kb > 0) { + if ($php_upload_max_kb > 0) { + return min((int) $column_max_kb, $php_upload_max_kb); + } + return (int) $column_max_kb; + } + return $php_upload_max_kb; + } + /** * セットすべきバリデータールールが存在する場合、受け取った配列にセットして返す * @@ -592,6 +732,26 @@ private function getValidatorRule($validator_array, $forms_column, $request) if ($forms_column->required) { $validator_rule[] = 'required'; } + // ファイルチェック + if ($forms_column->column_type == FormColumnType::file) { + if (! $forms_column->required) { + $validator_rule[] = 'nullable'; + } + $validator_rule[] = 'file'; + + $allowed_extensions = $this->getFormsColumnUploadExtensions($forms_column); + if (! empty($allowed_extensions)) { + $validator_rule[] = new CustomValiUploadExtensions($allowed_extensions); + } + + $allowed_mimetype_map = $this->getFormsColumnUploadMimetypeMap($forms_column); + $validator_rule[] = new CustomValiUploadMimetypes($allowed_mimetype_map, $allowed_extensions); + + $max_kb = $this->getFormsColumnUploadMaxKb($forms_column); + if ($max_kb > 0) { + $validator_rule[] = 'max:' . $max_kb; + } + } // メールアドレスチェック if ($forms_column->column_type == FormColumnType::mail) { $validator_rule[] = 'nullable'; @@ -937,12 +1097,16 @@ public function publicConfirm($request, $page_id, $frame_id, $id = null) if ($request->hasFile($req_filename)) { // ファイルチェック + $upload_file = $request->file($req_filename); + $upload_extension = FormsUploadHelper::normalizeExtension($upload_file->getClientOriginalExtension()); + $upload_mimetype = $upload_file->getMimeType() ?: $upload_file->getClientMimeType(); + // uploads テーブルに情報追加、ファイルのid を取得する $upload = Uploads::create([ - 'client_original_name' => $request->file($req_filename)->getClientOriginalName(), - 'mimetype' => $request->file($req_filename)->getClientMimeType(), - 'extension' => $request->file($req_filename)->getClientOriginalExtension(), - 'size' => $request->file($req_filename)->getSize(), + 'client_original_name' => $upload_file->getClientOriginalName(), + 'mimetype' => $upload_mimetype, + 'extension' => $upload_extension, + 'size' => $upload_file->getSize(), 'plugin_name' => 'forms', 'check_method' => 'canDownload', 'page_id' => $page_id, @@ -952,7 +1116,7 @@ public function publicConfirm($request, $page_id, $frame_id, $id = null) // ファイル保存 $directory = $this->getDirectory($upload->id); - $upload_path = $request->file($req_filename)->storeAs($directory, $upload->id . '.' . $request->file($req_filename)->getClientOriginalExtension()); + $upload_path = $upload_file->storeAs($directory, $upload->id . '.' . $upload_extension); // 項目とファイルID の関連保持 $upload->column_type = $forms_column->column_type; @@ -2396,6 +2560,43 @@ function ($attribute, $value, $fail) { $validator_values['rule_date_after_equal'] = ['numeric']; $validator_attributes['rule_date_after_equal'] = '~日以降を許容'; } + // ファイル型の拡張子チェック + if ($column->column_type == FormColumnType::file) { + $allowed_extensions = $this->getFormsUploadAllowedExtensions(); + + $validator_values['rule_file_extensions'] = [ + 'required', + 'array', + 'min:1', + function ($attribute, $value, $fail) use ($allowed_extensions) { + $extensions = FormsUploadHelper::normalizeExtensions($value); + if (empty($extensions)) { + return; + } + foreach ($extensions as $extension) { + if (! in_array($extension, $allowed_extensions, true)) { + $fail('許可拡張子に未対応の形式が含まれています。'); + return; + } + } + }, + ]; + $validator_attributes['rule_file_extensions'] = '許可拡張子'; + + // ファイル型の最大サイズ(KB)チェック + if ($request->rule_file_max_kb !== null && $request->rule_file_max_kb !== '') { + $validator_values['rule_file_max_kb'] = ['integer', 'min:1']; + $selectable_upload_max_kb = $this->getFormsSelectableUploadMaxKb(); + if (! empty($selectable_upload_max_kb)) { + $validator_values['rule_file_max_kb'][] = 'in:' . implode(',', $selectable_upload_max_kb); + } + $php_upload_max_kb = $this->getFormsUploadMaxKb(); + if ($php_upload_max_kb > 0) { + $validator_values['rule_file_max_kb'][] = 'max:' . $php_upload_max_kb; + } + $validator_attributes['rule_file_max_kb'] = '最大ファイルサイズ'; + } + } // アンケートの場合、項目名のwysiwygチェック if ($form->form_mode == FormMode::questionnaire) { $validator_values['column_name'] = ['required', new CustomValiWysiwygMax()]; @@ -2445,6 +2646,16 @@ function ($attribute, $value, $fail) { $column->rule_regex = $request->rule_regex; // ~日以降を許容 $column->rule_date_after_equal = $request->rule_date_after_equal; + // ファイル型設定 + if ($column->column_type == FormColumnType::file) { + $file_extensions = FormsUploadHelper::normalizeExtensions($request->input('rule_file_extensions', [])); + $file_extensions = array_values(array_intersect($file_extensions, $this->getFormsUploadAllowedExtensions())); + $column->rule_file_extensions = empty($file_extensions) ? null : implode(',', $file_extensions); + $column->rule_file_max_kb = ($request->rule_file_max_kb === null || $request->rule_file_max_kb === '') ? null : (int) $request->rule_file_max_kb; + } else { + $column->rule_file_extensions = null; + $column->rule_file_max_kb = null; + } // アンケートの場合、項目名の更新 if ($form->form_mode == FormMode::questionnaire) { $column->column_name = $request->column_name;
app/Plugins/User/Forms/FormsUploadHelper.php+293 −0 added@@ -0,0 +1,293 @@ +<?php + +namespace App\Plugins\User\Forms; + +use App\Enums\FormColumnType; +use App\Enums\UploadMaxSize; +use Illuminate\Http\UploadedFile; + +/** + * フォームのアップロード関連ユーティリティ。 + * + * 拡張子/MIME/サイズ表示に関する処理を Blade・Plugin・Rule 間で + * 共通化するための static ヘルパー。 + */ +final class FormsUploadHelper +{ + /** + * 拡張子を比較用に正規化する。 + * + * 先頭ドットを除去し、小文字化して返す。 + * + * @param mixed $extension + */ + public static function normalizeExtension($extension): string + { + return mb_strtolower(ltrim((string) $extension, '.')); + } + + /** + * 拡張子入力を正規化して返す。 + * + * 文字列入力は「半角/全角カンマ・空白」で分割し、 + * 空要素を除外した一意な拡張子配列に整形する。 + * + * @param mixed $extensions + * @return array<int, string> + */ + public static function normalizeExtensions($extensions): array + { + if (is_null($extensions) || $extensions === '') { + return []; + } + + if (is_string($extensions)) { + $extensions = preg_split('/[\s,,]+/u', $extensions, -1, PREG_SPLIT_NO_EMPTY); + } elseif (! is_array($extensions)) { + $extensions = [(string) $extensions]; + } + + $normalized = []; + foreach ($extensions as $extension) { + $extension = self::normalizeExtension($extension); + if ($extension === '') { + continue; + } + $normalized[] = $extension; + } + + return array_values(array_unique($normalized)); + } + + /** + * 既定許可リストと項目設定値から、実際に使用する許可拡張子を返す。 + * + * 項目設定値が空、または既定許可リストと交差しない場合は + * 既定許可リストを返す。 + * + * @param mixed $default_extensions + * @param mixed $column_extensions + * @return array<int, string> + */ + public static function resolveAllowedExtensions($default_extensions, $column_extensions): array + { + $default_extensions = self::normalizeExtensions($default_extensions); + $column_extensions = self::normalizeExtensions($column_extensions); + + if (empty($column_extensions)) { + return $default_extensions; + } + + $column_extensions = array_values(array_intersect($column_extensions, $default_extensions)); + return empty($column_extensions) ? $default_extensions : $column_extensions; + } + + /** + * accept属性文字列へ変換する。 + * + * 例: ['jpg', 'png'] -> '.jpg, .png' + * + * @param array<int, string> $extensions + */ + public static function toAcceptAttribute(array $extensions): string + { + $extensions = self::normalizeExtensions($extensions); + + $accept_extensions = array_map(function ($extension) { + return '.' . $extension; + }, $extensions); + + return implode(', ', $accept_extensions); + } + + /** + * キャプション内のアップロード最大サイズプレースホルダを置換する。 + * + * `[[upload_max_filesize]]` を、列設定またはPHP設定に基づく + * 表示用文字列へ置換し、改行は `nl2br()` で整形する。 + * + * @param mixed $caption + * @param mixed $form_column + */ + public static function replaceUploadMaxFilesize($caption, $form_column): string + { + $max_filesize_caption = ini_get('upload_max_filesize'); + if (! empty($form_column) && ($form_column->column_type ?? null) == FormColumnType::file) { + $rule_file_max_kb = $form_column->rule_file_max_kb ?? null; + if (is_numeric($rule_file_max_kb) && (int) $rule_file_max_kb > 0) { + $rule_file_max_kb = (string) ((int) $rule_file_max_kb); + $upload_max_size_members = UploadMaxSize::getMembers(); + $max_filesize_caption = $upload_max_size_members[$rule_file_max_kb] ?? ($rule_file_max_kb . 'KB'); + } + } + + return str_ireplace('[[upload_max_filesize]]', $max_filesize_caption, nl2br((string) $caption)); + } + + /** + * 項目設定画面(ファイル型)で選択状態にする拡張子を返す。 + * + * バリデーションエラー後の再表示時は old() を優先し、 + * 初期表示時は列設定値(未設定時は既定許可リスト全選択)を使用する。 + * + * @param mixed $selected_extensions + * @param mixed $is_old_submitted + * @param mixed $column_rule_file_extensions + * @param array<int, string> $file_extension_options + * @return array<int, string> + */ + public static function resolveSelectedExtensionsForEdit( + $selected_extensions, + $is_old_submitted, + $column_rule_file_extensions, + array $file_extension_options + ): array { + $file_extension_options = self::normalizeExtensions($file_extension_options); + if (! is_null($selected_extensions) || ! empty($is_old_submitted)) { + if (! is_array($selected_extensions)) { + $selected_extensions = []; + } + } else { + $selected_extensions = self::normalizeExtensions($column_rule_file_extensions); + if (empty($selected_extensions)) { + // 項目未設定時は既定値(許可リスト)を全てチェック状態にする。 + $selected_extensions = $file_extension_options; + } + } + + $selected_extensions = self::normalizeExtensions($selected_extensions); + if (empty($selected_extensions) && empty($is_old_submitted)) { + $selected_extensions = $file_extension_options; + } + + return $selected_extensions; + } + + /** + * 項目設定画面(ファイル型)で表示するアップロード最大サイズ文字列を返す。 + * + * `ini_get('upload_max_filesize')` の値をそのまま返す。 + */ + public static function getPhpUploadMaxFilesizeCaption(): string + { + return (string) ini_get('upload_max_filesize'); + } + + /** + * 項目設定画面(ファイル型)で利用する最大サイズ選択値を正規化する。 + * + * 未選択は空文字、選択済みは整数文字列へ統一する。 + * + * @param mixed $selected_file_max_kb + */ + public static function normalizeSelectedFileMaxKb($selected_file_max_kb): string + { + return ($selected_file_max_kb === null || $selected_file_max_kb === '') + ? '' + : (string) ((int) $selected_file_max_kb); + } + + /** + * 許可拡張子リストをカテゴリ表示用の配列へ整形する。 + * + * カテゴリ未所属の拡張子は「その他」グループへ集約する。 + * + * @param array<int, string> $file_extension_options + * @param mixed $extension_categories + * @return array<int, array{label: string, description: mixed, extensions: array<int, string>}> + */ + public static function buildCategorizedExtensionGroups( + array $file_extension_options, + $extension_categories + ): array { + if (! is_array($extension_categories)) { + $extension_categories = []; + } + + $categorized_extension_groups = []; + $categorized_extensions = []; + foreach ($extension_categories as $extension_category) { + if (! is_array($extension_category)) { + continue; + } + + $extensions = $extension_category['extensions'] ?? []; + if (! is_array($extensions)) { + continue; + } + + $extensions = self::normalizeExtensions($extensions); + $extensions = array_values(array_intersect($extensions, $file_extension_options)); + if (empty($extensions)) { + continue; + } + + $categorized_extension_groups[] = [ + 'label' => (string) ($extension_category['label'] ?? 'その他'), + 'description' => $extension_category['description'] ?? null, + 'extensions' => $extensions, + ]; + $categorized_extensions = array_merge($categorized_extensions, $extensions); + } + + $categorized_extensions = array_values(array_unique($categorized_extensions)); + + $uncategorized_extensions = array_values(array_diff($file_extension_options, $categorized_extensions)); + if (! empty($uncategorized_extensions)) { + $categorized_extension_groups[] = [ + 'label' => 'その他', + 'description' => null, + 'extensions' => $uncategorized_extensions, + ]; + } + + return $categorized_extension_groups; + } + + /** + * PHP設定からアップロード上限(KB)を取得する。 + * + * 取得不能・無効値の場合は `null` を返す。 + * + * @return int|null + */ + public static function getPhpUploadMaxKb(): ?int + { + $php_upload_max_bytes = UploadedFile::getMaxFilesize(); + if (! is_numeric($php_upload_max_bytes) || (float) $php_upload_max_bytes <= 0) { + return null; + } + + return max(1, (int) floor(((float) $php_upload_max_bytes) / 1024)); + } + + /** + * 最大サイズ選択肢を構築する。 + * + * enumの候補値を基に、PHP上限を超える値と無効値を除外する。 + * + * @param int|null $php_upload_max_kb + * @return array<string, string> + */ + public static function buildMaxSizeOptions(?int $php_upload_max_kb): array + { + $max_size_options = []; + foreach (UploadMaxSize::getMembers() as $size_kb => $size_label) { + if ($size_kb === UploadMaxSize::infinity || ! is_numeric($size_kb)) { + continue; + } + + $size_kb = (int) $size_kb; + if ($size_kb <= 0) { + continue; + } + if (! empty($php_upload_max_kb) && $size_kb > $php_upload_max_kb) { + continue; + } + + $max_size_options[(string) $size_kb] = $size_label . '(' . $size_kb . 'KB)'; + } + + return $max_size_options; + } +}
app/Rules/CustomValiUploadExtensions.php+54 −0 added@@ -0,0 +1,54 @@ +<?php + +namespace App\Rules; + +use App\Plugins\User\Forms\FormsUploadHelper; +use Illuminate\Contracts\Validation\Rule; + +/** + * アップロードファイルの拡張子許可リストチェック + */ +class CustomValiUploadExtensions implements Rule +{ + /** @var array<string> */ + private $allowed_extensions = []; + + /** + * @param array<string> $allowed_extensions + */ + public function __construct(array $allowed_extensions) + { + $this->allowed_extensions = FormsUploadHelper::normalizeExtensions($allowed_extensions); + } + + /** + * @param string $attribute + * @param mixed $value + */ + public function passes($attribute, $value): bool + { + // nullable向け。requiredは別ルールで判定される。 + if (empty($value)) { + return true; + } + + if (! method_exists($value, 'getClientOriginalExtension')) { + return false; + } + + $extension = FormsUploadHelper::normalizeExtension($value->getClientOriginalExtension()); + if ($extension === '') { + return false; + } + + return in_array($extension, $this->allowed_extensions, true); + } + + /** + * @return string + */ + public function message() + { + return ':attributeには ' . implode(', ', $this->allowed_extensions) . ' のうちいずれかの拡張子を指定してください。'; + } +}
app/Rules/CustomValiUploadMimetypes.php+127 −0 added@@ -0,0 +1,127 @@ +<?php + +namespace App\Rules; + +use App\Plugins\User\Forms\FormsUploadHelper; +use Illuminate\Contracts\Validation\Rule; + +/** + * アップロードファイルのMIMEタイプ許可リストチェック + */ +class CustomValiUploadMimetypes implements Rule +{ + /** @var array<string, array<int, string>> */ + private $allowed_mimetype_map = []; + + /** @var array<string> */ + private $allowed_extensions = []; + + /** + * @param array<string, array<int, string>|string> $allowed_mimetype_map + * @param array<string> $allowed_extensions + */ + public function __construct(array $allowed_mimetype_map, array $allowed_extensions = []) + { + $this->allowed_mimetype_map = $this->normalizeAllowedMimetypeMap($allowed_mimetype_map); + $this->allowed_extensions = FormsUploadHelper::normalizeExtensions($allowed_extensions); + } + + /** + * @param string $attribute + * @param mixed $value + */ + public function passes($attribute, $value): bool + { + // nullable向け。requiredは別ルールで判定される。 + if (empty($value)) { + return true; + } + + if (! method_exists($value, 'getMimeType') || ! method_exists($value, 'getClientOriginalExtension')) { + return false; + } + + $extension = FormsUploadHelper::normalizeExtension($value->getClientOriginalExtension()); + if ($extension === '') { + return false; + } + + if (! empty($this->allowed_extensions) && ! in_array($extension, $this->allowed_extensions, true)) { + return false; + } + + if (! isset($this->allowed_mimetype_map[$extension])) { + return false; + } + + $detected_mimetype = $this->normalizeMimetype((string) $value->getMimeType()); + if ($detected_mimetype === '') { + return false; + } + + return in_array($detected_mimetype, $this->allowed_mimetype_map[$extension], true); + } + + /** + * @param array<string, array<int, string>|string> $allowed_mimetype_map + * @return array<string, array<int, string>> + */ + private function normalizeAllowedMimetypeMap(array $allowed_mimetype_map): array + { + $normalized_map = []; + foreach ($allowed_mimetype_map as $extension => $mimetypes) { + $normalized_extension = FormsUploadHelper::normalizeExtension($extension); + if ($normalized_extension === '') { + continue; + } + + if (! is_array($mimetypes)) { + $mimetypes = [$mimetypes]; + } + + $normalized_mimetypes = array_values(array_unique(array_filter(array_map(function ($mimetype) { + return $this->normalizeMimetype((string) $mimetype); + }, $mimetypes)))); + + if (! empty($normalized_mimetypes)) { + $normalized_map[$normalized_extension] = $normalized_mimetypes; + } + } + + return $normalized_map; + } + + /** + * MIMEタイプの比較用正規化 + */ + private function normalizeMimetype(string $mimetype): string + { + $mimetype = mb_strtolower(trim($mimetype)); + if ($mimetype === '') { + return ''; + } + + $semicolon_pos = mb_strpos($mimetype, ';'); + if ($semicolon_pos !== false) { + $mimetype = trim(mb_substr($mimetype, 0, $semicolon_pos)); + } + + return $mimetype; + } + + /** + * @return string + */ + public function message() + { + if (! empty($this->allowed_extensions)) { + $extensions = array_map(function ($extension) { + return '.' . $extension; + }, $this->allowed_extensions); + + return ':attributeには ' . implode(', ', $extensions) . ' のうちいずれかの形式を指定してください。'; + } + + return ':attributeのファイル形式が許可されていません。'; + } +}
config/forms.php+160 −0 added@@ -0,0 +1,160 @@ +<?php + +return [ + + // ファイルアップロード許可設定 + 'upload' => [ + // クライアント拡張子の許可リスト + 'allowed_extensions' => [ + '7z', + 'aac', + 'ai', + 'avi', + 'avif', + 'bmp', + 'csv', + 'doc', + 'docx', + 'gif', + 'gz', + 'jpeg', + 'jpg', + 'json', + 'md', + 'mov', + 'mp3', + 'mp4', + 'odp', + 'ods', + 'odt', + 'ogg', + 'pdf', + 'png', + 'ppt', + 'pptx', + 'rar', + 'rtf', + 'tar', + 'tif', + 'tiff', + 'txt', + 'wav', + 'webm', + 'webp', + 'xls', + 'xlsx', + 'xml', + 'zip', + ], + + // 拡張子の表示カテゴリ + 'extension_categories' => [ + [ + 'label' => '文書ファイル', + 'description' => '申請書や資料など、文書データをアップロードする場合に利用します。', + 'extensions' => [ + 'csv', + 'doc', + 'docx', + 'json', + 'md', + 'odp', + 'ods', + 'odt', + 'pdf', + 'ppt', + 'pptx', + 'rtf', + 'txt', + 'xls', + 'xlsx', + 'xml', + ], + ], + [ + 'label' => '画像ファイル', + 'description' => '写真やスクリーンショットなど、画像をアップロードする場合に利用します。', + 'extensions' => [ + 'ai', + 'avif', + 'bmp', + 'gif', + 'jpeg', + 'jpg', + 'png', + 'tif', + 'tiff', + 'webp', + ], + ], + [ + 'label' => '音声・動画ファイル', + 'description' => '録音データや動画データをアップロードする場合に利用します。', + 'extensions' => [ + 'aac', + 'avi', + 'mov', + 'mp3', + 'mp4', + 'ogg', + 'wav', + 'webm', + ], + ], + [ + 'label' => '圧縮ファイル', + 'description' => '複数ファイルをまとめた圧縮データをアップロードする場合に利用します。', + 'extensions' => [ + '7z', + 'gz', + 'rar', + 'tar', + 'zip', + ], + ], + ], + + // 拡張子ごとの許可MIMEタイプ + 'mimetype_map' => [ + '7z' => ['application/x-7z-compressed'], + 'aac' => ['audio/aac'], + 'ai' => ['application/postscript', 'application/illustrator'], + 'avi' => ['video/x-msvideo'], + 'avif' => ['image/avif'], + 'bmp' => ['image/bmp'], + 'csv' => ['text/csv', 'application/csv', 'text/plain'], + 'doc' => ['application/msword'], + 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + 'gif' => ['image/gif'], + 'gz' => ['application/gzip', 'application/x-gzip'], + 'jpeg' => ['image/jpeg'], + 'jpg' => ['image/jpeg'], + 'json' => ['application/json', 'text/json'], + 'md' => ['text/markdown', 'text/x-markdown', 'text/plain'], + 'mov' => ['video/quicktime'], + 'mp3' => ['audio/mpeg'], + 'mp4' => ['video/mp4'], + 'odp' => ['application/vnd.oasis.opendocument.presentation'], + 'ods' => ['application/vnd.oasis.opendocument.spreadsheet'], + 'odt' => ['application/vnd.oasis.opendocument.text'], + 'ogg' => ['application/ogg', 'audio/ogg', 'video/ogg'], + 'pdf' => ['application/pdf'], + 'png' => ['image/png'], + 'ppt' => ['application/vnd.ms-powerpoint'], + 'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation'], + 'rar' => ['application/vnd.rar', 'application/x-rar-compressed'], + 'rtf' => ['application/rtf'], + 'tar' => ['application/x-tar'], + 'tif' => ['image/tiff'], + 'tiff' => ['image/tiff'], + 'txt' => ['text/plain'], + 'wav' => ['audio/wav'], + 'webm' => ['video/webm'], + 'webp' => ['image/webp'], + 'xls' => ['application/vnd.ms-excel'], + 'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + 'xml' => ['application/xml', 'text/xml'], + 'zip' => ['application/zip', 'application/x-zip-compressed'], + ], + ], +];
database/factories/User/Forms/FormsColumnsFactory.php+2 −0 modified@@ -40,6 +40,8 @@ public function definition() 'rule_min' => null, 'rule_word_count' => null, 'rule_date_after_equal' => null, + 'rule_file_extensions' => null, + 'rule_file_max_kb' => null, 'display_sequence' => 0, ]; }
database/migrations/2026_02_17_000000_add_file_upload_rules_to_forms_columns_table.php+37 −0 added@@ -0,0 +1,37 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +class AddFileUploadRulesToFormsColumnsTable extends Migration +{ + /** + * Run the migrations. + */ + public function up() + { + Schema::table('forms_columns', function (Blueprint $table) { + $table->text('rule_file_extensions') + ->nullable() + ->comment('ファイル型: 許可拡張子(CSV)') + ->after('rule_date_after_equal'); + $table->unsignedInteger('rule_file_max_kb') + ->nullable() + ->comment('ファイル型: 最大アップロードサイズ(KB)') + ->after('rule_file_extensions'); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::table('forms_columns', function (Blueprint $table) { + $table->dropColumn('rule_file_extensions'); + $table->dropColumn('rule_file_max_kb'); + }); + } +} +
resources/views/plugins/user/forms/default/forms.blade.php+3 −6 modified@@ -99,16 +99,14 @@ {{-- 項目 ※まとめ設定行 --}} @include('plugins.user.forms.default.forms_input_' . $group_row->column_type, ['form_obj' => $group_row, 'label_id' => 'column-'.$group_row->id.'-'.$frame_id]) @php - $caption = nl2br($group_row->caption); - $caption = str_ireplace('[[upload_max_filesize]]', ini_get('upload_max_filesize'), $caption); + $caption = \App\Plugins\User\Forms\FormsUploadHelper::replaceUploadMaxFilesize($group_row->caption, $group_row); @endphp <div class="small {{ $group_row->caption_color }}">{!! $caption !!}</div> </div> @endforeach </div> @php - $caption = nl2br($form_column->caption); - $caption = str_ireplace('[[upload_max_filesize]]', ini_get('upload_max_filesize'), $caption); + $caption = \App\Plugins\User\Forms\FormsUploadHelper::replaceUploadMaxFilesize($form_column->caption, $form_column); @endphp <div class="small {{ $form_column->caption_color }}">{!! $caption !!}</div> </div> @@ -140,8 +138,7 @@ <div class="col-sm"> @include('plugins.user.forms.default.forms_input_' . $form_column->column_type, ['form_obj' => $form_column, 'label_id' => 'column-'.$form_column->id.'-'.$frame_id]) @php - $caption = nl2br($form_column->caption); - $caption = str_ireplace('[[upload_max_filesize]]', ini_get('upload_max_filesize'), $caption); + $caption = \App\Plugins\User\Forms\FormsUploadHelper::replaceUploadMaxFilesize($form_column->caption, $form_column); @endphp <div class="small {{ $form_column->caption_color }}">{!! $caption !!}</div> </div>
resources/views/plugins/user/forms/default/forms_edit_row_detail.blade.php+155 −0 modified@@ -71,9 +71,51 @@ function submit_update_column_detail() { form_column_detail.submit(); } + /** + * ファイル拡張子の分類単位で一括チェック/一括解除する + */ + function toggleFileExtensionGroup(group_index, trigger) { + const checked = !!(trigger && trigger.checked); + const selector = 'input[name="rule_file_extensions[]"][data-extension-group="' + group_index + '"]'; + document.querySelectorAll(selector).forEach(function (checkbox) { + checkbox.checked = checked; + }); + syncFileExtensionGroupMaster(group_index); + } + + /** + * ファイル拡張子の分類単位で「全てチェック」状態を同期する + */ + function syncFileExtensionGroupMaster(group_index) { + const master = document.getElementById('rule_file_extensions_group_check_' + group_index); + if (!master) { + return; + } + + const selector = 'input[name="rule_file_extensions[]"][data-extension-group="' + group_index + '"]'; + const children = Array.from(document.querySelectorAll(selector)); + if (children.length === 0) { + master.checked = false; + return; + } + + master.checked = children.every(function (checkbox) { + return checkbox.checked; + }); + } + $(function () { // ツールチップ有効化 $('[data-toggle="tooltip"]').tooltip() + + // 初期表示時に「全てチェック」状態を同期 + const group_indexes = new Set(); + document.querySelectorAll('input[name="rule_file_extensions[]"][data-extension-group]').forEach(function (checkbox) { + group_indexes.add(checkbox.getAttribute('data-extension-group')); + }); + group_indexes.forEach(function (group_index) { + syncFileExtensionGroupMaster(group_index); + }); }) </script> @@ -430,6 +472,119 @@ class="btn btn-danger cc-font-90 text-nowrap" <br> @endif + @if ($column->column_type == FormColumnType::file) + @php + $file_extension_options = \App\Plugins\User\Forms\FormsUploadHelper::normalizeExtensions( + config('forms.upload.allowed_extensions', []) + ); + $selected_extensions = \App\Plugins\User\Forms\FormsUploadHelper::resolveSelectedExtensionsForEdit( + old('rule_file_extensions'), + old('rule_file_extensions_submitted'), + $column->rule_file_extensions, + $file_extension_options + ); + $categorized_extension_groups = \App\Plugins\User\Forms\FormsUploadHelper::buildCategorizedExtensionGroups( + $file_extension_options, + config('forms.upload.extension_categories', []) + ); + $php_upload_max_filesize = \App\Plugins\User\Forms\FormsUploadHelper::getPhpUploadMaxFilesizeCaption(); + $max_size_options = \App\Plugins\User\Forms\FormsUploadHelper::buildMaxSizeOptions( + \App\Plugins\User\Forms\FormsUploadHelper::getPhpUploadMaxKb() + ); + $selected_file_max_kb = \App\Plugins\User\Forms\FormsUploadHelper::normalizeSelectedFileMaxKb( + old('rule_file_max_kb', $column->rule_file_max_kb) + ); + @endphp + + <div class="card"> + <h5 class="card-header">ファイルアップロード設定</h5> + <div class="card-body"> + {{-- 許可拡張子 --}} + <div class="form-group row"> + <label class="{{$frame->getSettingLabelClass()}}">許可拡張子 <span class="badge badge-danger">必須</span></label> + <div class="{{$frame->getSettingInputClass()}}"> + <input type="hidden" name="rule_file_extensions_submitted" value="1"> + @foreach ($categorized_extension_groups as $group_index => $extension_group) + <div class="border rounded p-2 mb-2 bg-light"> + <div class="d-flex flex-wrap justify-content-between align-items-center"> + <div class="font-weight-bold">{{$extension_group['label']}}</div> + <div> + <div class="custom-control custom-checkbox custom-control-inline"> + <input + type="checkbox" + id="rule_file_extensions_group_check_{{$group_index}}" + class="custom-control-input" + onchange="toggleFileExtensionGroup({{$group_index}}, this)" + > + <label class="custom-control-label small" for="rule_file_extensions_group_check_{{$group_index}}">全てチェック</label> + </div> + </div> + </div> + @if (! empty($extension_group['description'])) + <small class="text-muted d-block mt-1">{{$extension_group['description']}}</small> + @endif + <div class="row mt-2"> + @foreach ($extension_group['extensions'] as $extension) + @php + $checkbox_id = 'rule_file_extensions_' . $group_index . '_' . $extension; + @endphp + <div class="col-md-3 col-sm-4 col-6"> + <div class="custom-control custom-checkbox"> + <input + type="checkbox" + name="rule_file_extensions[]" + id="{{$checkbox_id}}" + value="{{$extension}}" + class="custom-control-input" + data-extension-group="{{$group_index}}" + onchange="syncFileExtensionGroupMaster({{$group_index}})" + @if (in_array($extension, $selected_extensions, true)) checked @endif + > + <label class="custom-control-label" for="{{$checkbox_id}}">.{{$extension}}</label> + </div> + </div> + @endforeach + </div> + </div> + @endforeach + @if ($errors && $errors->has('rule_file_extensions')) <div class="text-danger">{{$errors->first('rule_file_extensions')}}</div> @endif + </div> + </div> + + {{-- 最大ファイルサイズ --}} + <div class="form-group row"> + <label class="{{$frame->getSettingLabelClass()}}">最大ファイルサイズ</label> + <div class="{{$frame->getSettingInputClass()}}"> + <select name="rule_file_max_kb" class="form-control"> + <option value=""> + @if (! empty($php_upload_max_filesize)) + サーバ設定を使用({{$php_upload_max_filesize}}) + @else + サーバ設定を使用 + @endif + </option> + @foreach ($max_size_options as $size_kb => $size_label) + <option value="{{$size_kb}}" @if ((string) $size_kb === $selected_file_max_kb) selected @endif> + {{$size_label}} + </option> + @endforeach + </select> + <small id="upload-size-help" class="text-muted d-block">※ サーバの設定によるため、サイズを変更しても反映されない場合があります。</small> + @if (! empty($php_upload_max_filesize)) + <small id="upload-size-server-help" class="text-muted d-block">※ サーバ設定:アップロードできる最大サイズ <span class="font-weight-bold">{{$php_upload_max_filesize}}</span></small> + @endif + @if ($errors && $errors->has('rule_file_max_kb')) <div class="text-danger">{{$errors->first('rule_file_max_kb')}}</div> @endif + </div> + </div> + + {{-- ボタンエリア --}} + <div class="form-group text-center"> + <button onclick="javascript:submit_update_column_detail();" class="btn btn-primary form-horizontal"><i class="fas fa-check"></i> 更新</button> + </div> + </div> + </div> + <br> + @endif {{-- キャプション設定 --}} <div class="card" id="div_caption">
resources/views/plugins/user/forms/default/forms_input_file.blade.php+11 −1 modified@@ -1,5 +1,15 @@ {{-- * 登録画面(input file)テンプレート。 --}} -<input name="forms_columns_value[{{$form_obj->id}}]" type="{{$form_obj->column_type}}" id="{{$label_id}}"> +@php + $default_extensions = \App\Plugins\User\Forms\FormsUploadHelper::normalizeExtensions( + config('forms.upload.allowed_extensions', []) + ); + $allowed_extensions = \App\Plugins\User\Forms\FormsUploadHelper::resolveAllowedExtensions( + $default_extensions, + $form_obj->rule_file_extensions + ); + $accept_attr = \App\Plugins\User\Forms\FormsUploadHelper::toAcceptAttribute($allowed_extensions); +@endphp +<input name="forms_columns_value[{{$form_obj->id}}]" type="{{$form_obj->column_type}}" id="{{$label_id}}" @if ($accept_attr) accept="{{$accept_attr}}" @endif> @include('plugins.common.errors_inline', ['name' => "forms_columns_value.$form_obj->id"])
resources/views/plugins/user/forms/default/index_tandem.blade.php+3 −6 modified@@ -75,17 +75,15 @@ {{-- 項目 ※まとめ設定行 --}} @include('plugins.user.forms.default.forms_input_' . $group_row->column_type, ['form_obj' => $group_row, 'label_id' => 'column-'.$group_row->id.'-'.$frame_id]) @php - $caption = nl2br($group_row->caption); - $caption = str_ireplace('[[upload_max_filesize]]', ini_get('upload_max_filesize'), $caption); + $caption = \App\Plugins\User\Forms\FormsUploadHelper::replaceUploadMaxFilesize($group_row->caption, $group_row); @endphp <div class="small {{ $group_row->caption_color }}">{!! $caption !!}</div> </div> @php $no++; @endphp @endforeach </div> @php - $caption = nl2br($form_column->caption); - $caption = str_ireplace('[[upload_max_filesize]]', ini_get('upload_max_filesize'), $caption); + $caption = \App\Plugins\User\Forms\FormsUploadHelper::replaceUploadMaxFilesize($form_column->caption, $form_column); @endphp <div class="small {{ $form_column->caption_color }}">{!! $caption !!}</div> </div> @@ -109,8 +107,7 @@ <div class="col-12"> @include('plugins.user.forms.default.forms_input_' . $form_column->column_type, ['form_obj' => $form_column, 'label_id' => 'column-'.$form_column->id.'-'.$frame_id]) @php - $caption = nl2br($form_column->caption); - $caption = str_ireplace('[[upload_max_filesize]]', ini_get('upload_max_filesize'), $caption); + $caption = \App\Plugins\User\Forms\FormsUploadHelper::replaceUploadMaxFilesize($form_column->caption, $form_column); @endphp <div class="small {{ $form_column->caption_color }}">{!! $caption !!}</div> </div>
tests/Feature/Core/UploadFileResponseTest.php+74 −0 added@@ -0,0 +1,74 @@ +<?php + +namespace Tests\Feature\Core; + +use App\Models\Common\Uploads; +use App\Models\Core\Configs; +use App\Traits\ConnectCommonTrait; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; +use Tests\TestCase; + +/** + * アップロードファイル配信ヘッダの振る舞いを検証するFeatureテスト。 + */ +class UploadFileResponseTest extends TestCase +{ + use RefreshDatabase; + use ConnectCommonTrait; + + protected function setUp(): void + { + parent::setUp(); + $this->seed(); + } + + private function putUploadFile(Uploads $upload, string $content = 'dummy'): void + { + $path = $this->getDirectory($upload->id) . '/' . $upload->id . '.' . $upload->extension; + Storage::disk('local')->put($path, $content); + } + + /** + * /file/{id} では html が attachment で返ること(インライン禁止)。 + */ + public function testGetFileReturnsAttachmentForHtml(): void + { + $upload = Uploads::factory()->create([ + 'client_original_name' => 'sample.html', + 'mimetype' => 'text/html', + 'extension' => 'html', + 'plugin_name' => 'forms', + 'page_id' => 0, + 'temporary_flag' => 0, + ]); + + $this->putUploadFile($upload, '<html><body>test</body></html>'); + + $response = $this->get("/file/{$upload->id}"); + + $response->assertStatus(200); + $this->assertStringContainsString('attachment', (string) $response->headers->get('Content-Disposition')); + } + + /** + * /file/user/{dir}/{filename} では html のインライン表示を維持すること。 + */ + public function testGetUserFileKeepsInlineForHtml(): void + { + $dir = 'feature_userdir'; + $filename = 'sample.html'; + Storage::disk('user')->put($dir . '/' . $filename, '<html><body>test</body></html>'); + + Configs::create([ + 'category' => 'userdir_allow', + 'name' => $dir, + 'value' => 'allow_all', + ]); + + $response = $this->get("/file/user/{$dir}/{$filename}"); + + $response->assertStatus(200); + $this->assertStringContainsString('inline', (string) $response->headers->get('Content-Disposition')); + } +}
tests/Feature/Plugins/User/Forms/FormsUploadValidationTest.php+204 −0 added@@ -0,0 +1,204 @@ +<?php + +namespace Tests\Feature\Plugins\User\Forms; + +use App\Enums\FormColumnType; +use App\Models\Common\Buckets; +use App\Models\Common\Frame; +use App\Models\Common\Page; +use App\Models\Common\Uploads; +use App\Models\User\Forms\Forms; +use App\Models\User\Forms\FormsColumns; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Tests\TestCase; + +/** + * フォームのファイルアップロード制限を検証するFeatureテスト。 + */ +class FormsUploadValidationTest extends TestCase +{ + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + $this->seed(); + } + + /** + * テスト用フォーム(ファイル型カラム1つ)を作成する。 + */ + private function createFormSetup(): array + { + $page = Page::factory()->create(); + $bucket = Buckets::factory()->create(['plugin_name' => 'forms']); + $frame = Frame::factory()->create([ + 'page_id' => $page->id, + 'plugin_name' => 'forms', + 'bucket_id' => $bucket->id, + ]); + $form = Forms::factory()->create([ + 'bucket_id' => $bucket->id, + 'form_mode' => 'form', + 'data_save_flag' => 1, + ]); + $column = FormsColumns::factory()->create([ + 'forms_id' => $form->id, + 'column_type' => FormColumnType::file, + 'column_name' => '添付ファイル', + 'required' => 1, + 'display_sequence' => 1, + ]); + + return [$page, $frame, $column]; + } + + /** + * 許可された拡張子/MIME/サイズのファイルは確認画面へ進み、uploadsに保存されること。 + */ + public function testPublicConfirmAcceptsAllowedFileUpload(): void + { + [$page, $frame, $column] = $this->createFormSetup(); + + $file = UploadedFile::fake()->image('safe.jpg')->size(100); + + $response = $this->post( + "/plugin/forms/publicConfirm/{$page->id}/{$frame->id}", + [ + 'forms_columns_value' => [ + $column->id => $file, + ], + ] + ); + + $response->assertStatus(200); + $this->assertSame(1, Uploads::where('plugin_name', 'forms')->count()); + $this->assertDatabaseHas('uploads', [ + 'plugin_name' => 'forms', + 'extension' => 'jpg', + 'temporary_flag' => 1, + ]); + } + + /** + * 許可外拡張子は拒否され、uploadsに保存されないこと。 + */ + public function testPublicConfirmRejectsDisallowedExtension(): void + { + [$page, $frame, $column] = $this->createFormSetup(); + + $file = UploadedFile::fake()->create('attack.js', 10, 'application/javascript'); + + $response = $this->post( + "/plugin/forms/publicConfirm/{$page->id}/{$frame->id}", + [ + 'forms_columns_value' => [ + $column->id => $file, + ], + ] + ); + + $response->assertStatus(200); + $this->assertSame(0, Uploads::where('plugin_name', 'forms')->count()); + } + + /** + * 許可外MIMEタイプは拒否され、uploadsに保存されないこと。 + */ + public function testPublicConfirmRejectsDisallowedMimetype(): void + { + [$page, $frame, $column] = $this->createFormSetup(); + + $file = UploadedFile::fake()->create('mismatch.jpg', 10, 'text/html'); + + $response = $this->post( + "/plugin/forms/publicConfirm/{$page->id}/{$frame->id}", + [ + 'forms_columns_value' => [ + $column->id => $file, + ], + ] + ); + + $response->assertStatus(200); + $this->assertSame(0, Uploads::where('plugin_name', 'forms')->count()); + } + + /** + * 他拡張子で許可されるMIMEでも、拡張子との組み合わせが不一致なら拒否されること。 + */ + public function testPublicConfirmRejectsMimeTypeAllowedForDifferentExtension(): void + { + [$page, $frame, $column] = $this->createFormSetup(); + $column->rule_file_extensions = 'jpg,txt'; + $column->save(); + + $file = UploadedFile::fake()->create('mismatch.jpg', 10, 'text/plain'); + + $response = $this->post( + "/plugin/forms/publicConfirm/{$page->id}/{$frame->id}", + [ + 'forms_columns_value' => [ + $column->id => $file, + ], + ] + ); + + $response->assertStatus(200); + $this->assertSame(0, Uploads::where('plugin_name', 'forms')->count()); + } + + /** + * 許可サイズを超えるファイルは拒否され、uploadsに保存されないこと。 + */ + public function testPublicConfirmRejectsOverLimitSize(): void + { + [$page, $frame, $column] = $this->createFormSetup(); + $column->rule_file_max_kb = 10; + $column->save(); + + $file = UploadedFile::fake()->image('large.jpg')->size(11); + + $response = $this->post( + "/plugin/forms/publicConfirm/{$page->id}/{$frame->id}", + [ + 'forms_columns_value' => [ + $column->id => $file, + ], + ] + ); + + $response->assertStatus(200); + $this->assertSame(0, Uploads::where('plugin_name', 'forms')->count()); + } + + /** + * 最大サイズ未入力時はPHPのアップロード上限を使って拒否すること。 + */ + public function testPublicConfirmUsesPhpUploadLimitWhenColumnMaxIsEmpty(): void + { + [$page, $frame, $column] = $this->createFormSetup(); + $column->rule_file_max_kb = null; + $column->save(); + + $php_upload_max_kb = max(1, (int) floor(((float) UploadedFile::getMaxFilesize()) / 1024)); + if ($php_upload_max_kb > 8192) { + $this->markTestSkipped('PHP upload上限が大きいため、このテストをスキップします。'); + } + + $file = UploadedFile::fake()->image('too-large.jpg')->size($php_upload_max_kb + 1); + + $response = $this->post( + "/plugin/forms/publicConfirm/{$page->id}/{$frame->id}", + [ + 'forms_columns_value' => [ + $column->id => $file, + ], + ] + ); + + $response->assertStatus(200); + $this->assertSame(0, Uploads::where('plugin_name', 'forms')->count()); + } +}
tests/Unit/Plugins/User/Forms/FormsUploadHelperTest.php+118 −0 added@@ -0,0 +1,118 @@ +<?php + +namespace Tests\Unit\Plugins\User\Forms; + +use App\Enums\FormColumnType; +use App\Plugins\User\Forms\FormsUploadHelper; +use PHPUnit\Framework\TestCase; + +/** + * FormsUploadHelper のユニットテスト。 + */ +class FormsUploadHelperTest extends TestCase +{ + /** + * 文字列入力を区切り分解して拡張子を正規化できること。 + */ + public function testNormalizeExtensionsFromString(): void + { + $extensions = FormsUploadHelper::normalizeExtensions('.JPG, png,TXT jpg'); + + $this->assertSame(['jpg', 'png', 'txt'], $extensions); + } + + /** + * 項目設定値が既定許可外のみの場合は既定許可リストへフォールバックすること。 + */ + public function testResolveAllowedExtensionsFallsBackToDefaultWhenIntersectionIsEmpty(): void + { + $allowed_extensions = FormsUploadHelper::resolveAllowedExtensions(['jpg', 'png'], 'exe'); + + $this->assertSame(['jpg', 'png'], $allowed_extensions); + } + + /** + * accept属性文字列へ変換できること。 + */ + public function testToAcceptAttributeBuildsAcceptString(): void + { + $accept_attr = FormsUploadHelper::toAcceptAttribute(['jpg', '.PNG']); + + $this->assertSame('.jpg, .png', $accept_attr); + } + + /** + * ファイル項目で列設定がある場合は項目設定の最大サイズ表記へ置換できること。 + */ + public function testReplaceUploadMaxFilesizeUsesColumnSettingForFileColumn(): void + { + $form_column = new \stdClass(); + $form_column->column_type = FormColumnType::file; + $form_column->rule_file_max_kb = '2048'; + + $caption = FormsUploadHelper::replaceUploadMaxFilesize('最大: [[upload_max_filesize]]', $form_column); + + $this->assertSame('最大: 2M', $caption); + } + + /** + * 旧入力がない場合は列設定の拡張子を選択状態にすること。 + */ + public function testResolveSelectedExtensionsForEditUsesColumnSettingByDefault(): void + { + $selected_extensions = FormsUploadHelper::resolveSelectedExtensionsForEdit( + null, + null, + 'jpg,png', + ['jpg', 'png', 'pdf'] + ); + + $this->assertSame(['jpg', 'png'], $selected_extensions); + } + + /** + * バリデーションエラー後に未選択で再表示された場合は選択状態を維持すること。 + */ + public function testResolveSelectedExtensionsForEditKeepsEmptySelectionAfterSubmitted(): void + { + $selected_extensions = FormsUploadHelper::resolveSelectedExtensionsForEdit( + [], + '1', + 'jpg,png', + ['jpg', 'png'] + ); + + $this->assertSame([], $selected_extensions); + } + + /** + * 拡張子カテゴリ未所属の項目は「その他」グループへまとめること。 + */ + public function testBuildCategorizedExtensionGroupsAddsOthersGroup(): void + { + $categorized_extension_groups = FormsUploadHelper::buildCategorizedExtensionGroups( + ['jpg', 'png', 'pdf'], + [ + [ + 'label' => '画像', + 'description' => '画像カテゴリ', + 'extensions' => ['jpg', 'png'], + ], + ] + ); + + $this->assertSame('画像', $categorized_extension_groups[0]['label']); + $this->assertSame(['jpg', 'png'], $categorized_extension_groups[0]['extensions']); + $this->assertSame('その他', $categorized_extension_groups[1]['label']); + $this->assertSame(['pdf'], $categorized_extension_groups[1]['extensions']); + } + + /** + * 最大サイズ選択値をフォーム表示用に正規化できること。 + */ + public function testNormalizeSelectedFileMaxKb(): void + { + $this->assertSame('', FormsUploadHelper::normalizeSelectedFileMaxKb('')); + $this->assertSame('2048', FormsUploadHelper::normalizeSelectedFileMaxKb('2048')); + } +}
tests/Unit/Rules/CustomValiUploadRulesTest.php+163 −0 added@@ -0,0 +1,163 @@ +<?php + +namespace Tests\Unit\Rules; + +use App\Rules\CustomValiUploadExtensions; +use App\Rules\CustomValiUploadMimetypes; +use PHPUnit\Framework\TestCase; + +/** + * ファイルアップロード判定 Rule のユニットテスト + */ +class CustomValiUploadRulesTest extends TestCase +{ + /** + * 許可拡張子なら通ること + */ + public function testUploadExtensionsPassesWhenExtensionIsAllowed() + { + $rule = new CustomValiUploadExtensions(['jpg', 'png']); + + $file = new class { + public function getClientOriginalExtension() + { + return 'JPG'; + } + }; + + $this->assertTrue($rule->passes('file', $file)); + } + + /** + * 許可外拡張子なら弾くこと + */ + public function testUploadExtensionsFailsWhenExtensionIsDisallowed() + { + $rule = new CustomValiUploadExtensions(['jpg', 'png']); + + $file = new class { + public function getClientOriginalExtension() + { + return 'js'; + } + }; + + $this->assertFalse($rule->passes('file', $file)); + } + + /** + * MIME はサーバ側判定値を優先すること + */ + public function testUploadMimetypesUsesDetectedMimeTypeFirst() + { + $rule = new CustomValiUploadMimetypes([ + 'jpg' => ['image/jpeg'], + ], ['jpg']); + + $file = new class { + public function getClientOriginalExtension() + { + return 'jpg'; + } + public function getMimeType() + { + return 'text/html'; + } + public function getClientMimeType() + { + return 'image/jpeg'; + } + }; + + $this->assertFalse($rule->passes('file', $file)); + } + + /** + * 拡張子とサーバ側判定MIMEタイプが一致する場合は通ること + */ + public function testUploadMimetypesPassesWhenExtensionAndDetectedMimeTypeMatch() + { + $rule = new CustomValiUploadMimetypes([ + 'jpg' => ['image/jpeg'], + ], ['jpg']); + + $file = new class { + public function getClientOriginalExtension() + { + return 'jpg'; + } + public function getMimeType() + { + return 'image/jpeg'; + } + }; + + $this->assertTrue($rule->passes('file', $file)); + } + + /** + * 拡張子とMIMEタイプの組み合わせが不一致なら弾くこと + */ + public function testUploadMimetypesFailsWhenExtensionAndMimeTypeDoNotMatch() + { + $rule = new CustomValiUploadMimetypes([ + 'jpg' => ['image/jpeg'], + 'txt' => ['text/plain'], + ], ['jpg', 'txt']); + + $file = new class { + public function getClientOriginalExtension() + { + return 'jpg'; + } + public function getMimeType() + { + return 'text/plain'; + } + }; + + $this->assertFalse($rule->passes('file', $file)); + } + + /** + * サーバ側判定 MIME が空なら失敗すること(クライアント申告 MIME にはフォールバックしない) + */ + public function testUploadMimetypesFailsWhenDetectedMimeTypeIsEmpty() + { + $rule = new CustomValiUploadMimetypes([ + 'jpg' => ['image/jpeg'], + ], ['jpg']); + + $file = new class { + public function getClientOriginalExtension() + { + return 'jpg'; + } + public function getMimeType() + { + return ''; + } + public function getClientMimeType() + { + return 'image/jpeg'; + } + }; + + $this->assertFalse($rule->passes('file', $file)); + } + + /** + * エラーメッセージに許可拡張子の表示が含まれること + */ + public function testUploadMimetypesMessageContainsAllowedExtensions() + { + $rule = new CustomValiUploadMimetypes([ + 'jpg' => ['image/jpeg'], + 'png' => ['image/png'], + ], ['jpg', 'png']); + + $message = $rule->message(); + $this->assertStringContainsString('.jpg', $message); + $this->assertStringContainsString('.png', $message); + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-mv3p-7p89-wq9pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32278ghsaADVISORY
- github.com/opensource-workshop/connect-cms/commit/9d87fe8ecf7f57efbb0e5231be058807734c96b3ghsax_refsource_MISCWEB
- github.com/opensource-workshop/connect-cms/releases/tag/v1.41.1ghsax_refsource_MISCWEB
- github.com/opensource-workshop/connect-cms/releases/tag/v2.41.1ghsax_refsource_MISCWEB
- github.com/opensource-workshop/connect-cms/security/advisories/GHSA-mv3p-7p89-wq9pghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.