From be9003a38e5f1ddae7db31e166e14f000b16ab04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 03:51:48 +0000 Subject: [PATCH 01/13] chore(deps): bump qs and express Bumps [qs](https://github.com/ljharb/qs) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together. Updates `qs` from 6.14.0 to 6.14.2 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.14.0...v6.14.2) Updates `express` from 4.21.2 to 4.22.1 - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/v4.22.1/History.md) - [Commits](https://github.com/expressjs/express/compare/4.21.2...v4.22.1) --- updated-dependencies: - dependency-name: qs dependency-version: 6.14.2 dependency-type: indirect - dependency-name: express dependency-version: 4.22.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 61 ++++++++++++++++++----------------------------- 1 file changed, 23 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0e773a30..8391c7be2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5772,40 +5772,40 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -5835,22 +5835,6 @@ "dev": true, "license": "MIT" }, - "node_modules/express/node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -9753,9 +9737,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -10105,10 +10089,11 @@ } }, "node_modules/request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", "dev": true, + "license": "BSD-3-Clause", "optional": true, "engines": { "node": ">=0.6" From c04dc40f814eff891915752ef1ec00ba6612441c Mon Sep 17 00:00:00 2001 From: gakigaki <32890286+gakigaki@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:14:01 +0900 Subject: [PATCH 02/13] Fix: GHSA-cmfh-mpmf-fmq4 --- .../views/plugins/user/cabinets/default/index.blade.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/resources/views/plugins/user/cabinets/default/index.blade.php b/resources/views/plugins/user/cabinets/default/index.blade.php index 8772173b1..1e477ecf1 100644 --- a/resources/views/plugins/user/cabinets/default/index.blade.php +++ b/resources/views/plugins/user/cabinets/default/index.blade.php @@ -583,7 +583,12 @@ // 選択リストの更新 const selectedList = document.getElementById('selected-contents{{$frame_id}}'); if (selectedList) { - selectedList.innerHTML = this.selectedContents.map(name => `
  • ${name}
  • `).join(''); + selectedList.textContent = ''; + this.selectedContents.forEach((name) => { + const listItem = document.createElement('li'); + listItem.textContent = name; + selectedList.appendChild(listItem); + }); } // 全選択チェックボックスの更新 From 7c9951738c62a1d51b91e9956d1eb756c5d52cce Mon Sep 17 00:00:00 2001 From: gakigaki <32890286+gakigaki@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:14:57 +0900 Subject: [PATCH 03/13] Fix: GHSA-qr6x-wvxr-8hm9 --- .../Mypage/ProfileMypage/ProfileMypage.php | 14 ++-- .../mypage/profile/edit_form.blade.php | 2 +- .../Mypage/ProfileMypageUpdateTest.php | 80 +++++++++++++++++++ 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Mypage/ProfileMypageUpdateTest.php diff --git a/app/Plugins/Mypage/ProfileMypage/ProfileMypage.php b/app/Plugins/Mypage/ProfileMypage/ProfileMypage.php index 7c68dc328..1c4f158a2 100644 --- a/app/Plugins/Mypage/ProfileMypage/ProfileMypage.php +++ b/app/Plugins/Mypage/ProfileMypage/ProfileMypage.php @@ -12,7 +12,6 @@ use App\Plugins\Mypage\MypagePluginBase; use App\Rules\CustomValiLoginIdAndPasswordDoNotMatch; use App\Rules\CustomValiUserEmailUnique; -use App\User; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; @@ -62,7 +61,6 @@ public function index($request, $id = null) 'themes' => $request->themes, "function" => __FUNCTION__, "plugin_name" => "profile", - "id" => $user->id, "user" => $user, "users_columns" => $users_columns, "users_columns_id_select" => $users_columns_id_select, @@ -75,9 +73,11 @@ public function index($request, $id = null) /** * 更新 */ - public function update($request, $id) + public function update($request, $id = null) { - $user = User::where('id', $id)->first(); + // URLのidではなく、ログインユーザー自身のみを更新対象にする。 + $user = Auth::user(); + $user_id = $user->id; // ユーザーのカラム $users_columns_all = UsersTool::getUsersColumns($user->columns_set_id); @@ -100,11 +100,11 @@ public function update($request, $id) $base_rules = ['required', 'string', 'max:255']; $validator_array['column']['name'] = UsersTool::getDefaultColumnAdditionalRules($base_rules, $users_column); } elseif ($users_column->column_type == UserColumnType::login_id) { - $base_rules = ['required', 'max:255', Rule::unique('users', 'userid')->ignore($id)]; + $base_rules = ['required', 'max:255', Rule::unique('users', 'userid')->ignore($user_id)]; $validator_array['column']['userid'] = UsersTool::getDefaultColumnAdditionalRules($base_rules, $users_column); } elseif ($users_column->column_type == UserColumnType::user_email) { // $validator_array['column']['email'] = ['nullable', 'email', 'max:255', Rule::unique('users')->ignore($id)]; - $base_rules = ['email', 'max:255', new CustomValiUserEmailUnique($request->columns_set_id, $id)]; + $base_rules = ['email', 'max:255', new CustomValiUserEmailUnique($user->columns_set_id, $user_id)]; if ($users_column->required) { array_unshift($base_rules, 'required'); } else { @@ -146,7 +146,7 @@ function ($attribute, $value, $fail) { // チェックしない } else { // バリデータールールをセット - $validator_array = UsersTool::getValidatorRule($validator_array, $users_column, $user->columns_set_id, $id); + $validator_array = UsersTool::getValidatorRule($validator_array, $users_column, $user->columns_set_id, $user_id); } } diff --git a/resources/views/plugins/mypage/profile/edit_form.blade.php b/resources/views/plugins/mypage/profile/edit_form.blade.php index a84b9be26..67acfa08f 100644 --- a/resources/views/plugins/mypage/profile/edit_form.blade.php +++ b/resources/views/plugins/mypage/profile/edit_form.blade.php @@ -16,7 +16,7 @@ {{-- 登録後メッセージ表示 --}} @include('plugins.common.flash_message') -
    + {{ csrf_field() }} @foreach($users_columns as $column) diff --git a/tests/Feature/Mypage/ProfileMypageUpdateTest.php b/tests/Feature/Mypage/ProfileMypageUpdateTest.php new file mode 100644 index 000000000..7d1a09b11 --- /dev/null +++ b/tests/Feature/Mypage/ProfileMypageUpdateTest.php @@ -0,0 +1,80 @@ +seed(); + } + + /** + * URL引数のIDを指定しても、ログインユーザー以外のプロフィールは更新できない。 + */ + public function testProfileUpdatePathIdCannotUpdateAnotherUser(): void + { + $attacker = User::factory()->create([ + 'name' => 'attacker', + 'userid' => 'attacker-userid', + 'email' => 'attacker@example.com', + 'columns_set_id' => 1, + ]); + + $victim = User::factory()->create([ + 'name' => 'victim', + 'userid' => 'victim-userid', + 'email' => 'victim@example.com', + 'columns_set_id' => 1, + ]); + + $response = $this->actingAs($attacker)->post("/mypage/profile/update/{$victim->id}", [ + 'name' => $attacker->name, + 'userid' => $attacker->userid, + 'email' => 'attacker-updated@example.com', + ]); + + $response->assertStatus(302); + $response->assertRedirect(url('/mypage/profile')); + $response->assertSessionHas('flash_message', '更新しました。'); + + $this->assertSame('attacker-updated@example.com', $attacker->fresh()->email); + $this->assertSame('victim@example.com', $victim->fresh()->email); + } + + /** + * フォーム送信先(IDなし)でもログインユーザーのプロフィールを更新できる。 + */ + public function testProfileUpdateWithoutPathIdUpdatesLoggedInUser(): void + { + $user = User::factory()->create([ + 'name' => 'self-user', + 'userid' => 'self-userid', + 'email' => 'self@example.com', + 'columns_set_id' => 1, + ]); + + $response = $this->actingAs($user)->post('/mypage/profile/update', [ + 'name' => $user->name, + 'userid' => $user->userid, + 'email' => 'self-updated@example.com', + ]); + + $response->assertStatus(302); + $response->assertRedirect(url('/mypage/profile')); + $response->assertSessionHas('flash_message', '更新しました。'); + + $this->assertSame('self-updated@example.com', $user->fresh()->email); + } +} From 9d87fe8ecf7f57efbb0e5231be058807734c96b3 Mon Sep 17 00:00:00 2001 From: gakigaki <32890286+gakigaki@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:40:18 +0900 Subject: [PATCH 04/13] Fix: GHSA-mv3p-7p89-wq9p --- .../Controllers/Core/UploadController.php | 1 - app/Models/User/Forms/FormsColumns.php | 2 +- app/Plugins/User/Forms/FormsPlugin.php | 221 ++++++++++++- app/Plugins/User/Forms/FormsUploadHelper.php | 293 ++++++++++++++++++ app/Rules/CustomValiUploadExtensions.php | 54 ++++ app/Rules/CustomValiUploadMimetypes.php | 127 ++++++++ config/forms.php | 160 ++++++++++ .../User/Forms/FormsColumnsFactory.php | 2 + ...le_upload_rules_to_forms_columns_table.php | 37 +++ .../user/forms/default/forms.blade.php | 9 +- .../default/forms_edit_row_detail.blade.php | 155 +++++++++ .../forms/default/forms_input_file.blade.php | 12 +- .../user/forms/default/index_tandem.blade.php | 9 +- tests/Feature/Core/UploadFileResponseTest.php | 74 +++++ .../User/Forms/FormsUploadValidationTest.php | 204 ++++++++++++ .../User/Forms/FormsUploadHelperTest.php | 118 +++++++ .../Unit/Rules/CustomValiUploadRulesTest.php | 163 ++++++++++ 17 files changed, 1621 insertions(+), 20 deletions(-) create mode 100644 app/Plugins/User/Forms/FormsUploadHelper.php create mode 100644 app/Rules/CustomValiUploadExtensions.php create mode 100644 app/Rules/CustomValiUploadMimetypes.php create mode 100644 config/forms.php create mode 100644 database/migrations/2026_02_17_000000_add_file_upload_rules_to_forms_columns_table.php create mode 100644 tests/Feature/Core/UploadFileResponseTest.php create mode 100644 tests/Feature/Plugins/User/Forms/FormsUploadValidationTest.php create mode 100644 tests/Unit/Plugins/User/Forms/FormsUploadHelperTest.php create mode 100644 tests/Unit/Rules/CustomValiUploadRulesTest.php diff --git a/app/Http/Controllers/Core/UploadController.php b/app/Http/Controllers/Core/UploadController.php index aeea71df8..c3c8b1cda 100644 --- a/app/Http/Controllers/Core/UploadController.php +++ b/app/Http/Controllers/Core/UploadController.php @@ -162,7 +162,6 @@ public function getFile(Request $request, $id = null) 'jpe', 'jpeg', 'gif', - 'html', ]; // サムネイル指定の場合は、キャッシュを使ってファイルを返す。 diff --git a/app/Models/User/Forms/FormsColumns.php b/app/Models/User/Forms/FormsColumns.php index 7b27b806d..a50f9737d 100644 --- a/app/Models/User/Forms/FormsColumns.php +++ b/app/Models/User/Forms/FormsColumns.php @@ -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']; /** * ファイルタイプのカラム型か diff --git a/app/Plugins/User/Forms/FormsPlugin.php b/app/Plugins/User/Forms/FormsPlugin.php index fb273ad39..e08af0482 100644 --- a/app/Plugins/User/Forms/FormsPlugin.php +++ b/app/Plugins/User/Forms/FormsPlugin.php @@ -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 + */ + private function getFormsUploadAllowedExtensions(): array + { + $extensions = config('forms.upload.allowed_extensions', []); + return FormsUploadHelper::normalizeExtensions($extensions); + } + + /** + * 拡張子ごとの許可MIMEタイプ + * + * @return array> + */ + 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 + */ + 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 + */ + private function getFormsColumnUploadExtensions($forms_column): array + { + $default_extensions = $this->getFormsUploadAllowedExtensions(); + return FormsUploadHelper::resolveAllowedExtensions($default_extensions, $forms_column->rule_file_extensions ?? null); + } + + /** + * 項目設定を反映した拡張子ごとの許可MIMEタイプを取得 + * + * @return array> + */ + 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; diff --git a/app/Plugins/User/Forms/FormsUploadHelper.php b/app/Plugins/User/Forms/FormsUploadHelper.php new file mode 100644 index 000000000..5cb96e019 --- /dev/null +++ b/app/Plugins/User/Forms/FormsUploadHelper.php @@ -0,0 +1,293 @@ + + */ + 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 + */ + 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 $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 $file_extension_options + * @return array + */ + 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 $file_extension_options + * @param mixed $extension_categories + * @return array}> + */ + 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 + */ + 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; + } +} diff --git a/app/Rules/CustomValiUploadExtensions.php b/app/Rules/CustomValiUploadExtensions.php new file mode 100644 index 000000000..60590a0b7 --- /dev/null +++ b/app/Rules/CustomValiUploadExtensions.php @@ -0,0 +1,54 @@ + */ + private $allowed_extensions = []; + + /** + * @param array $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) . ' のうちいずれかの拡張子を指定してください。'; + } +} diff --git a/app/Rules/CustomValiUploadMimetypes.php b/app/Rules/CustomValiUploadMimetypes.php new file mode 100644 index 000000000..da0fd4a53 --- /dev/null +++ b/app/Rules/CustomValiUploadMimetypes.php @@ -0,0 +1,127 @@ +> */ + private $allowed_mimetype_map = []; + + /** @var array */ + private $allowed_extensions = []; + + /** + * @param array|string> $allowed_mimetype_map + * @param array $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> $allowed_mimetype_map + * @return array> + */ + 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のファイル形式が許可されていません。'; + } +} diff --git a/config/forms.php b/config/forms.php new file mode 100644 index 000000000..eb451fe95 --- /dev/null +++ b/config/forms.php @@ -0,0 +1,160 @@ + [ + // クライアント拡張子の許可リスト + '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'], + ], + ], +]; diff --git a/database/factories/User/Forms/FormsColumnsFactory.php b/database/factories/User/Forms/FormsColumnsFactory.php index 525ad8abf..5a0127085 100644 --- a/database/factories/User/Forms/FormsColumnsFactory.php +++ b/database/factories/User/Forms/FormsColumnsFactory.php @@ -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, ]; } diff --git a/database/migrations/2026_02_17_000000_add_file_upload_rules_to_forms_columns_table.php b/database/migrations/2026_02_17_000000_add_file_upload_rules_to_forms_columns_table.php new file mode 100644 index 000000000..3583136ef --- /dev/null +++ b/database/migrations/2026_02_17_000000_add_file_upload_rules_to_forms_columns_table.php @@ -0,0 +1,37 @@ +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'); + }); + } +} + diff --git a/resources/views/plugins/user/forms/default/forms.blade.php b/resources/views/plugins/user/forms/default/forms.blade.php index 12e844a6c..5dc59590a 100644 --- a/resources/views/plugins/user/forms/default/forms.blade.php +++ b/resources/views/plugins/user/forms/default/forms.blade.php @@ -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
    {!! $caption !!}
    @endforeach @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
    {!! $caption !!}
    @@ -140,8 +138,7 @@
    @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
    {!! $caption !!}
    diff --git a/resources/views/plugins/user/forms/default/forms_edit_row_detail.blade.php b/resources/views/plugins/user/forms/default/forms_edit_row_detail.blade.php index 77f9c04a7..763ca17b6 100644 --- a/resources/views/plugins/user/forms/default/forms_edit_row_detail.blade.php +++ b/resources/views/plugins/user/forms/default/forms_edit_row_detail.blade.php @@ -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); + }); }) @@ -430,6 +472,119 @@ class="btn btn-danger cc-font-90 text-nowrap"
    @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 + +
    +
    ファイルアップロード設定
    +
    + {{-- 許可拡張子 --}} +
    + +
    + + @foreach ($categorized_extension_groups as $group_index => $extension_group) +
    +
    +
    {{$extension_group['label']}}
    +
    +
    + + +
    +
    +
    + @if (! empty($extension_group['description'])) + {{$extension_group['description']}} + @endif +
    + @foreach ($extension_group['extensions'] as $extension) + @php + $checkbox_id = 'rule_file_extensions_' . $group_index . '_' . $extension; + @endphp +
    +
    + + +
    +
    + @endforeach +
    +
    + @endforeach + @if ($errors && $errors->has('rule_file_extensions'))
    {{$errors->first('rule_file_extensions')}}
    @endif +
    +
    + + {{-- 最大ファイルサイズ --}} +
    + +
    + + ※ サーバの設定によるため、サイズを変更しても反映されない場合があります。 + @if (! empty($php_upload_max_filesize)) + ※ サーバ設定:アップロードできる最大サイズ {{$php_upload_max_filesize}} + @endif + @if ($errors && $errors->has('rule_file_max_kb'))
    {{$errors->first('rule_file_max_kb')}}
    @endif +
    +
    + + {{-- ボタンエリア --}} +
    + +
    +
    +
    +
    + @endif {{-- キャプション設定 --}}
    diff --git a/resources/views/plugins/user/forms/default/forms_input_file.blade.php b/resources/views/plugins/user/forms/default/forms_input_file.blade.php index 5c191c77e..0800d3ff1 100644 --- a/resources/views/plugins/user/forms/default/forms_input_file.blade.php +++ b/resources/views/plugins/user/forms/default/forms_input_file.blade.php @@ -1,5 +1,15 @@ {{-- * 登録画面(input file)テンプレート。 --}} - +@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 + @include('plugins.common.errors_inline', ['name' => "forms_columns_value.$form_obj->id"]) diff --git a/resources/views/plugins/user/forms/default/index_tandem.blade.php b/resources/views/plugins/user/forms/default/index_tandem.blade.php index 7ca165e68..30b86688f 100644 --- a/resources/views/plugins/user/forms/default/index_tandem.blade.php +++ b/resources/views/plugins/user/forms/default/index_tandem.blade.php @@ -75,8 +75,7 @@ {{-- 項目 ※まとめ設定行 --}} @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
    {!! $caption !!}
    @@ -84,8 +83,7 @@ @endforeach @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
    {!! $caption !!}
    @@ -109,8 +107,7 @@
    @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
    {!! $caption !!}
    diff --git a/tests/Feature/Core/UploadFileResponseTest.php b/tests/Feature/Core/UploadFileResponseTest.php new file mode 100644 index 000000000..cbc67673b --- /dev/null +++ b/tests/Feature/Core/UploadFileResponseTest.php @@ -0,0 +1,74 @@ +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, 'test'); + + $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, 'test'); + + 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')); + } +} diff --git a/tests/Feature/Plugins/User/Forms/FormsUploadValidationTest.php b/tests/Feature/Plugins/User/Forms/FormsUploadValidationTest.php new file mode 100644 index 000000000..ae44089ee --- /dev/null +++ b/tests/Feature/Plugins/User/Forms/FormsUploadValidationTest.php @@ -0,0 +1,204 @@ +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()); + } +} diff --git a/tests/Unit/Plugins/User/Forms/FormsUploadHelperTest.php b/tests/Unit/Plugins/User/Forms/FormsUploadHelperTest.php new file mode 100644 index 000000000..4548bfc06 --- /dev/null +++ b/tests/Unit/Plugins/User/Forms/FormsUploadHelperTest.php @@ -0,0 +1,118 @@ +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')); + } +} diff --git a/tests/Unit/Rules/CustomValiUploadRulesTest.php b/tests/Unit/Rules/CustomValiUploadRulesTest.php new file mode 100644 index 000000000..d36d216a3 --- /dev/null +++ b/tests/Unit/Rules/CustomValiUploadRulesTest.php @@ -0,0 +1,163 @@ +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); + } +} From 8ef15cdc310fc784ae3755f49130da61ebba1bea Mon Sep 17 00:00:00 2001 From: gakigaki <32890286+gakigaki@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:41:00 +0900 Subject: [PATCH 05/13] Fix: GHSA-62ch-j6x7-722j --- .../Controllers/Core/DefaultController.php | 19 +++- app/Http/Middleware/ConnectPage.php | 91 ++++++++++++++----- 2 files changed, 85 insertions(+), 25 deletions(-) diff --git a/app/Http/Controllers/Core/DefaultController.php b/app/Http/Controllers/Core/DefaultController.php index adfd903bd..49e81ec4d 100644 --- a/app/Http/Controllers/Core/DefaultController.php +++ b/app/Http/Controllers/Core/DefaultController.php @@ -742,12 +742,22 @@ public function invokeGetJson(Request $request, $plugin_name, $action = null, $p // app\Http\Middleware\ConnectPage.php でセットした値 $page = $request->attributes->get('page'); $pages = $request->attributes->get('pages'); + $http_status_code = $request->attributes->get('http_status_code'); + + // 403/404 判定済みのリクエストは処理を継続させない。 + if ($http_status_code) { + abort($http_status_code); + } // アプリのロケールを変更 $this->setAppLocale($page); // プラグインのインスタンス生成 - $frame = Frame::find($frame_id); + $frame = $request->attributes->get('frame'); + if (empty($frame)) { + abort(404); + } + $class_name = $this->getClassName($frame->plugin_name); // $plugin_instance = new $class_name($this->page, $frame, $this->pages); $plugin_instance = new $class_name($page, $frame, $pages); @@ -920,6 +930,13 @@ public function invokePostRedirect(Request $request, $plugin_name, $action = nul */ public function invokePostDownload(Request $request, $plugin_name, $action = null, $page_id = null, $frame_id = null, $id = null) { + $http_status_code = $request->attributes->get('http_status_code'); + + // 403/404 判定済みのリクエストは処理を継続させない。 + if ($http_status_code) { + abort($http_status_code); + } + // プラグイン毎に動的にnew する。 // Todo:プラグインを動的にインスタンス生成すること。 diff --git a/app/Http/Middleware/ConnectPage.php b/app/Http/Middleware/ConnectPage.php index c9d1aa338..41c3056c0 100644 --- a/app/Http/Middleware/ConnectPage.php +++ b/app/Http/Middleware/ConnectPage.php @@ -31,6 +31,7 @@ class ConnectPage * ・pages * ・top_page * ・page_tree (pageがあれば) + * ・frame (routeにframe_idがある場合。frameが存在しなければnull) * ・http_status_code (403, 404エラー時で403,404ページを指定していた場合) * ・全ビュー間のデータ共有 * ・page_list @@ -49,10 +50,17 @@ public function handle($request, Closure $next) $router = app(Router::class); + // frame_id があれば先にフレームを取得し、同一リクエスト内で再利用する。 + $route_frame_id = $request->route('frame_id'); + if (!empty($route_frame_id)) { + $request->attributes->add(['frame' => Frame::find($route_frame_id)]); + } + // ページの特定 - if (!empty($request->page_id)) { + $route_page_id = $request->route('page_id'); + if (!empty($route_page_id)) { // ページID が渡ってきた場合 - $this->page = Page::where('id', $request->page_id)->first(); + $this->page = Page::where('id', $route_page_id)->first(); } else { // ページID が渡されなかった場合、URL から取得 $this->page = $this->getCurrentPage(); @@ -150,7 +158,7 @@ public function handle($request, Closure $next) // 現在のページが参照可能か判定して、NG なら403 ページを振り向ける。 // (ページがある(管理画面ではページがない)&IP制限がかかっていない場合は参照OK) // HTTP ステータスコード(null なら200) - $http_status_code = $this->checkPageForbidden($page_tree, $router); + $http_status_code = $this->checkPageForbidden($request, $page_tree, $router); if ($http_status_code) { // requestにセット $request->attributes->add(['http_status_code' => $http_status_code]); @@ -225,17 +233,8 @@ private function checkPageNotFound($request, $router) } // 対象となる処理は、画面を持つルートの処理とする。 - // bugfix: php artisan route:list 実行時「Call to a member function getName() on null」エラー対応 - // $route_name = $router->current()->getName(); $route_name = is_null($router->current()) ? null : $router->current()->getName(); - if ($route_name == 'get_plugin' || - $route_name == 'post_plugin' || - $route_name == 'post_redirect' || - $route_name == 'get_redirect' || - $route_name == 'get_all' || - $route_name == 'post_all') { - // 対象として次へ - } else { + if (!$this->isPageLimitCheckRoute($route_name)) { // 対象外の処理なので、戻る return; } @@ -383,22 +382,20 @@ private function getPage($permanent_link, $language = null) * * ($this->page 有り+チェックする $route_name なら、参照可否チェック) */ - private function checkPageForbidden($page_tree, $router) + private function checkPageForbidden($request, $page_tree, $router) { - // 対象となる処理は、画面を持つルートの処理とする。 - $route_name = $router->current()->getName(); - if ($route_name == 'get_plugin' || - $route_name == 'post_plugin' || - $route_name == 'post_redirect' || - $route_name == 'get_redirect' || - $route_name == 'get_all' || - $route_name == 'post_all') { - // 対象として次へ - } else { + // 対象となる処理は、ページ/フレームの情報を受け取るルートとする。 + $route_name = is_null($router->current()) ? null : $router->current()->getName(); + if (!$this->isPageLimitCheckRoute($route_name)) { // 対象外の処理なので、戻る return; } + // page_id と frame_id の組み合わせが不整合なら、不正アクセスとして 403 扱いにする。 + if (!$this->isValidPageAndFrame($request)) { + return $this->doForbidden(); + } + if ($this->page && get_class($this->page) == 'App\Models\Common\Page') { // 親子ページを加味してページ表示できるか $is_view = $this->page->isVisibleAncestorsAndSelf($page_tree); @@ -411,6 +408,52 @@ private function checkPageForbidden($page_tree, $router) return; } + /** + * ページ閲覧制限チェックの対象ルート判定 + */ + private function isPageLimitCheckRoute($route_name) + { + return in_array($route_name, [ + 'get_plugin', + 'post_plugin', + 'get_json', + 'post_json', + 'post_redirect', + 'get_redirect', + 'post_download', + 'get_download', + 'get_all', + 'post_all', + ], true); + } + + /** + * page_id と frame_id の整合性判定 + */ + private function isValidPageAndFrame($request) + { + $route_page_id = $request->route('page_id'); + $route_frame_id = $request->route('frame_id'); + + // frame_id がなければ判定不要 + if (empty($route_frame_id)) { + return true; + } + + // frame_id があるのに page_id がない場合は不正 + if (empty($route_page_id)) { + return false; + } + + // frameはhandle()で事前に取得済み + $frame = $request->attributes->get('frame'); + if (empty($frame)) { + return false; + } + + return ((int)$frame->page_id === (int)$route_page_id); + } + /** * 403 処理 * (ConnectController から移動してきた) From 617a874e14b8476da7c0760a06384b9da21bdd4f Mon Sep 17 00:00:00 2001 From: gakigaki <32890286+gakigaki@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:41:42 +0900 Subject: [PATCH 06/13] Fix: GHSA-jh46-85jr-6ph9 --- app/Plugins/Manage/PageManage/PageManage.php | 11 +- .../MigrationExportHtmlPageTrait.php | 230 +++++++++++++++++- .../Migration/MigrationExportNc3PageTrait.php | 16 ++ app/Utilities/Url/UrlUtils.php | 169 +++++++++++++ tests/Unit/Utilities/Url/UrlUtilsTest.php | 49 ++++ 5 files changed, 465 insertions(+), 10 deletions(-) create mode 100644 app/Utilities/Url/UrlUtils.php create mode 100644 tests/Unit/Utilities/Url/UrlUtilsTest.php diff --git a/app/Plugins/Manage/PageManage/PageManage.php b/app/Plugins/Manage/PageManage/PageManage.php index 3c85ffbd5..0663713c1 100644 --- a/app/Plugins/Manage/PageManage/PageManage.php +++ b/app/Plugins/Manage/PageManage/PageManage.php @@ -22,6 +22,7 @@ use App\User; use App\Utilities\Csv\CsvUtils; use App\Utilities\String\StringUtils; +use App\Utilities\Url\UrlUtils; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -1040,7 +1041,15 @@ public function migrationGet($request, $page_id) // 項目のエラーチェック $validator = Validator::make($request->all(), [ 'source_system' => 'required', - 'url' => 'required', + 'url' => [ + 'required', + 'url', + function ($attribute, $value, $fail) { + if (!UrlUtils::isGlobalHttpUrl((string) $value)) { + $fail('移行元URLには、グローバルな http/https URL(プライベート/予約アドレス以外)を指定してください。'); + } + }, + ], 'destination_page_id' => 'required', ]); $validator->setAttributeNames([ diff --git a/app/Traits/Migration/MigrationExportHtmlPageTrait.php b/app/Traits/Migration/MigrationExportHtmlPageTrait.php index 00c97bf6f..994db3764 100644 --- a/app/Traits/Migration/MigrationExportHtmlPageTrait.php +++ b/app/Traits/Migration/MigrationExportHtmlPageTrait.php @@ -2,11 +2,15 @@ namespace App\Traits\Migration; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\UriResolver; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Log; use App\Utilities\Migration\MigrationUtils; -use App\Utilities\Curl\CurlUtils; +use App\Utilities\Url\UrlUtils; /** * 1つのウェブページからデータをエクスポート(想定形式:HTML) @@ -34,22 +38,32 @@ trait MigrationExportHtmlPageTrait */ private function migrationHtmlPage(string $url, int $page_id) : void { + if (!UrlUtils::isGlobalHttpUrl($url)) { + Log::warning('[migrationHtmlPage] Rejected non-global URL: ' . $url); + return; + } + // マイグレーション用のディレクトリに$page_idのディレクトリが存在する場合は削除する if (Storage::exists("migration/import/pages/" . $page_id)) { // 指定されたディレクトリを削除 Storage::deleteDirectory("migration/import/pages/" . $page_id); } - // $urlからルートURLとディレクトリまでのURLをそれぞれ抽出する - $root_url = $this->extractRootURL($url); - $target_dir_url = $this->extractUrlDirectory($url); - // 画像ファイルや添付ファイルを取得する場合のテンポラリ・ディレクトリ Storage::makeDirectory('migration/import/pages/' . $page_id); - // 指定されたページのHTML を取得 - $result_array = CurlUtils::execute($url); + // 指定されたページのHTML を取得(リダイレクト先URLも都度検証する) + $result_array = $this->executeMigrationHtmlRequest($url); + if ($result_array === null) { + return; + } + $html = $result_array['body']; + $effective_url = $result_array['effective_url']; + + // 実際に取得したURLからルートURLとディレクトリまでのURLを抽出する + $root_url = $this->extractRootURL($effective_url); + $target_dir_url = $this->extractUrlDirectory($effective_url); // HTMLドキュメントの解析準備 $dom = new \DOMDocument; @@ -96,7 +110,7 @@ private function migrationHtmlPage(string $url, int $page_id) : void $frame_ini .= "source_key = \"" . 'xxxxx' . "\"\n"; $frame_ini .= "target_source_table = \"announcement\"\n"; - + // 画像ファイルの抽出 ※抽出ファイルがない場合はfalseが返る $image_paths = MigrationUtils::getContentImage($content_html); @@ -120,6 +134,11 @@ private function migrationHtmlPage(string $url, int $page_id) : void } Log::debug('download_img_path: ' . $download_img_path); + if (!UrlUtils::isGlobalHttpUrl((string) $download_img_path)) { + Log::warning('[migrationHtmlPage] Skip non-global resource URL: ' . $download_img_path); + continue; + } + $file_name = "frame_0001_" . $image_index; $save_path = 'migration/import/pages/' . $page_id . "/" . $file_name; $save_storage_path = storage_path() . '/app/' . $save_path; @@ -173,7 +192,7 @@ private function migrationHtmlPage(string $url, int $page_id) : void // ファイルが存在する、且つ、拡張子が取得できた場合、ファイル名に拡張子を付与して保存 if (Storage::exists($save_path) && $img_extension) { Storage::move($save_path, $save_path . '.' . $img_extension); - + // 画像の設定情報の記載 $frame_ini .= $file_name . '.' . $img_extension . ' = ""' . PHP_EOL; @@ -197,6 +216,199 @@ private function migrationHtmlPage(string $url, int $page_id) : void Storage::put('migration/import/pages/' . $page_id . "/frame_0001.html", trim($content_html)); } + /** + * ページのHTMLを取得する(リダイレクト時は遷移先URLを都度検証) + * + * @param string $url + * @return array|null ['body' => string, 'effective_url' => string] + */ + private function executeMigrationHtmlRequest(string $url): ?array + { + $current_url = $url; + $max_redirects = 5; + $http_client = $this->createMigrationHttpClient(); + + for ($redirect_count = 0; $redirect_count <= $max_redirects; $redirect_count++) { + $response = $this->executeSingleMigrationRequest($http_client, $current_url); + + if (!$this->isRedirectHttpCode($response['http_code'])) { + return [ + 'body' => $response['body'], + 'effective_url' => $current_url, + ]; + } + + if ($redirect_count === $max_redirects) { + Log::warning('[migrationHtmlPage] Too many redirects: ' . $url); + return null; + } + + $redirect_url = $this->buildRedirectUrl($current_url, $response['location']); + if ($redirect_url === '') { + Log::warning('[migrationHtmlPage] Invalid redirect URL. Source URL: ' . $current_url . ' Location: ' . $response['location']); + return null; + } + + if (!UrlUtils::isGlobalHttpUrl($redirect_url)) { + Log::warning('[migrationHtmlPage] Rejected redirect destination URL: ' . $redirect_url); + return null; + } + + $current_url = $redirect_url; + } + + return null; + } + + /** + * 単一URLへHTTPリクエストを実行する(自動リダイレクト追跡なし) + * + * @param Client $http_client + * @param string $url + * @return array ['body' => string, 'http_code' => int, 'location' => string] + */ + private function executeSingleMigrationRequest(Client $http_client, string $url): array + { + if (!UrlUtils::isGlobalHttpUrl($url)) { + Log::warning('[migrationHtmlPage] Rejected non-global URL: ' . $url); + throw new \RuntimeException('[migrationHtmlPage] Rejected non-global URL: ' . $url); + } + + try { + $response = $http_client->request('GET', $url); + } catch (GuzzleException $e) { + $error_message = "HTTP [GET] {$url} : failed. " . $e->getMessage(); + Log::error($error_message); + throw new \RuntimeException($error_message, 0, $e); + } + + $body = (string) $response->getBody(); + $http_code = (int) $response->getStatusCode(); + $location = trim($response->getHeaderLine('Location')); + + return [ + 'body' => $body, + 'http_code' => $http_code, + 'location' => $location, + ]; + } + + /** + * HTTPステータスコードがリダイレクトかどうかを判定する + * + * @param int $http_code + * @return bool + */ + private function isRedirectHttpCode(int $http_code): bool + { + return in_array($http_code, [301, 302, 303, 307, 308], true); + } + + /** + * マイグレーションHTML取得用のHTTPクライアントを生成する + * + * @return Client + */ + private function createMigrationHttpClient(): Client + { + $http_client_options = [ + 'http_errors' => false, + 'allow_redirects' => false, + ]; + + $timeout = config('connect.CURL_TIMEOUT'); + if (!empty($timeout)) { + $http_client_options['timeout'] = (float) $timeout; + } + + if (config('connect.HTTPPROXYTUNNEL')) { + $proxy = $this->buildMigrationProxyOption(); + if ($proxy !== null) { + $http_client_options['proxy'] = $proxy; + } + } + + return new Client($http_client_options); + } + + /** + * connect設定からGuzzle用のプロキシURLを生成する + * + * @return string|null + */ + private function buildMigrationProxyOption(): ?string + { + $proxy = trim((string) config('connect.PROXY')); + if ($proxy === '') { + return null; + } + + if (strpos($proxy, '://') === false) { + $proxy = 'http://' . $proxy; + } + + $proxy_parts = parse_url($proxy); + if ($proxy_parts === false || !isset($proxy_parts['host'])) { + return null; + } + + $scheme = $proxy_parts['scheme'] ?? 'http'; + $host = $proxy_parts['host']; + if (strpos($host, ':') !== false && strpos($host, '[') !== 0) { + $host = '[' . $host . ']'; + } + + $port = $proxy_parts['port'] ?? null; + $config_proxy_port = trim((string) config('connect.PROXYPORT')); + if ($config_proxy_port !== '') { + $port = $config_proxy_port; + } + + $user = $proxy_parts['user'] ?? null; + $pass = $proxy_parts['pass'] ?? null; + $proxy_user_pwd = trim((string) config('connect.PROXYUSERPWD')); + if ($proxy_user_pwd !== '') { + list($user, $pass) = array_pad(explode(':', $proxy_user_pwd, 2), 2, ''); + } + + $auth = ''; + if (!empty($user)) { + $auth = rawurlencode($user); + if (!empty($pass)) { + $auth .= ':' . rawurlencode($pass); + } + $auth .= '@'; + } + + $proxy_url = $scheme . '://' . $auth . $host; + if (!empty($port)) { + $proxy_url .= ':' . $port; + } + + return $proxy_url; + } + + /** + * レスポンスLocationヘッダーを絶対URLへ解決する + * + * @param string $current_url + * @param string $location + * @return string + */ + private function buildRedirectUrl(string $current_url, string $location): string + { + $location = trim($location); + if ($location === '') { + return ''; + } + + try { + return (string) UriResolver::resolve(new Uri($current_url), new Uri($location)); + } catch (\InvalidArgumentException $e) { + return ''; + } + } + /** * nodeをHTMLとして取り出す */ diff --git a/app/Traits/Migration/MigrationExportNc3PageTrait.php b/app/Traits/Migration/MigrationExportNc3PageTrait.php index 50e0cdb21..6a9cc3e4b 100644 --- a/app/Traits/Migration/MigrationExportNc3PageTrait.php +++ b/app/Traits/Migration/MigrationExportNc3PageTrait.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Storage; use App\Utilities\Migration\MigrationUtils; +use App\Utilities\Url\UrlUtils; /** * NC3 の1つのウェブページからデータをエクスポート @@ -30,6 +31,11 @@ trait MigrationExportNc3PageTrait */ private function migrationNC3Page($url, $page_id) { + if (!UrlUtils::isGlobalHttpUrl((string) $url)) { + Log::warning('[migrationNC3Page] Rejected non-global URL: ' . $url); + return; + } + /* ページ移行関数呼び出し(URL, 移行先のページid) @@ -136,6 +142,11 @@ private function migrationNC3Page($url, $page_id) $image_index++; $downloadPath = $image_url; + if (!UrlUtils::isGlobalHttpUrl((string) $downloadPath)) { + Log::warning('[migrationNC3Page] Skip non-global image URL: ' . $downloadPath); + continue; + } + $file_name = "frame_" . $frame_index_str . '_' . $image_index; $savePath = 'migration/import/pages/' . $page_id . "/" . $file_name; $saveStragePath = storage_path() . '/app/' . $savePath; @@ -207,6 +218,11 @@ private function migrationNC3Page($url, $page_id) $file_index++; $downloadPath = $anchor_href; + if (!UrlUtils::isGlobalHttpUrl((string) $downloadPath)) { + Log::warning('[migrationNC3Page] Skip non-global file URL: ' . $downloadPath); + continue; + } + $file_name = "frame_" . $frame_index_str . '_file_' . $file_index; $savePath = 'migration/import/pages/' . $page_id . "/" . $file_name; $saveStragePath = storage_path() . '/app/' . $savePath; diff --git a/app/Utilities/Url/UrlUtils.php b/app/Utilities/Url/UrlUtils.php new file mode 100644 index 000000000..a53a8a5b1 --- /dev/null +++ b/app/Utilities/Url/UrlUtils.php @@ -0,0 +1,169 @@ +assertSame($expected, UrlUtils::isGlobalHttpUrl($url), $url); + } + + public function globalHttpUrlProvider(): array + { + return [ + 'allow_global_ipv4_http' => ['http://8.8.8.8', true], + 'allow_global_ipv4_https' => ['https://1.1.1.1/path', true], + 'allow_global_domain' => ['https://example.com', true], + 'deny_non_http_scheme' => ['ftp://8.8.8.8', false], + 'deny_localhost' => ['http://localhost', false], + 'deny_localhost_subdomain' => ['http://foo.localhost', false], + 'deny_localhost_trailing_dot' => ['http://localhost./', false], + 'deny_loopback_ipv4' => ['http://127.0.0.1', false], + 'deny_private_ipv4' => ['http://10.0.0.1', false], + 'deny_private_172_ipv4' => ['http://172.16.0.1', false], + 'deny_private_192_ipv4' => ['http://192.168.1.1', false], + 'deny_unspecified_ipv4' => ['http://0.0.0.0', false], + 'deny_link_local_ipv4' => ['http://169.254.169.254/latest/meta-data', false], + 'deny_decimal_ipv4_notation' => ['http://2130706433', false], + 'deny_octal_ipv4_notation' => ['http://0177.0.0.1', false], + 'deny_loopback_ipv6' => ['http://[::1]/', false], + 'deny_private_ipv6_ula' => ['http://[fd00::1]/', false], + 'deny_ipv4_mapped_loopback_ipv6' => ['http://[::ffff:127.0.0.1]/', false], + 'deny_ipv4_mapped_link_local_ipv6' => ['http://[::ffff:169.254.169.254]/', false], + 'deny_ipv4_mapped_expanded_ipv6' => ['http://[0:0:0:0:0:ffff:7f00:1]/', false], + 'deny_empty_string' => ['', false], + 'deny_scheme_only' => ['http://', false], + 'deny_protocol_relative_url' => ['//example.com', false], + 'deny_data_scheme' => ['data:text/html,

    Hi

    ', false], + 'deny_file_scheme' => ['file:///etc/passwd', false], + 'deny_invalid_text' => ['not-a-url', false], + ]; + } +} From c0bcd07fc1e9375941aa1295d044328ecd44ed85 Mon Sep 17 00:00:00 2001 From: gakigaki <32890286+gakigaki@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:42:23 +0900 Subject: [PATCH 07/13] Fix: GHSA-hxqw-6qv7-cqfv --- app/Models/User/Codestudies/Codestudies.php | 17 - .../User/Codestudies/CodestudiesPlugin.php | 542 ------------------ app/Plugins/User/Codestudies/plugin.ini | 2 - config/connect.php | 2 +- ...26_02_17_000000_drop_codestudies_table.php | 41 ++ ...place_codestudies_frames_with_contents.php | 91 +++ ...000000_remove_codestudies_from_plugins.php | 29 + .../codestudies_frame_edit_tab.blade.php | 16 - .../codestudies/default/codestudies.blade.php | 178 ------ .../default/codestudies_forbidden.blade.php | 13 - .../codestudies/default/download.blade.php | 27 - tests/bin/connect-cms-test.bat | 2 +- 12 files changed, 163 insertions(+), 797 deletions(-) delete mode 100644 app/Models/User/Codestudies/Codestudies.php delete mode 100644 app/Plugins/User/Codestudies/CodestudiesPlugin.php delete mode 100644 app/Plugins/User/Codestudies/plugin.ini create mode 100644 database/migrations/2026_02_17_000000_drop_codestudies_table.php create mode 100644 database/migrations/2026_02_17_000000_replace_codestudies_frames_with_contents.php create mode 100644 database/migrations/2026_02_18_000000_remove_codestudies_from_plugins.php delete mode 100644 resources/views/plugins/user/codestudies/codestudies_frame_edit_tab.blade.php delete mode 100644 resources/views/plugins/user/codestudies/default/codestudies.blade.php delete mode 100644 resources/views/plugins/user/codestudies/default/codestudies_forbidden.blade.php delete mode 100644 resources/views/plugins/user/codestudies/default/download.blade.php diff --git a/app/Models/User/Codestudies/Codestudies.php b/app/Models/User/Codestudies/Codestudies.php deleted file mode 100644 index b00137fd1..000000000 --- a/app/Models/User/Codestudies/Codestudies.php +++ /dev/null @@ -1,17 +0,0 @@ - - * @copyright OpenSource-WorkShop Co.,Ltd. All Rights Reserved - * @category コードスタディプラグイン - * @package Contoroller - */ -class CodestudiesPlugin extends UserPluginBase -{ - - /* オブジェクト変数 */ - - /** - * 変更時のPOSTデータ - */ - public $post = null; - - /** - * 実行関数チェック - */ - private $run_check_msgs = null; - - /* コアから呼び出す関数 */ - - /** - * 関数定義(コアから呼び出す) - */ - public function getPublicFunctions() - { - // 標準関数以外で画面などから呼ばれる関数の定義 - $functions = array(); - $functions['get'] = ['editcode', 'viewDownload', 'download']; - $functions['post'] = ['savecode', 'run', 'deletecode']; - return $functions; - } - - /** - * 追加の権限定義(コアから呼び出す) - */ - public function declareRole() - { - // 標準権限以外で設定画面などから呼ばれる権限の定義 - // 標準権限は右記で定義 config/cc_role.php - // - // 権限チェックテーブル - // [TODO] 【各プラグイン】declareRoleファンクションで適切な追加の権限定義を設定する https://github.com/opensource-workshop/connect-cms/issues/658 - $role_ckeck_table = array(); - $role_ckeck_table["editcode"] = array('role_reporter'); - $role_ckeck_table["savecode"] = array('role_reporter'); - $role_ckeck_table["run"] = array('role_reporter'); - $role_ckeck_table["deletecode"] = array('role_reporter'); - $role_ckeck_table["download"] = array('role_arrangement'); - $role_ckeck_table["viewDownload"] = array('role_arrangement'); - return $role_ckeck_table; - } - - /** - * 使用言語のバージョン取得 - */ - private function getLangVersion() - { - $versions = array(); - // PHP - $versions['PHP'] = phpversion(); - // Java - $cmd = 'javac -encoding UTF-8 -version'; - exec("$cmd 2>&1", $result); - // バージョン取得ががうまくいった場合($result が空) - if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { - $versions['Java'] = mb_convert_encoding(implode('
    ', $result), "UTF-8", "sjis-win"); - } else { - $versions['Java'] = implode('
    ', $result); - } - - return $versions; - } - - /** - * POST取得関数(コアから呼び出す) - * コアがPOSTチェックの際に呼び出す関数 - */ - public function getPost($id) - { - - // 一度読んでいれば、そのPOSTを再利用する。 - if (!empty($this->post)) { - return $this->post; - } - - // コード取得 - $this->post = Codestudies::where('id', $id)->first(); - - return $this->post; - } - - /* 画面アクション関数 */ - - /** - * データ初期表示関数 - * コアがページ表示の際に呼び出す関数 - */ - public function index($request, $page_id, $frame_id, $errors = null) - { - // セッション初期化などのLaravel 処理。 - $request->flash(); - - // 認証されているユーザの取得 - $user = Auth::user(); - - // ログイン - if (empty($user) || empty($user->id)) { - // 認証エラーテンプレートを呼び出す。 - return $this->view( - 'codestudies_forbidden', [ - ] - ); - } - - // 自分の保存したプログラムを取得 - $codestudies = Codestudies::where('created_id', $user->id)->get(); - - // 画面で空を表示させるために、空のオブジェクトを生成 - $codestudy = new Codestudies(); - - // 表示テンプレートを呼び出す。 - return $this->view( - 'codestudies', [ - 'codestudies' => $codestudies, - 'codestudy' => $codestudy, - 'errors' => $errors, - 'versions' => $this->getLangVersion(), - ] - )->withInput($request->all); - } - - /** - * 編集画面 - */ - public function editcode($request, $page_id, $frame_id, $codestudy_id = null, $result = null, $error_flag = null, $errors = null) - { - // セッション初期化などのLaravel 処理。 - $request->flash(); - - // 認証されているユーザの取得 - $user = Auth::user(); - - // ログイン - if (empty($user) || empty($user->id)) { - // 認証エラーテンプレートを呼び出す。 - return $this->view( - 'codestudies_forbidden', [ - ] - ); - } - - // 自分のコード全て - $codestudies = Codestudies::where('created_id', $user->id)->get(); - - // コード取得 - $codestudy = $this->getPost($codestudy_id); - - // if (empty($codestudy)) { - // $codestudy = new Codestudies(); - // } - - - // 変更画面を呼び出す。(blade でold を使用するため、withInput 使用) - return $this->view( - 'codestudies', [ - 'codestudies' => $codestudies, - 'codestudy' => $codestudy, - 'result' => $result, - 'error_flag' => $error_flag, - 'errors' => $errors, - 'run_check_msgs' => $this->run_check_msgs, - 'versions' => $this->getLangVersion(), - ] - )->withInput($request->all); - } - - /** - * 保存処理 - */ - private function saveImpl($request, $page_id, $frame_id, $codestudy_id) - { - // id があれば更新、なければ登録 - if (empty($codestudy_id)) { - $codestudies = new Codestudies(); - } else { - $codestudies = Codestudies::where('id', $codestudy_id)->first(); - } - - // コード設定 - $codestudies->title = $request->title; - $codestudies->study_lang = $request->study_lang; - $codestudies->code_text = $request->code_text; - $codestudies->created_id = Auth::user()->id; - - // データ保存 - $codestudies->save(); - - // id を返却 - return $codestudies->id; - } - - /** - * 保存画面処理 - */ - public function savecode($request, $page_id, $frame_id, $codestudy_id) - { - - // 項目のエラーチェック - $validator = Validator::make($request->all(), [ - 'code_text' => ['required'], - 'study_lang' => ['required'], - ]); - $validator->setAttributeNames([ - 'code_text' => 'コード', - 'study_lang' => '言語', - ]); - - // エラーがあった場合は入力画面に戻る。 - if ($validator->fails()) { - if ($codestudy_id) { - return $this->editcode($request, $page_id, $frame_id, $codestudy_id, null, null, $validator->errors()); - } else { - return $this->index($request, $page_id, $frame_id, $validator->errors()); - } - } - - // データ保存 - $codestudy_id = $this->saveImpl($request, $page_id, $frame_id, $codestudy_id); - - // 登録後は表示用の初期処理を呼ぶ。 - return $this->editcode($request, $page_id, $frame_id, $codestudy_id); - } - - /** - * 実行可否の判定 - */ - private function runCheck($codestudy) - { - // 禁止関数 - $deny_method = array(); - $deny_method['php'][] = array('method'=>'dl', 'check_str'=>'dl('); - $deny_method['php'][] = array('method'=>'exec', 'check_str'=>'exec('); - $deny_method['php'][] = array('method'=>'fsockopen', 'check_str'=>'fsockopen('); - $deny_method['php'][] = array('method'=>'passthru', 'check_str'=>'passthru('); - $deny_method['php'][] = array('method'=>'pcntl_exec', 'check_str'=>'pcntl_exec('); - $deny_method['php'][] = array('method'=>'pfsockopen', 'check_str'=>'pfsockopen('); - $deny_method['php'][] = array('method'=>'phpinfo', 'check_str'=>'phpinfo('); - $deny_method['php'][] = array('method'=>'popen', 'check_str'=>'popen('); - $deny_method['php'][] = array('method'=>'proc_open', 'check_str'=>'proc_open('); - $deny_method['php'][] = array('method'=>'shell_exec', 'check_str'=>'shell_exec('); - $deny_method['php'][] = array('method'=>'stream_socket_client', 'check_str'=>'stream_socket_client('); - $deny_method['php'][] = array('method'=>'system', 'check_str'=>'system('); - - // 戻り値 - $return = array(); - - // コードからスペースを取り除く - $code_text_trim_space = str_replace(' ', '', $codestudy->code_text); - - // エラーチェック - if (array_key_exists($codestudy->study_lang, $deny_method)) { - foreach ($deny_method[$codestudy->study_lang] as $check_method) { - if (stripos($code_text_trim_space, $check_method['check_str']) !== false) { - $return[] = "「" . $check_method['method'] . "」が実行される可能性のあるプログラムは実行できません。"; - } - } - } - //return $return; - - $this->run_check_msgs = $return; - - return; - } - - /** - * Java クラス名抜き出し - */ - private function getClassName($codestudy) - { - $tmp_code = trim($codestudy->code_text, "\n"); - $tmp_code = trim($tmp_code, "\r"); - - $tmp_code = substr($tmp_code, strpos($tmp_code, 'class') + 5, strpos($tmp_code, '{') - strpos($tmp_code, 'class') - 5); - - $tmp_code = trim(mb_convert_kana($tmp_code, 'as', 'UTF-8')); - $tmp_code = preg_replace('/[^0-9a-zA-Z]/', '', $tmp_code); - - return $tmp_code; - } - - /** - * 実行処理 - */ - public function run($request, $page_id, $frame_id, $codestudy_id) - { - // 権限チェック(run 関数は標準チェックにないので、独自チェック) - //if ($this->can('posts.update', $this->getPost($codestudy_id))) { - // return $this->viewError(403); - //} - - // 項目のエラーチェック - $validator = Validator::make($request->all(), [ - 'code_text' => ['required'], - 'study_lang' => ['required'], - ]); - $validator->setAttributeNames([ - 'code_text' => 'コード', - 'study_lang' => '言語', - ]); - - // エラーがあった場合は入力画面に戻る。 - if ($validator->fails()) { - if ($codestudy_id) { - return $this->editcode($request, $page_id, $frame_id, $codestudy_id, null, null, $validator->errors()); - } else { - return $this->index($request, $page_id, $frame_id, $validator->errors()); - } - } - - // データ保存 - $codestudy_id = $this->saveImpl($request, $page_id, $frame_id, $codestudy_id); - - // コード取得 - $codestudy = Codestudies::where('id', $codestudy_id)->first(); - - // ファイルに出力 - //Storage::put('codestudy/' . $codestudy_id . '.php', $codestudy->code_text); - Storage::makeDirectory('codestudy/' . $codestudy->created_id . '/' . $codestudy_id); - - $class_name = ""; - - // 言語判定 - if ($codestudy->study_lang == 'php') { - Storage::put('codestudy/' . $codestudy->created_id . '/' . $codestudy_id . '/' . $codestudy_id . '.php', $codestudy->code_text); - } elseif ($codestudy->study_lang == 'java') { - $class_name = $this->getClassName($codestudy); - Storage::put('codestudy/' . $codestudy->created_id . '/' . $codestudy_id . '/' . $class_name . '.java', $codestudy->code_text); - } - - // 実行可否の判定 - $error_flag = null; - //$error_msg = $this->runCheck($codestudy); - $error_msg = array(); - - $this->runCheck($codestudy); - - //if ($error_msg) { - if ($this->run_check_msgs) { - // エラーメッセージを返す。 - $error_flag = 1; - $result = $error_msg; - } else { - $cmd = ''; - - if ($codestudy->study_lang == 'php') { - // PHP 実行 - $cmd = 'php ' . storage_path('app/codestudy/' . $codestudy->created_id . '/' . $codestudy_id . '/' . $codestudy_id . '.php'); - //$cmd = 'php -l ' . storage_path('app/codestudy/' . $codestudy_id . '.php'); - - if (!empty($cmd)) { - exec("$cmd 2>&1", $result); - } - } elseif ($codestudy->study_lang == 'javascript') { - $result = $codestudy; - } elseif ($codestudy->study_lang == 'java') { - // コンパイル - $cmd = 'javac -encoding UTF-8 ' . storage_path('app/codestudy/' . $codestudy->created_id . '/' . $codestudy_id . '/' . $class_name . '.java'); - if (!empty($cmd)) { - exec("$cmd 2>&1", $result); - //Log::debug($cmd); - //Log::debug($result); - } - - // コンパイルがうまくいった場合($result が空) - if (empty($result)) { - // 実行 - $cmd = 'java -Dfile.encoding=UTF-8 -classpath ' . storage_path('app/codestudy/' . $codestudy->created_id . '/' . $codestudy_id); - $cmd .= ' ' . $class_name; - exec("$cmd 2>&1", $result); - //Log::debug($cmd); - //Log::debug($result); - } - } - } - - // $result 内のプログラム・ファイルパスを編集する。 - $rep_str = storage_path('app\\codestudy\\' . $codestudy->created_id . '\\' . $codestudy_id . '\\'); - foreach ($result as &$result_item) { - $result_item = str_replace($rep_str, '', $result_item); - } - - // 登録後は表示用の初期処理を呼ぶ。 - return $this->editcode($request, $page_id, $frame_id, $codestudy_id, $result, $error_flag); - } - - /** - * 削除処理 - */ - public function deletecode($request, $page_id, $frame_id, $codestudy_id) - { - // id がある場合、データを削除 - if ($codestudy_id) { - // データを削除する。 - Codestudies::where('id', $codestudy_id)->delete(); - } - // 削除後は表示用の初期処理を呼ぶ。 - return $this->index($request, $page_id, $frame_id); - } - - /** - * 学習結果ダウンロード指示画面 - */ - public function viewDownload($request, $page_id, $frame_id) - { - // 表示テンプレートを呼び出す。 - return $this->view( - 'download', [] - )->withInput($request->all); - } - - /** - * 学習結果ダウンロード実行 - */ - public function download($request, $page_id, $frame_id) - { - - $save_path = $this->getTmpDirectory() . uniqid('', true) . '.zip'; - $this->makeZip($save_path, $request); - - // 一時ファイルは削除して、ダウンロードレスポンスを返す. download()でAllowed memory sizeエラー時にtmpファイル削除対応 - $response = response()->download( - $save_path, - 'StudyCodes.zip', - ['Content-Disposition' => 'filename=StudyCodes.zip'] - ); - // )->deleteFileAfterSend(true); - register_shutdown_function('unlink', $save_path); - return $response; - } - - /** - * ダウンロードするZIPファイルを作成する。 - * - * @param string $save_path 保存先パス - * @param \Illuminate\Http\Request $request リクエスト - */ - private function makeZip($save_path, $request) - { - $zip = new \ZipArchive(); - $zip->open($save_path, \ZipArchive::CREATE); - - // フォルダがないとzipファイルを作れない - if (!is_dir($this->getTmpDirectory())) { - mkdir($this->getTmpDirectory(), 0777, true); - } - - // 学生のコードを取得する。 - // ユーザは削除された場合のことも想定しておく。 - $codestudies = Codestudies::select( - 'codestudies.*', - 'users.userid', - 'users.name', - ) - ->leftJoin('users', 'users.id', '=', 'codestudies.created_id') - ->orderBy('codestudies.created_id', 'asc') - ->orderBy('codestudies.id', 'asc') - ->get(); - - // 学生ループ - $tmp_dir_name = ''; - foreach ($codestudies as $codestudy) { - // 学生用フォルダ。ログインID(学籍番号を想定)で作成 - // もしユーザデータがなかったら、_{$created_id} - $dir = $codestudy->userid; - if (empty($dir)) { - $dir = '_' . $codestudy->created_id; - } - // 学生用フォルダ作成 - if ($tmp_dir_name != $dir) { - $zip->addEmptyDir($dir); - $tmp_dir_name = $dir; - } - // 拡張子 - $ext = ""; - if ($codestudy->study_lang == 'javascript') { - $ext = ".js"; - } elseif ($codestudy->study_lang == 'java') { - $ext = ".java"; - } elseif ($codestudy->study_lang == 'php') { - $ext = ".php"; - } - // コードの保存 - $zip->addFromString($dir . "/" . mb_convert_encoding($codestudy->title, CsvCharacterCode::sjis_win) . $ext, $codestudy->code_text . "\n"); - } - - // 空のZIPファイルが出来たら404 - if ($zip->count() === 0) { - // zipファイル後始末 - $zip->close(); - if (file_exists($save_path)) { - unlink($save_path); - } - abort(404, 'ファイルがありません。'); - } - $zip->close(); - } - - /** - * 一時フォルダのパスを取得する - * - * @return string 一時フォルダのパス - */ - private function getTmpDirectory() - { - return storage_path('app/') . 'tmp/codestudies/'; - } -} diff --git a/app/Plugins/User/Codestudies/plugin.ini b/app/Plugins/User/Codestudies/plugin.ini deleted file mode 100644 index 0b5b32c55..000000000 --- a/app/Plugins/User/Codestudies/plugin.ini +++ /dev/null @@ -1,2 +0,0 @@ -[plugin_base] -plugin_name_full = コードスタディ diff --git a/config/connect.php b/config/connect.php index e7bf1a847..3c71c9306 100644 --- a/config/connect.php +++ b/config/connect.php @@ -24,7 +24,7 @@ 'manual_voiceid' => env('MANUAL_VOICEID', 'takumi'), // プラグイン管理にも表示しないプラグイン(小文字で指定) - 'PLUGIN_FORCE_HIDDEN' => ['knowledges', 'codestudies'], + 'PLUGIN_FORCE_HIDDEN' => ['knowledges'], // 特別なPath定義(管理画面) 'CC_SPECIAL_PATH_MANAGE' => array_merge( diff --git a/database/migrations/2026_02_17_000000_drop_codestudies_table.php b/database/migrations/2026_02_17_000000_drop_codestudies_table.php new file mode 100644 index 000000000..8a5130501 --- /dev/null +++ b/database/migrations/2026_02_17_000000_drop_codestudies_table.php @@ -0,0 +1,41 @@ +increments('id'); + $table->string('study_lang'); + $table->text('title')->nullable(); + $table->text('code_text')->nullable(); + $table->integer('created_id')->nullable(); + $table->string('created_name', 255)->nullable(); + $table->timestamps(); + $table->integer('updated_id')->nullable(); + $table->string('updated_name', 255)->nullable(); + $table->integer('deleted_id')->nullable(); + $table->string('deleted_name', 255)->nullable(); + $table->softDeletes(); + }); + } +} diff --git a/database/migrations/2026_02_17_000000_replace_codestudies_frames_with_contents.php b/database/migrations/2026_02_17_000000_replace_codestudies_frames_with_contents.php new file mode 100644 index 000000000..fb4270c63 --- /dev/null +++ b/database/migrations/2026_02_17_000000_replace_codestudies_frames_with_contents.php @@ -0,0 +1,91 @@ +select('id', 'page_id', 'frame_title') + ->where('plugin_name', 'codestudies') + ->orderBy('id') + ->get(); + + foreach ($frames as $frame) { + $timestamp = now(); + $bucket_id = DB::table('buckets')->insertGetId([ + 'bucket_name' => $this->getBucketName($frame), + 'plugin_name' => 'contents', + 'container_page_id' => $frame->page_id, + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + + DB::table('contents')->insert([ + 'bucket_id' => $bucket_id, + 'content_text' => $this->getNoticeMessage(), + 'content2_text' => null, + 'read_more_flag' => 0, + 'read_more_button' => '続きを読む', + 'close_more_button' => '閉じる', + 'status' => 0, + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + + DB::table('frames') + ->where('id', $frame->id) + ->update([ + 'plugin_name' => 'contents', + 'bucket_id' => $bucket_id, + 'updated_at' => $timestamp, + ]); + } + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // rollback時に変換前のバケツIDを復元できないため、処理しない。 + } + + /** + * 固定記事バケツ名 + * + * @param object $frame + * @return string + */ + private function getBucketName($frame) + { + if (!empty($frame->frame_title)) { + return $frame->frame_title; + } + + return 'コードスタディ廃止のお知らせ'; + } + + /** + * 廃止メッセージ + * + * @return string + */ + private function getNoticeMessage() + { + return '

    コードスタディプラグインは廃止されました。

    ' + . '

    このフレームは固定記事に置き換えています。

    ' + . '

    必要な情報がある場合は、サイト管理者にお問い合わせください。

    '; + } +} diff --git a/database/migrations/2026_02_18_000000_remove_codestudies_from_plugins.php b/database/migrations/2026_02_18_000000_remove_codestudies_from_plugins.php new file mode 100644 index 000000000..1233f6705 --- /dev/null +++ b/database/migrations/2026_02_18_000000_remove_codestudies_from_plugins.php @@ -0,0 +1,29 @@ +whereRaw('LOWER(plugin_name) = ?', ['codestudies']) + ->delete(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // 削除済みプラグインの再表示を防ぐため、復元はしない。 + } +} diff --git a/resources/views/plugins/user/codestudies/codestudies_frame_edit_tab.blade.php b/resources/views/plugins/user/codestudies/codestudies_frame_edit_tab.blade.php deleted file mode 100644 index 415b031a1..000000000 --- a/resources/views/plugins/user/codestudies/codestudies_frame_edit_tab.blade.php +++ /dev/null @@ -1,16 +0,0 @@ -{{-- - * 編集画面tabテンプレート - * - * @author 永原 篤 - * @copyright OpenSource-WorkShop Co.,Ltd. All Rights Reserved - * @category コードスタディ・プラグイン - --}} -@if ($action == 'viewDownload') - -@else - -@endif diff --git a/resources/views/plugins/user/codestudies/default/codestudies.blade.php b/resources/views/plugins/user/codestudies/default/codestudies.blade.php deleted file mode 100644 index 29ac5b66c..000000000 --- a/resources/views/plugins/user/codestudies/default/codestudies.blade.php +++ /dev/null @@ -1,178 +0,0 @@ -{{-- - * コードスタディ画面テンプレート。 - * - * @author 永原 篤 - * @author 牟田口 満 - * @copyright OpenSource-WorkShop Co.,Ltd. All Rights Reserved - * @category コードスタディプラグイン - --}} -@extends('core.cms_frame_base') - -@section("plugin_contents_$frame->id") - - -{{-- 結果があれば表示 --}} -@if (isset($run_check_msgs) && $run_check_msgs) -
    -
    制限エラー
    -
    - @foreach ($run_check_msgs as $run_check_msg) - {!!$run_check_msg!!}
    - @endforeach -
    -
    -@endif - -@if (isset($result) && $result) -@if ($error_flag == 1) -
    -@else -
    -@endif -
    実行結果
    -
    - @if ($codestudy->study_lang == 'javascript') - {!!$result->code_text!!} - @else - @foreach ($result as $result_row) - {!!$result_row!!}
    - @endforeach - @endif -
    -
    -@endif - -@if ($codestudy->id) - -@else - -@endif - - {{ csrf_field() }} - -
    -
    - -
    - -
    -
    - - @if ($errors && $errors->has('code_text'))
    {{$errors->first('code_text')}}
    @endif -
    - -@php - $mode = 'javascript()'; - if ($codestudy->study_lang == 'php') { - $mode = 'php()'; - } elseif ($codestudy->study_lang == 'javascript') { - $mode = 'javascript()'; - } elseif ($codestudy->study_lang == 'java') { - $mode = 'java()'; - } -@endphp -@include('plugins.common.codemirror', ['element_id' => 'txt_editor', 'mode' => $mode]) - -
    -
    - - -
    -
    - @foreach($versions as $language => $version) - {{$language}}
    -
    - {!!$version!!}
    -
    - @endforeach -
    -
    - -
    -
    -
    -
    -
    - - - -
    -
    -
    - @if (!empty($codestudy->id)) - - 削除 - - @endif -
    -
    -
    - - -
    -
    -
    - プログラムを削除します。
    元に戻すことはできないため、よく確認して実行してください。
    - -
    - {{-- 削除ボタン --}} -
    - {{csrf_field()}} - -
    -
    -
    -
    -
    - -
    -
    保存済みプログラム
    -
    -
      - @foreach($codestudies as $codestudy) - @if($codestudy->title) -
    1. {{$codestudy->title}} [{{$codestudy->study_lang}}]
    2. - @else -
    3. 無題 [{{$codestudy->study_lang}}]
    4. - @endif - @endforeach -
    -
    -
    -@endsection diff --git a/resources/views/plugins/user/codestudies/default/codestudies_forbidden.blade.php b/resources/views/plugins/user/codestudies/default/codestudies_forbidden.blade.php deleted file mode 100644 index 717daef36..000000000 --- a/resources/views/plugins/user/codestudies/default/codestudies_forbidden.blade.php +++ /dev/null @@ -1,13 +0,0 @@ -{{-- - * コードスタディ画面テンプレート。 - * - * @author 永原 篤 - * @copyright OpenSource-WorkShop Co.,Ltd. All Rights Reserved - * @category コードスタディプラグイン - --}} - -
    - - コードスタディを使用するには、ログインしてください。 -
    - diff --git a/resources/views/plugins/user/codestudies/default/download.blade.php b/resources/views/plugins/user/codestudies/default/download.blade.php deleted file mode 100644 index 73a201d81..000000000 --- a/resources/views/plugins/user/codestudies/default/download.blade.php +++ /dev/null @@ -1,27 +0,0 @@ -{{-- - * 成績ダウンロード指示画面 - * - * @author 永原 篤 - * @copyright OpenSource-WorkShop Co.,Ltd. All Rights Reserved - * @category コードスタディプラグイン - --}} -@extends('core.cms_frame_base_setting') - -@section("core.cms_frame_edit_tab_$frame->id") - {{-- プラグイン側のフレームメニュー --}} - @include('plugins.user.codestudies.codestudies_frame_edit_tab') -@endsection - -@section("plugin_setting_$frame->id") - -{{-- ダウンロードフォーム --}} -
    -
    - {{ csrf_field() }} -
    - -
    -
    -
    - -@endsection diff --git a/tests/bin/connect-cms-test.bat b/tests/bin/connect-cms-test.bat index e7c10f8b8..3a7dfb63a 100644 --- a/tests/bin/connect-cms-test.bat +++ b/tests/bin/connect-cms-test.bat @@ -361,7 +361,7 @@ rem rem y̌z f, {ݗ\ rem y̐z ^u rem y̎sz e[}`FW[ -rem y̋z (DroneStudy), (CodeStudy) +rem y̋z (DroneStudy) rem yHTMLj[z \iJeSփNAPDFփNAփNA‹ACZXi\tgEFAA}jAjj rem --------------------------------------------- From 4a1a64a8f768a53e06a4239e25782d9e2e88fc63 Mon Sep 17 00:00:00 2001 From: gakigaki <32890286+gakigaki@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:37:14 +0900 Subject: [PATCH 08/13] Fix: GHSA-jh46-85jr-6ph9 --- app/Plugins/Manage/PageManage/PageManage.php | 18 +- .../MigrationExportHtmlPageTrait.php | 185 +++--- .../Migration/MigrationExportNc3PageTrait.php | 122 +++- .../Migration/MigrationHttpClientUtils.php | 491 ++++++++++++++++ app/Utilities/Url/UrlUtils.php | 2 +- .../manage/page/migration_order.blade.php | 16 + .../PageManageMigrationGetSecurityTest.php | 87 +++ .../PageManageMigrationGetValidationTest.php | 133 +++++ ...grationHttpClientUtilsDnsFunctionMocks.php | 90 +++ .../MigrationExportHtmlPageTraitTest.php | 302 ++++++++++ .../MigrationExportNc3PageTraitTest.php | 555 ++++++++++++++++++ .../MigrationHttpClientUtilsTest.php | 470 +++++++++++++++ 12 files changed, 2323 insertions(+), 148 deletions(-) create mode 100644 app/Utilities/Migration/MigrationHttpClientUtils.php create mode 100644 tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetSecurityTest.php create mode 100644 tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetValidationTest.php create mode 100644 tests/Support/Migration/MigrationHttpClientUtilsDnsFunctionMocks.php create mode 100644 tests/Unit/Traits/Migration/MigrationExportHtmlPageTraitTest.php create mode 100644 tests/Unit/Traits/Migration/MigrationExportNc3PageTraitTest.php create mode 100644 tests/Unit/Utilities/Migration/MigrationHttpClientUtilsTest.php diff --git a/app/Plugins/Manage/PageManage/PageManage.php b/app/Plugins/Manage/PageManage/PageManage.php index 0663713c1..a35c1fd56 100644 --- a/app/Plugins/Manage/PageManage/PageManage.php +++ b/app/Plugins/Manage/PageManage/PageManage.php @@ -1050,14 +1050,27 @@ function ($attribute, $value, $fail) { } }, ], + 'use_proxy' => 'sometimes|accepted', 'destination_page_id' => 'required', ]); $validator->setAttributeNames([ 'source_system' => '移行元システム', 'url' => '移行元URL', + 'use_proxy' => 'プロキシ使用', 'destination_page_id' => '移行先ページ', ]); + $use_proxy = $request->boolean('use_proxy'); + if ($use_proxy) { + $proxy_tunnel_enabled = (bool) config('connect.HTTPPROXYTUNNEL'); + $proxy_host = trim((string) config('connect.PROXY')); + if (!$proxy_tunnel_enabled || $proxy_host === '') { + $validator->after(function ($validator) { + $validator->errors()->add('use_proxy', 'プロキシを使用する場合は、プロキシ設定(HTTPPROXYTUNNEL / PROXY)を設定してください。'); + }); + } + } + // エラーがあった場合は入力画面に戻る。 if ($validator->fails()) { return redirect('manage/page/migrationOrder/' . $page_id) @@ -1083,14 +1096,15 @@ function ($attribute, $value, $fail) { } // 移行元システムによって処理を分岐 + $migration_http_options = ['use_proxy' => $use_proxy]; if ($request->source_system == WebsiteType::netcommons2) { // TODO: netcommons2 からの移行 } elseif ($request->source_system == WebsiteType::netcommons3) { // netcommons3 からの移行 - $this->migrationNC3Page($request->url, $request->destination_page_id); + $this->migrationNC3Page($request->url, $request->destination_page_id, $migration_http_options); } elseif ($request->source_system == WebsiteType::html) { // html からの移行 - $this->migrationHtmlPage($request->url, $request->destination_page_id); + $this->migrationHtmlPage($request->url, $request->destination_page_id, $migration_http_options); } // リクエストを送信した時間をファイルに書き込む diff --git a/app/Traits/Migration/MigrationExportHtmlPageTrait.php b/app/Traits/Migration/MigrationExportHtmlPageTrait.php index 994db3764..013da4a36 100644 --- a/app/Traits/Migration/MigrationExportHtmlPageTrait.php +++ b/app/Traits/Migration/MigrationExportHtmlPageTrait.php @@ -3,12 +3,12 @@ namespace App\Traits\Migration; use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\UriResolver; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Log; +use App\Utilities\Migration\MigrationHttpClientUtils; use App\Utilities\Migration\MigrationUtils; use App\Utilities\Url\UrlUtils; @@ -36,7 +36,7 @@ trait MigrationExportHtmlPageTrait * @param integer $page_id * @return void */ - private function migrationHtmlPage(string $url, int $page_id) : void + private function migrationHtmlPage(string $url, int $page_id, array $http_options = []) : void { if (!UrlUtils::isGlobalHttpUrl($url)) { Log::warning('[migrationHtmlPage] Rejected non-global URL: ' . $url); @@ -53,7 +53,7 @@ private function migrationHtmlPage(string $url, int $page_id) : void Storage::makeDirectory('migration/import/pages/' . $page_id); // 指定されたページのHTML を取得(リダイレクト先URLも都度検証する) - $result_array = $this->executeMigrationHtmlRequest($url); + $result_array = $this->executeMigrationHtmlRequest($url, $http_options); if ($result_array === null) { return; } @@ -68,8 +68,9 @@ private function migrationHtmlPage(string $url, int $page_id) : void // HTMLドキュメントの解析準備 $dom = new \DOMDocument; - // DOMDocument が返ってくる。 - @$dom->loadHTML($html); + // 外部ページには非標準タグ/壊れたEntityが含まれることがあるため、 + // libxml警告は内部エラーとして扱い、取り込み処理は継続する。 + $this->loadHtmlDocument($dom, $html); $xpath = new \DOMXPath($dom); // bodyタグ配下を抽出する @@ -117,6 +118,8 @@ private function migrationHtmlPage(string $url, int $page_id) : void // 画像ファイルのダウンロード if ($image_paths) { + $http_client = $this->migrationHttpCreateClient($http_options); + // HTML 中の画像ファイルをループで処理 $frame_ini .= "\n[image_names]\n"; $image_index = 0; @@ -143,30 +146,21 @@ private function migrationHtmlPage(string $url, int $page_id) : void $save_path = 'migration/import/pages/' . $page_id . "/" . $file_name; $save_storage_path = storage_path() . '/app/' . $save_path; - // CURL 設定、ファイル取得 - $ch = curl_init($download_img_path); - $fp = fopen($save_storage_path, 'w'); - curl_setopt($ch, CURLOPT_FILE, $fp); - curl_setopt($ch, CURLOPT_HEADER, false); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, array(&$this,'callbackHtmlHeader')); - $result = curl_exec($ch); - - // エラーがあった場合はスキップ - if (!empty(curl_errno($ch))) { - Log::debug('curl_errno: ' . curl_errno($ch)); + try { + $download_response = $this->migrationHttpDownloadToFile($http_client, $download_img_path, $save_storage_path, $http_options); + } catch (\RuntimeException $e) { + Storage::delete($save_path); continue; } + // ステータス404はスキップ - Log::debug('curl_getinfo: ' . curl_getinfo($ch, CURLINFO_HTTP_CODE)); - if (trim(curl_getinfo($ch, CURLINFO_HTTP_CODE)) == '404') { + Log::debug('http_code: ' . $download_response['http_code']); + if ((int) $download_response['http_code'] === 404) { Log::debug('File deleted.'); Storage::delete($save_path); continue; } - curl_close($ch); - fclose($fp); - //@getimagesize関数で画像情報を取得する list($img_width, $img_height, $mime_type, $attr) = @getimagesize($save_storage_path); @@ -222,14 +216,14 @@ private function migrationHtmlPage(string $url, int $page_id) : void * @param string $url * @return array|null ['body' => string, 'effective_url' => string] */ - private function executeMigrationHtmlRequest(string $url): ?array + private function executeMigrationHtmlRequest(string $url, array $http_options = []): ?array { $current_url = $url; $max_redirects = 5; - $http_client = $this->createMigrationHttpClient(); + $http_client = $this->migrationHttpCreateClient($http_options); for ($redirect_count = 0; $redirect_count <= $max_redirects; $redirect_count++) { - $response = $this->executeSingleMigrationRequest($http_client, $current_url); + $response = $this->executeSingleMigrationRequest($http_client, $current_url, $http_options); if (!$this->isRedirectHttpCode($response['http_code'])) { return [ @@ -267,125 +261,60 @@ private function executeMigrationHtmlRequest(string $url): ?array * @param string $url * @return array ['body' => string, 'http_code' => int, 'location' => string] */ - private function executeSingleMigrationRequest(Client $http_client, string $url): array + private function executeSingleMigrationRequest(Client $http_client, string $url, array $http_options = []): array { if (!UrlUtils::isGlobalHttpUrl($url)) { Log::warning('[migrationHtmlPage] Rejected non-global URL: ' . $url); throw new \RuntimeException('[migrationHtmlPage] Rejected non-global URL: ' . $url); } - try { - $response = $http_client->request('GET', $url); - } catch (GuzzleException $e) { - $error_message = "HTTP [GET] {$url} : failed. " . $e->getMessage(); - Log::error($error_message); - throw new \RuntimeException($error_message, 0, $e); - } - - $body = (string) $response->getBody(); - $http_code = (int) $response->getStatusCode(); - $location = trim($response->getHeaderLine('Location')); - - return [ - 'body' => $body, - 'http_code' => $http_code, - 'location' => $location, - ]; + return $this->migrationHttpGet($http_client, $url, $http_options); } /** - * HTTPステータスコードがリダイレクトかどうかを判定する + * 移行処理用HTTPクライアントを生成する * - * @param int $http_code - * @return bool + * @return Client */ - private function isRedirectHttpCode(int $http_code): bool + protected function migrationHttpCreateClient(array $http_options = []): Client { - return in_array($http_code, [301, 302, 303, 307, 308], true); + return MigrationHttpClientUtils::createClient($http_options); } /** - * マイグレーションHTML取得用のHTTPクライアントを生成する + * 移行処理用HTTP GET(文字列レスポンス) * - * @return Client + * @param Client $http_client + * @param string $url + * @return array */ - private function createMigrationHttpClient(): Client + protected function migrationHttpGet(Client $http_client, string $url, array $http_options = []): array { - $http_client_options = [ - 'http_errors' => false, - 'allow_redirects' => false, - ]; - - $timeout = config('connect.CURL_TIMEOUT'); - if (!empty($timeout)) { - $http_client_options['timeout'] = (float) $timeout; - } - - if (config('connect.HTTPPROXYTUNNEL')) { - $proxy = $this->buildMigrationProxyOption(); - if ($proxy !== null) { - $http_client_options['proxy'] = $proxy; - } - } - - return new Client($http_client_options); + return MigrationHttpClientUtils::get($http_client, $url, $http_options); } /** - * connect設定からGuzzle用のプロキシURLを生成する + * 移行処理用HTTP GET(ファイル保存) * - * @return string|null + * @param Client $http_client + * @param string $url + * @param string $sink_path + * @return array */ - private function buildMigrationProxyOption(): ?string + protected function migrationHttpDownloadToFile(Client $http_client, string $url, string $sink_path, array $http_options = []): array { - $proxy = trim((string) config('connect.PROXY')); - if ($proxy === '') { - return null; - } - - if (strpos($proxy, '://') === false) { - $proxy = 'http://' . $proxy; - } - - $proxy_parts = parse_url($proxy); - if ($proxy_parts === false || !isset($proxy_parts['host'])) { - return null; - } - - $scheme = $proxy_parts['scheme'] ?? 'http'; - $host = $proxy_parts['host']; - if (strpos($host, ':') !== false && strpos($host, '[') !== 0) { - $host = '[' . $host . ']'; - } - - $port = $proxy_parts['port'] ?? null; - $config_proxy_port = trim((string) config('connect.PROXYPORT')); - if ($config_proxy_port !== '') { - $port = $config_proxy_port; - } - - $user = $proxy_parts['user'] ?? null; - $pass = $proxy_parts['pass'] ?? null; - $proxy_user_pwd = trim((string) config('connect.PROXYUSERPWD')); - if ($proxy_user_pwd !== '') { - list($user, $pass) = array_pad(explode(':', $proxy_user_pwd, 2), 2, ''); - } - - $auth = ''; - if (!empty($user)) { - $auth = rawurlencode($user); - if (!empty($pass)) { - $auth .= ':' . rawurlencode($pass); - } - $auth .= '@'; - } - - $proxy_url = $scheme . '://' . $auth . $host; - if (!empty($port)) { - $proxy_url .= ':' . $port; - } + return MigrationHttpClientUtils::downloadToFile($http_client, $url, $sink_path, $http_options); + } - return $proxy_url; + /** + * HTTPステータスコードがリダイレクトかどうかを判定する + * + * @param int $http_code + * @return bool + */ + private function isRedirectHttpCode(int $http_code): bool + { + return in_array($http_code, [301, 302, 303, 307, 308], true); } /** @@ -421,6 +350,26 @@ private function getHtmlInnerHtml($node) return $node->ownerDocument->saveHTML($node); } + /** + * HTMLをDOMへ読み込む(libxml警告は内部で処理) + * + * @param \DOMDocument $dom + * @param string $html + * @return void + */ + private function loadHtmlDocument(\DOMDocument $dom, string $html): void + { + $previous_internal_errors = libxml_use_internal_errors(true); + libxml_clear_errors(); + + try { + $dom->loadHTML($html); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous_internal_errors); + } + } + /** * CURL のhttp ヘッダー処理コールバック関数 */ diff --git a/app/Traits/Migration/MigrationExportNc3PageTrait.php b/app/Traits/Migration/MigrationExportNc3PageTrait.php index 6a9cc3e4b..fb2432080 100644 --- a/app/Traits/Migration/MigrationExportNc3PageTrait.php +++ b/app/Traits/Migration/MigrationExportNc3PageTrait.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; +use App\Utilities\Migration\MigrationHttpClientUtils; use App\Utilities\Migration\MigrationUtils; use App\Utilities\Url\UrlUtils; @@ -29,7 +30,7 @@ trait MigrationExportNc3PageTrait /** * ページのHTML取得 */ - private function migrationNC3Page($url, $page_id) + private function migrationNC3Page($url, $page_id, array $http_options = []) { if (!UrlUtils::isGlobalHttpUrl((string) $url)) { Log::warning('[migrationNC3Page] Rejected non-global URL: ' . $url); @@ -55,19 +56,28 @@ private function migrationNC3Page($url, $page_id) // 画像ファイルや添付ファイルを取得する場合のテンポラリ・ディレクトリ Storage::makeDirectory('migration/import/pages/' . $page_id); + $http_client = $this->migrationHttpCreateClientForNc3($http_options); + // 指定されたページのHTML を取得 // $html = $this->getHTMLPage($url); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - $html = curl_exec($ch); - curl_close($ch); + try { + $page_response = $this->migrationHttpGetForNc3($http_client, (string) $url, $http_options); + } catch (\RuntimeException $e) { + Log::error('[migrationNC3Page] Failed to fetch page HTML.', [ + 'page_id' => $page_id, + 'url' => (string) $url, + 'exception' => $e, + ]); + return; + } + $html = $page_response['body']; // HTMLドキュメントの解析準備 $dom = new \DOMDocument; - // DOMDocument が返ってくる。 - @$dom->loadHTML($html); + // 外部HTMLには非標準タグ/壊れたEntityが含まれることがあるため、 + // libxml警告は内部エラーとして扱い、取り込み処理は継続する。 + $this->loadNc3HtmlDocument($dom, $html); $xpath = new \DOMXPath($dom); // NC3 のメイン部分を抜き出します。 @@ -151,18 +161,18 @@ private function migrationNC3Page($url, $page_id) $savePath = 'migration/import/pages/' . $page_id . "/" . $file_name; $saveStragePath = storage_path() . '/app/' . $savePath; - // CURL 設定、ファイル取得 - $ch = curl_init($downloadPath); - $fp = fopen($saveStragePath, 'w'); - curl_setopt($ch, CURLOPT_FILE, $fp); - curl_setopt($ch, CURLOPT_HEADER, false); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, array(&$this,'callbackNc3Header')); - $result = curl_exec($ch); - if (!empty(curl_errno($ch))) { + try { + $download_response = $this->migrationHttpDownloadToFileForNc3($http_client, (string) $downloadPath, $saveStragePath, $http_options); + } catch (\RuntimeException $e) { + Log::error('[migrationNC3Page] Failed to download image.', [ + 'page_id' => $page_id, + 'url' => (string) $downloadPath, + 'exception' => $e, + ]); + Storage::delete($savePath); continue; } - curl_close($ch); - fclose($fp); + $this->content_disposition = $download_response['content_disposition']; //echo $this->content_disposition; //@getimagesize関数で画像情報を取得する @@ -227,15 +237,18 @@ private function migrationNC3Page($url, $page_id) $savePath = 'migration/import/pages/' . $page_id . "/" . $file_name; $saveStragePath = storage_path() . '/app/' . $savePath; - // CURL 設定、ファイル取得 - $ch = curl_init($downloadPath); - $fp = fopen($saveStragePath, 'w'); - curl_setopt($ch, CURLOPT_FILE, $fp); - curl_setopt($ch, CURLOPT_HEADER, false); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, array(&$this,'callbackNc3Header')); - $result = curl_exec($ch); - curl_close($ch); - fclose($fp); + try { + $download_response = $this->migrationHttpDownloadToFileForNc3($http_client, (string) $downloadPath, $saveStragePath, $http_options); + } catch (\RuntimeException $e) { + Log::error('[migrationNC3Page] Failed to download file.', [ + 'page_id' => $page_id, + 'url' => (string) $downloadPath, + 'exception' => $e, + ]); + Storage::delete($savePath); + continue; + } + $this->content_disposition = $download_response['content_disposition']; //echo $this->content_disposition; @@ -275,6 +288,41 @@ private function migrationNC3Page($url, $page_id) } } + /** + * NC3移行処理用HTTPクライアントを生成する + * + * @return \GuzzleHttp\Client + */ + protected function migrationHttpCreateClientForNc3(array $http_options = []) + { + return MigrationHttpClientUtils::createClient($http_options); + } + + /** + * NC3移行処理用HTTP GET(文字列レスポンス) + * + * @param \GuzzleHttp\Client $http_client + * @param string $url + * @return array + */ + protected function migrationHttpGetForNc3($http_client, string $url, array $http_options = []): array + { + return MigrationHttpClientUtils::get($http_client, $url, $http_options); + } + + /** + * NC3移行処理用HTTP GET(ファイル保存) + * + * @param \GuzzleHttp\Client $http_client + * @param string $url + * @param string $sink_path + * @return array + */ + protected function migrationHttpDownloadToFileForNc3($http_client, string $url, string $sink_path, array $http_options = []): array + { + return MigrationHttpClientUtils::downloadToFile($http_client, $url, $sink_path, $http_options); + } + /** * nodeをHTMLとして取り出す */ @@ -293,6 +341,26 @@ private function getNc3InnerHtml($node) return $html; } + /** + * HTMLをDOMへ読み込む(libxml警告は内部で処理) + * + * @param \DOMDocument $dom + * @param string $html + * @return void + */ + private function loadNc3HtmlDocument(\DOMDocument $dom, string $html): void + { + $previous_internal_errors = libxml_use_internal_errors(true); + libxml_clear_errors(); + + try { + $dom->loadHTML($html); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous_internal_errors); + } + } + /** * フレームデザインの取得 */ diff --git a/app/Utilities/Migration/MigrationHttpClientUtils.php b/app/Utilities/Migration/MigrationHttpClientUtils.php new file mode 100644 index 000000000..36d366f9f --- /dev/null +++ b/app/Utilities/Migration/MigrationHttpClientUtils.php @@ -0,0 +1,491 @@ + false, + 'allow_redirects' => false, + ]; + + $timeout = config('connect.CURL_TIMEOUT'); + if (!empty($timeout)) { + $http_client_options['timeout'] = (float) $timeout; + } + + if (self::shouldUseProxy($runtime_options)) { + $proxy = self::buildProxyOption(); + if ($proxy !== null) { + $http_client_options['proxy'] = $proxy; + } + } + + return $http_client_options; + } + + /** + * 文字列レスポンスとしてGETする + * + * @param Client $http_client + * @param string $url + * @return array + */ + public static function get(Client $http_client, string $url, array $runtime_options = []): array + { + return self::request($http_client, 'GET', $url, [], $runtime_options); + } + + /** + * ファイルへ保存しながらGETする + * + * @param Client $http_client + * @param string $url + * @param string $sink_path + * @return array + */ + public static function downloadToFile(Client $http_client, string $url, string $sink_path, array $runtime_options = []): array + { + return self::request($http_client, 'GET', $url, ['sink' => $sink_path], $runtime_options); + } + + /** + * HTTPリクエストを実行する + * + * @param Client $http_client + * @param string $method + * @param string $url + * @param array $request_options + * @return array + */ + private static function request(Client $http_client, string $method, string $url, array $request_options, array $runtime_options = []): array + { + $proxy_option = array_key_exists('proxy', $request_options) + ? $request_options['proxy'] + : (self::shouldUseProxy($runtime_options) ? self::buildProxyOption() : null); + $is_proxy_used = self::hasConfiguredProxy($proxy_option); + + // プロキシ未使用時のみ、事前検証で解決したホスト名を固定して TOCTOU を防ぐ。 + if (!$is_proxy_used) { + $request_options = self::applyDnsPinning($url, $request_options); + } + + $handler_stats = []; + $request_options['on_stats'] = function ($stats) use (&$handler_stats) { + if (is_object($stats) && method_exists($stats, 'getHandlerStats')) { + $handler_stats = (array) $stats->getHandlerStats(); + } + }; + + try { + $response = $http_client->request($method, $url, $request_options); + } catch (GuzzleException $e) { + $error_message = "HTTP [{$method}] {$url} : failed. " . $e->getMessage(); + Log::error($error_message); + throw new \RuntimeException($error_message, 0, $e); + } + + self::assertGlobalHandlerPrimaryIp($handler_stats, $url, $is_proxy_used); + + $body = ''; + if (!array_key_exists('sink', $request_options)) { + $body = (string) $response->getBody(); + } + + return [ + 'body' => $body, + 'http_code' => (int) $response->getStatusCode(), + 'location' => trim($response->getHeaderLine('Location')), + 'content_disposition' => self::formatContentDispositionHeader($response->getHeaderLine('Content-Disposition')), + ]; + } + + /** + * ランタイム指定を加味してプロキシを利用するかを判定する + * + * @param array $runtime_options + * @return bool + */ + private static function shouldUseProxy(array $runtime_options = []): bool + { + $proxy_tunnel_enabled = (bool) config('connect.HTTPPROXYTUNNEL'); + if (!$proxy_tunnel_enabled) { + return false; + } + + if (array_key_exists('use_proxy', $runtime_options)) { + return (bool) $runtime_options['use_proxy']; + } + + return true; + } + + /** + * プロキシ未使用時に cURL の名前解決を固定する(DNS pinning) + * + * @param string $url + * @param array $request_options + * @return array + */ + private static function applyDnsPinning(string $url, array $request_options): array + { + $resolve_entries = self::buildCurlResolveEntries($url); + if (empty($resolve_entries)) { + return $request_options; + } + + if (!defined('CURLOPT_RESOLVE')) { + return $request_options; + } + + $curl_options = []; + if (isset($request_options['curl']) && is_array($request_options['curl'])) { + $curl_options = $request_options['curl']; + } + + $existing_resolve_entries = []; + if (isset($curl_options[CURLOPT_RESOLVE]) && is_array($curl_options[CURLOPT_RESOLVE])) { + $existing_resolve_entries = $curl_options[CURLOPT_RESOLVE]; + } + + $curl_options[CURLOPT_RESOLVE] = array_values(array_unique(array_merge($existing_resolve_entries, $resolve_entries))); + $request_options['curl'] = $curl_options; + + return $request_options; + } + + /** + * URL から cURL CURLOPT_RESOLVE 用エントリを生成する + * + * @param string $url + * @return array + */ + private static function buildCurlResolveEntries(string $url): array + { + $parsed_url = parse_url($url); + if ($parsed_url === false || !isset($parsed_url['scheme']) || !isset($parsed_url['host'])) { + throw new \RuntimeException('[migrationHttpClient] Failed to parse URL for DNS pinning: ' . $url); + } + + $host = self::normalizeHostForDnsPinning((string) $parsed_url['host']); + if ($host === '') { + throw new \RuntimeException('[migrationHttpClient] Empty host for DNS pinning: ' . $url); + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + if (!UrlUtils::isGlobalIp($host)) { + throw new \RuntimeException('[migrationHttpClient] Rejected non-global host IP for DNS pinning: ' . $host . ' URL: ' . $url); + } + + // IP直指定は再解決が発生しないため pinning 不要 + return []; + } + + $resolved_ips = self::resolveHostIpsForDnsPinning($host); + if (empty($resolved_ips)) { + throw new \RuntimeException('[migrationHttpClient] Failed to resolve host for DNS pinning: ' . $host . ' URL: ' . $url); + } + + $global_ips = []; + foreach ($resolved_ips as $resolved_ip) { + if (!UrlUtils::isGlobalIp($resolved_ip)) { + throw new \RuntimeException('[migrationHttpClient] Rejected non-global DNS result for pinning: ' . $resolved_ip . ' URL: ' . $url); + } + + $global_ips[] = $resolved_ip; + } + + if (empty($global_ips)) { + throw new \RuntimeException('[migrationHttpClient] Failed to collect global DNS results for pinning: ' . $host . ' URL: ' . $url); + } + + $port = self::extractPortForDnsPinning($parsed_url); + $pinned_addresses = array_map(function ($ip) { + return self::formatIpForCurlResolve($ip); + }, $global_ips); + + // IPv4/IPv6 のどちらか一方に固定しないよう、検証済みの全アドレスを pinning する。 + return [$host . ':' . $port . ':' . implode(',', $pinned_addresses)]; + } + + /** + * DNS pinning 用にホストを正規化する + * + * @param string $host + * @return string + */ + private static function normalizeHostForDnsPinning(string $host): string + { + if (preg_match('/^\[(.*)\]$/', $host, $matches)) { + $host = $matches[1]; + } + + $host = rtrim(strtolower($host), '.'); + if ($host === '') { + return ''; + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return $host; + } + + if (function_exists('idn_to_ascii')) { + $idn_flags = defined('IDNA_DEFAULT') ? IDNA_DEFAULT : 0; + $idn_variant = defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : 0; + $ascii_host = @idn_to_ascii($host, $idn_flags, $idn_variant); + if ($ascii_host === false || $ascii_host === '') { + return ''; + } + $host = strtolower($ascii_host); + } + + return $host; + } + + /** + * DNS pinning 用にホスト名を解決する + * + * @param string $host + * @return array + */ + private static function resolveHostIpsForDnsPinning(string $host): array + { + $ips = []; + + if (function_exists('dns_get_record')) { + $records = @dns_get_record($host, DNS_A + DNS_AAAA); + if (is_array($records)) { + foreach ($records as $record) { + if (isset($record['ip'])) { + $ips[] = $record['ip']; + } + if (isset($record['ipv6'])) { + $ips[] = $record['ipv6']; + } + } + } + } + + if (empty($ips)) { + $v4_addresses = @gethostbynamel($host); + if (is_array($v4_addresses)) { + $ips = array_merge($ips, $v4_addresses); + } + } + + $ips = array_filter(array_unique($ips), function ($ip) { + return filter_var($ip, FILTER_VALIDATE_IP) !== false; + }); + + return array_values($ips); + } + + /** + * DNS pinning 用に接続ポートを抽出する + * + * @param array $parsed_url + * @return int + */ + private static function extractPortForDnsPinning(array $parsed_url): int + { + if (isset($parsed_url['port'])) { + return (int) $parsed_url['port']; + } + + $scheme = strtolower((string) ($parsed_url['scheme'] ?? 'http')); + return $scheme === 'https' ? 443 : 80; + } + + /** + * CURLOPT_RESOLVE 用に IP を整形する(IPv6 は [] で囲む) + * + * @param string $ip + * @return string + */ + private static function formatIpForCurlResolve(string $ip): string + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + return '[' . $ip . ']'; + } + + return $ip; + } + + /** + * Guzzle(cURL) の接続先IP(primary_ip)を再検証する + * + * @param array $handler_stats + * @param string $url + * @param bool $is_proxy_used + * @return void + */ + private static function assertGlobalHandlerPrimaryIp(array $handler_stats, string $url, bool $is_proxy_used): void + { + // プロキシ経由時のprimary_ipはプロキシIPになるため除外 + if (config('connect.HTTPPROXYTUNNEL') && $is_proxy_used) { + return; + } + + $primary_ip = trim((string) ($handler_stats['primary_ip'] ?? '')); + if ($primary_ip === '') { + $message = '[migrationHttpClient] Failed to verify primary_ip: ' . $url; + Log::warning($message); + throw new \RuntimeException($message); + } + + $normalized_primary_ip = self::normalizePrimaryIpForGlobalCheck($primary_ip); + if (!UrlUtils::isGlobalIp($normalized_primary_ip)) { + $message = '[migrationHttpClient] Rejected non-global primary_ip: ' . $primary_ip . ' URL: ' . $url; + Log::warning($message); + throw new \RuntimeException($message); + } + } + + /** + * proxy オプションが実質的に設定されているかを判定する + * + * @param mixed $proxy_option + * @return bool + */ + private static function hasConfiguredProxy($proxy_option): bool + { + if (is_string($proxy_option)) { + return trim($proxy_option) !== ''; + } + + if (is_array($proxy_option)) { + foreach ($proxy_option as $value) { + if (is_string($value) && trim($value) !== '') { + return true; + } + } + + return false; + } + + return !empty($proxy_option); + } + + /** + * libcurl の primary_ip 表現差分(IPv4-mapped IPv6)を正規化する + * + * @param string $ip + * @return string + */ + private static function normalizePrimaryIpForGlobalCheck(string $ip): string + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return $ip; + } + + $binary_ip = @inet_pton($ip); + if ($binary_ip === false || strlen($binary_ip) !== 16) { + return $ip; + } + + $ipv4_mapped_prefix = str_repeat("\x00", 10) . "\xff\xff"; + if (substr($binary_ip, 0, 12) !== $ipv4_mapped_prefix) { + return $ip; + } + + $ipv4 = @inet_ntop(substr($binary_ip, 12, 4)); + return is_string($ipv4) ? $ipv4 : $ip; + } + + /** + * connect設定からGuzzle用のプロキシURLを生成する + * + * @return string|null + */ + private static function buildProxyOption(): ?string + { + $proxy = trim((string) config('connect.PROXY')); + if ($proxy === '') { + return null; + } + + if (strpos($proxy, '://') === false) { + $proxy = 'http://' . $proxy; + } + + $proxy_parts = parse_url($proxy); + if ($proxy_parts === false || !isset($proxy_parts['host'])) { + return null; + } + + $scheme = $proxy_parts['scheme'] ?? 'http'; + $host = $proxy_parts['host']; + if (strpos($host, ':') !== false && strpos($host, '[') !== 0) { + $host = '[' . $host . ']'; + } + + $port = $proxy_parts['port'] ?? null; + $config_proxy_port = trim((string) config('connect.PROXYPORT')); + if ($config_proxy_port !== '') { + $port = $config_proxy_port; + } + + $user = $proxy_parts['user'] ?? null; + $pass = $proxy_parts['pass'] ?? null; + $proxy_user_pwd = trim((string) config('connect.PROXYUSERPWD')); + if ($proxy_user_pwd !== '') { + list($user, $pass) = array_pad(explode(':', $proxy_user_pwd, 2), 2, ''); + } + + $auth = ''; + if (!empty($user)) { + $auth = rawurlencode($user); + if (!empty($pass)) { + $auth .= ':' . rawurlencode($pass); + } + $auth .= '@'; + } + + $proxy_url = $scheme . '://' . $auth . $host; + if (!empty($port)) { + $proxy_url .= ':' . $port; + } + + return $proxy_url; + } + + /** + * 既存cURLコールバック互換の形式でContent-Dispositionを返す + * + * @param string $header_value + * @return string + */ + private static function formatContentDispositionHeader(string $header_value): string + { + $header_value = trim($header_value); + if ($header_value === '') { + return ''; + } + + return 'Content-Disposition: ' . urldecode($header_value); + } +} diff --git a/app/Utilities/Url/UrlUtils.php b/app/Utilities/Url/UrlUtils.php index a53a8a5b1..8368c6d11 100644 --- a/app/Utilities/Url/UrlUtils.php +++ b/app/Utilities/Url/UrlUtils.php @@ -137,7 +137,7 @@ private static function resolveHostIps(string $host): array * @param string $ip * @return bool */ - private static function isGlobalIp(string $ip): bool + public static function isGlobalIp(string $ip): bool { if (self::isIpv4MappedIpv6($ip)) { return false; diff --git a/resources/views/plugins/manage/page/migration_order.blade.php b/resources/views/plugins/manage/page/migration_order.blade.php index 5dc00c8c7..a4ff3bd50 100644 --- a/resources/views/plugins/manage/page/migration_order.blade.php +++ b/resources/views/plugins/manage/page/migration_order.blade.php @@ -73,6 +73,22 @@
    + @if (config('connect.HTTPPROXYTUNNEL')) +
    + +
    +
    + + +
    +
    + プロキシ使用時は、接続先IP固定(DNS pinning)および接続先IP検証(primary_ip)を行いません。SSRF対策が弱くなるため、必要時のみ使用してください。 +
    + @include('plugins.common.errors_inline', ['name' => 'use_proxy']) +
    +
    + @endif + {{-- UI的に、セレクトボックスは不要だったのでとりあえず、コメントアウト
    diff --git a/tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetSecurityTest.php b/tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetSecurityTest.php new file mode 100644 index 000000000..e43b83694 --- /dev/null +++ b/tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetSecurityTest.php @@ -0,0 +1,87 @@ +seed(); + } + + /** + * admin_page権限を持つユーザーを作成する。 + */ + private function createPageAdminUser(): User + { + $user = User::factory()->create(); + UsersRoles::factory()->create([ + 'users_id' => $user->id, + 'target' => 'manage', + 'role_name' => 'admin_page', + 'role_value' => 1, + ]); + + return $user; + } + + /** + * SSRFに悪用されうる内部/予約IP URLは取り込み前に拒否されること。 + * + * @test + * @dataProvider blockedMigrationSourceUrlProvider + */ + public function internalOrReservedSourceUrlIsRejectedBeforeFetching(string $source_system, string $url): void + { + Storage::fake(); + + $admin = $this->createPageAdminUser(); + $page = Page::query()->firstOrFail(); + + $response = $this->actingAs($admin)->post("/manage/page/migrationGet/{$page->id}", [ + 'source_system' => $source_system, + 'url' => $url, + 'destination_page_id' => $page->id, + ]); + + $response->assertStatus(302); + $response->assertRedirect("/manage/page/migrationOrder/{$page->id}"); + $response->assertSessionHasErrors(['url']); + + Storage::disk(config('filesystems.default'))->assertMissing('migration/import/migration_last_request_time.txt'); + Storage::disk(config('filesystems.default'))->assertMissing("migration/import/pages/{$page->id}/frame_0001.html"); + } + + /** + * @return array + */ + public function blockedMigrationSourceUrlProvider(): array + { + return [ + 'html_localhost_loopback' => [WebsiteType::html, 'http://127.0.0.1/private'], + 'html_link_local_metadata' => [WebsiteType::html, 'http://169.254.169.254/latest/meta-data'], + 'nc3_localhost_loopback' => [WebsiteType::netcommons3, 'http://127.0.0.1/nc3/index.html'], + 'nc3_link_local_metadata' => [WebsiteType::netcommons3, 'http://169.254.169.254/nc3/index.html'], + ]; + } +} diff --git a/tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetValidationTest.php b/tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetValidationTest.php new file mode 100644 index 000000000..7f3b1b856 --- /dev/null +++ b/tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetValidationTest.php @@ -0,0 +1,133 @@ +seed(); + } + + /** + * admin_page権限を持つユーザーを作成する。 + */ + private function createPageAdminUser(): User + { + $user = User::factory()->create(); + UsersRoles::factory()->create([ + 'users_id' => $user->id, + 'target' => 'manage', + 'role_name' => 'admin_page', + 'role_value' => 1, + ]); + + return $user; + } + + /** + * グローバルHTTP URL は受け付けられ、取り込みリクエスト時刻が記録されること。 + * + * @test + */ + public function globalHttpUrlCanPassValidationAndRequestTimeIsRecorded(): void + { + Storage::fake(); + + $admin = $this->createPageAdminUser(); + $page = Page::query()->firstOrFail(); + + $response = $this->actingAs($admin)->post("/manage/page/migrationGet/{$page->id}", [ + 'source_system' => WebsiteType::netcommons2, + 'url' => 'http://8.8.8.8/nc2/index.html', + 'destination_page_id' => $page->id, + ]); + + $response->assertOk(); + $response->assertSee('本機能(Webスクレイピング)を利用するに当たっての注意点'); + $response->assertSessionDoesntHaveErrors(['url']); + + Storage::disk(config('filesystems.default'))->assertExists('migration/import/migration_last_request_time.txt'); + Storage::disk(config('filesystems.default'))->assertMissing("migration/import/pages/{$page->id}/frame_0001.html"); + + $last_request_time = Storage::disk(config('filesystems.default'))->get('migration/import/migration_last_request_time.txt'); + $this->assertMatchesRegularExpression('/^\d+$/', $last_request_time); + } + + /** + * プロキシ設定が未設定でも、プロキシ使用チェック未選択なら検証エラーにならないこと。 + * + * @test + */ + public function migrationGetCanProceedWhenUseProxyCheckboxIsUncheckedEvenIfProxyConfigIsMissing(): void + { + Storage::fake(); + config([ + 'connect.HTTPPROXYTUNNEL' => false, + 'connect.PROXY' => '', + ]); + + $admin = $this->createPageAdminUser(); + $page = Page::query()->firstOrFail(); + + $response = $this->actingAs($admin)->post("/manage/page/migrationGet/{$page->id}", [ + 'source_system' => WebsiteType::netcommons2, + 'url' => 'http://8.8.8.8/nc2/index.html', + 'destination_page_id' => $page->id, + ]); + + $response->assertOk(); + $response->assertSessionDoesntHaveErrors(['use_proxy']); + Storage::disk(config('filesystems.default'))->assertExists('migration/import/migration_last_request_time.txt'); + } + + /** + * プロキシ使用チェック選択時は、移行用プロキシ設定が未設定なら検証エラーになること。 + * + * @test + */ + public function migrationGetFailsWhenUseProxyCheckboxIsCheckedAndProxyConfigIsMissing(): void + { + Storage::fake(); + config([ + 'connect.HTTPPROXYTUNNEL' => false, + 'connect.PROXY' => '', + ]); + + $admin = $this->createPageAdminUser(); + $page = Page::query()->firstOrFail(); + + $response = $this->actingAs($admin)->post("/manage/page/migrationGet/{$page->id}", [ + 'source_system' => WebsiteType::netcommons2, + 'url' => 'http://8.8.8.8/nc2/index.html', + 'use_proxy' => 'on', + 'destination_page_id' => $page->id, + ]); + + $response->assertStatus(302); + $response->assertRedirect("/manage/page/migrationOrder/{$page->id}"); + $response->assertSessionHasErrors(['use_proxy']); + + Storage::disk(config('filesystems.default'))->assertMissing('migration/import/migration_last_request_time.txt'); + } +} diff --git a/tests/Support/Migration/MigrationHttpClientUtilsDnsFunctionMocks.php b/tests/Support/Migration/MigrationHttpClientUtilsDnsFunctionMocks.php new file mode 100644 index 000000000..097a4dab5 --- /dev/null +++ b/tests/Support/Migration/MigrationHttpClientUtilsDnsFunctionMocks.php @@ -0,0 +1,90 @@ +fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + + $target->runMigrationHtmlPage('http://127.0.0.1/test/index.html', 1001); + + $this->assertSame([], $target->requestedGetUrls()); + $this->assertFrameNotSaved(1001); + } + + /** + * グローバルURLはHTTPヘルパーを呼び、HTMLを保存すること + * + * @return void + */ + public function testGlobalUrlHtmlIsImported() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $target->queueGetResponse([ + 'body' => '

    ok

    ', + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/source/index.html'; + $page_id = 1002; + $target->runMigrationHtmlPage($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertFrameSavedContains($page_id, '

    ok

    '); + } + + /** + * DOMDocumentの解析警告が発生するHTMLでも取り込みを継続して保存すること + * + * @return void + */ + public function testImportContinuesWhenDomDocumentEmitsHtmlParseWarnings() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $target->queueGetResponse([ + 'body' => '

    before &nobr; after

    ok

    ', + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/source/invalid-entity.html'; + $page_id = 1006; + + $target->runMigrationHtmlPage($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertFrameSavedContains($page_id, '

    ok

    '); + } + + /** + * リダイレクト先が非グローバルURLなら保存せずに中断すること + * + * @return void + */ + public function testImportStopsWhenRedirectDestinationIsNonGlobal() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $target->queueGetResponse([ + 'body' => '', + 'http_code' => 302, + 'location' => 'http://127.0.0.1/internal/index.html', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/start/index.html'; + $page_id = 1003; + $target->runMigrationHtmlPage($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertFrameNotSaved($page_id); + } + + /** + * グローバルなリダイレクト先には追従してHTMLを保存すること + * + * @return void + */ + public function testImportFollowsGlobalRedirectAndSavesHtml() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $target->queueGetResponse([ + 'body' => '', + 'http_code' => 302, + 'location' => '/moved/index.html', + 'content_disposition' => '', + ]); + $target->queueGetResponse([ + 'body' => '

    redirected

    ', + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $start_url = 'http://8.8.8.8/start/index.html'; + $page_id = 1004; + $target->runMigrationHtmlPage($start_url, $page_id); + + $this->assertSame([ + 'http://8.8.8.8/start/index.html', + 'http://8.8.8.8/moved/index.html', + ], $target->requestedGetUrls()); + $this->assertFrameSavedContains($page_id, '

    redirected

    '); + } + + /** + * 画像ダウンロード失敗時は画像をスキップし、本文の保存は継続すること + * + * @return void + */ + public function testImportSkipsImageWhenImageDownloadFailsAndStillSavesBody() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $image_url = 'http://8.8.8.8/files/missing.png'; + $target->queueGetResponse([ + 'body' => '

    img

    KeepBody

    ', + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + $target->queueDownloadException(new \RuntimeException('image download failed')); + + $page_id = 1005; + $target->runMigrationHtmlPage('http://8.8.8.8/source/image-fail.html', $page_id); + + $this->assertSame(['http://8.8.8.8/source/image-fail.html'], $target->requestedGetUrls()); + $this->assertSame([$image_url], $target->requestedDownloadUrls()); + + $html = Storage::get("migration/import/pages/{$page_id}/frame_0001.html"); + $ini = Storage::get("migration/import/pages/{$page_id}/frame_0001.ini"); + + $this->assertStringContainsString('KeepBody', $html); + $this->assertStringContainsString($image_url, $html); + $this->assertStringNotContainsString('frame_0001_1.', $html); + $this->assertStringContainsString('[image_names]', $ini); + $this->assertStringNotContainsString('frame_0001_1', $ini); + } + + /** + * 移行用ストレージをfake化する + * + * @return void + */ + private function fakeMigrationStorage(): void + { + Storage::fake(config('filesystems.default', 'local')); + } + + /** + * フレームHTML/INIが保存されていないことを検証する + * + * @param int $page_id + * @return void + */ + private function assertFrameNotSaved(int $page_id): void + { + Storage::assertMissing("migration/import/pages/{$page_id}/frame_0001.html"); + Storage::assertMissing("migration/import/pages/{$page_id}/frame_0001.ini"); + } + + /** + * フレームHTML/INIが保存され、HTML内に期待文字列を含むことを検証する + * + * @param int $page_id + * @param string $expected_fragment + * @return void + */ + private function assertFrameSavedContains(int $page_id, string $expected_fragment): void + { + Storage::assertExists("migration/import/pages/{$page_id}/frame_0001.html"); + Storage::assertExists("migration/import/pages/{$page_id}/frame_0001.ini"); + $this->assertStringContainsString($expected_fragment, Storage::get("migration/import/pages/{$page_id}/frame_0001.html")); + } +} + +// phpcs:ignore PSR1.Classes.ClassDeclaration.MultipleClasses -- Test double is intentionally colocated with the test. +class TestableMigrationExportHtmlPageTraitTarget +{ + use MigrationExportHtmlPageTrait { + migrationHtmlPage as private traitMigrationHtmlPage; + } + + /** @var array */ + private $http_get_calls = []; + + /** @var array */ + private $queued_get_responses = []; + + /** @var array */ + private $download_calls = []; + + /** @var array */ + private $queued_download_results = []; + + public function runMigrationHtmlPage(string $url, int $page_id): void + { + $this->traitMigrationHtmlPage($url, $page_id); + } + + public function queueGetResponse(array $response): void + { + $this->queued_get_responses[] = $response; + } + + /** + * ダウンロード例外をキューに積む + * + * @param \RuntimeException $exception + * @return void + */ + public function queueDownloadException(\RuntimeException $exception): void + { + $this->queued_download_results[] = ['type' => 'exception', 'value' => $exception]; + } + + /** + * GETリクエストされたURL一覧を返す + * + * @return array + */ + public function requestedGetUrls(): array + { + return $this->http_get_calls; + } + + /** + * ダウンロードURL一覧を返す + * + * @return array + */ + public function requestedDownloadUrls(): array + { + return $this->download_calls; + } + + protected function migrationHttpCreateClient(array $http_options = []): Client + { + return new Client(); + } + + protected function migrationHttpGet(Client $http_client, string $url, array $http_options = []): array + { + $this->http_get_calls[] = $url; + + if (empty($this->queued_get_responses)) { + throw new \RuntimeException('No queued migrationHttpGet response.'); + } + + return array_shift($this->queued_get_responses); + } + + protected function migrationHttpDownloadToFile(Client $http_client, string $url, string $sink_path, array $http_options = []): array + { + $this->download_calls[] = $url; + + if (empty($this->queued_download_results)) { + throw new \RuntimeException('Unexpected migrationHttpDownloadToFile call in this test.'); + } + + $queued = array_shift($this->queued_download_results); + if ($queued['type'] === 'exception') { + throw $queued['value']; + } + + throw new \RuntimeException('Unsupported queued download result type.'); + } +} diff --git a/tests/Unit/Traits/Migration/MigrationExportNc3PageTraitTest.php b/tests/Unit/Traits/Migration/MigrationExportNc3PageTraitTest.php new file mode 100644 index 000000000..9fd2a5eff --- /dev/null +++ b/tests/Unit/Traits/Migration/MigrationExportNc3PageTraitTest.php @@ -0,0 +1,555 @@ +real_storage_page_ids_to_cleanup as $page_id) { + File::deleteDirectory(storage_path("app/migration/import/pages/{$page_id}")); + } + + parent::tearDown(); + } + + /** + * 非グローバルURLは事前検証で拒否し、インポートしないこと + * + * @return void + */ + public function testNonGlobalUrlIsNotImported() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + + $target->runMigrationNc3Page('http://127.0.0.1/nc3/index.html', 2001); + + $this->assertSame([], $target->requestedGetUrls()); + $this->assertFrameNotSaved(2001, 1); + } + + /** + * ページ取得失敗時は中断し、フレームを保存しないこと + * + * @return void + */ + public function testImportStopsWhenPageFetchFails() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $target->queueGetException(new RuntimeException('network failed')); + + $url = 'http://8.8.8.8/nc3/index.html'; + $target->runMigrationNc3Page($url, 2002); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertFrameNotSaved(2002, 1); + } + + /** + * NC3の1フレームをConnect-CMS形式として保存できること + * + * @return void + */ + public function testSingleNc3SectionIsImportedAsSingleFrame() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-101', + 'class' => 'panel panel-primary', + 'title' => 'Notice', + 'content' => '

    BodyA

    ', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/nc3/index.html'; + $page_id = 2003; + $target->runMigrationNc3Page($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertNc3FrameSavedContains($page_id, 1, '

    BodyA

    '); + $this->assertStringContainsString('frame_title = "Notice"', Storage::get("migration/import/pages/{$page_id}/frame_0001.ini")); + $this->assertStringContainsString('source_key = "101"', Storage::get("migration/import/pages/{$page_id}/frame_0001.ini")); + } + + /** + * DOMDocumentの解析警告が発生するNC3 HTMLでも取り込みを継続して保存すること + * + * @return void + */ + public function testImportContinuesWhenDomDocumentEmitsHtmlParseWarnings() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-111', + 'class' => 'panel panel-primary', + 'title' => 'WarnHtml', + 'content' => '

    before &nobr; after

    BodyWarn

    ', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/nc3/warn.html'; + $page_id = 2009; + $target->runMigrationNc3Page($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertNc3FrameSavedContains($page_id, 1, '

    BodyWarn

    '); + } + + /** + * 複数セクションを複数フレームとして保存できること + * + * @return void + */ + public function testMultipleNc3SectionsAreImportedAsMultipleFrames() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-201', + 'class' => 'panel panel-info', + 'title' => 'Frame1', + 'content' => '

    Body1

    ', + ], + [ + 'id' => 'frame-202', + 'class' => 'panel panel-success', + 'title' => 'Frame2', + 'content' => '

    Body2

    ', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $page_id = 2004; + $target->runMigrationNc3Page('http://8.8.8.8/nc3/multi.html', $page_id); + + $this->assertNc3FrameSavedContains($page_id, 1, '

    Body1

    '); + $this->assertNc3FrameSavedContains($page_id, 2, '

    Body2

    '); + $this->assertStringContainsString('frame_title = "Frame1"', Storage::get("migration/import/pages/{$page_id}/frame_0001.ini")); + $this->assertStringContainsString('frame_title = "Frame2"', Storage::get("migration/import/pages/{$page_id}/frame_0002.ini")); + } + + /** + * 画像ダウンロードを経由して画像参照をローカルファイル名へ置換すること + * + * @return void + */ + public function testImageDownloadIsImportedAndReferencedByLocalFileName() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $image_url = 'http://8.8.8.8/files/image.png'; + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-301', + 'class' => 'panel panel-primary', + 'title' => 'WithImage', + 'content' => '

    img

    ', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + $target->queueDownloadResponse([ + 'url' => $image_url, + 'bytes' => $this->tinyPngBinary(), + 'content_disposition' => "Content-Disposition: attachment;filename*=UTF-8''sample-image.png", + ]); + + $page_id = 2005; + $target->runMigrationNc3Page('http://8.8.8.8/nc3/image.html', $page_id); + + $this->assertSame([$image_url], $target->requestedDownloadUrls()); + $html = Storage::get("migration/import/pages/{$page_id}/frame_0001.html"); + $ini = Storage::get("migration/import/pages/{$page_id}/frame_0001.ini"); + + $this->assertStringContainsString('frame_0001_1.png', $html); + $this->assertStringNotContainsString($image_url, $html); + $this->assertStringContainsString('[image_names]', $ini); + $this->assertStringContainsString('frame_0001_1.png = "sample-image.png"', $ini); + } + + /** + * 添付ファイルダウンロードを経由してリンク参照をローカルファイル名へ置換すること + * + * @return void + */ + public function testAttachmentDownloadIsImportedAndReferencedByLocalFileName() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $file_url = 'http://8.8.8.8/cabinet_files/download/123'; + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-401', + 'class' => 'panel panel-info', + 'title' => 'WithFile', + 'content' => '

    download

    ', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + $target->queueDownloadResponse([ + 'url' => $file_url, + 'bytes' => "dummy file body\n", + 'content_disposition' => "Content-Disposition: attachment;filename*=UTF-8''doc.txt", + ]); + + $page_id = 2006; + $target->runMigrationNc3Page('http://8.8.8.8/nc3/file.html', $page_id); + + $this->assertSame([$file_url], $target->requestedDownloadUrls()); + $html = Storage::get("migration/import/pages/{$page_id}/frame_0001.html"); + $ini = Storage::get("migration/import/pages/{$page_id}/frame_0001.ini"); + + $this->assertStringContainsString('frame_0001_file_1.txt', $html); + $this->assertStringNotContainsString($file_url, $html); + $this->assertStringContainsString('[file_names]', $ini); + $this->assertStringContainsString('frame_0001_file_1.txt = "doc.txt"', $ini); + } + + /** + * 画像ダウンロード失敗時は画像をスキップし、本文の保存は継続すること + * + * @return void + */ + public function testImportSkipsImageWhenImageDownloadFailsAndStillSavesBody() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $image_url = 'http://8.8.8.8/files/missing.png'; + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-501', + 'class' => 'panel panel-primary', + 'title' => 'ImageFail', + 'content' => '

    img

    KeepBody

    ', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + $target->queueDownloadException(new RuntimeException('image download failed')); + + $page_id = 2007; + $target->runMigrationNc3Page('http://8.8.8.8/nc3/image-fail.html', $page_id); + + $this->assertSame([$image_url], $target->requestedDownloadUrls()); + $html = Storage::get("migration/import/pages/{$page_id}/frame_0001.html"); + $ini = Storage::get("migration/import/pages/{$page_id}/frame_0001.ini"); + + $this->assertStringContainsString('KeepBody', $html); + $this->assertStringContainsString($image_url, $html); + $this->assertStringNotContainsString('frame_0001_1.png', $html); + $this->assertStringContainsString('[image_names]', $ini); + $this->assertStringNotContainsString('frame_0001_1.png =', $ini); + } + + /** + * 添付ダウンロード失敗時は添付をスキップし、本文の保存は継続すること + * + * @return void + */ + public function testImportSkipsAttachmentWhenAttachmentDownloadFailsAndStillSavesBody() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $file_url = 'http://8.8.8.8/cabinet_files/download/999'; + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-601', + 'class' => 'panel panel-info', + 'title' => 'FileFail', + 'content' => '

    download

    KeepBody

    ', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + $target->queueDownloadException(new RuntimeException('attachment download failed')); + + $page_id = 2008; + $target->runMigrationNc3Page('http://8.8.8.8/nc3/file-fail.html', $page_id); + + $this->assertSame([$file_url], $target->requestedDownloadUrls()); + $html = Storage::get("migration/import/pages/{$page_id}/frame_0001.html"); + $ini = Storage::get("migration/import/pages/{$page_id}/frame_0001.ini"); + + $this->assertStringContainsString('KeepBody', $html); + $this->assertStringContainsString($file_url, $html); + $this->assertStringNotContainsString('frame_0001_file_1.txt', $html); + $this->assertStringContainsString('[file_names]', $ini); + $this->assertStringNotContainsString('frame_0001_file_1.txt =', $ini); + } + + /** + * 移行用ストレージをfake化する + * + * @return void + */ + private function fakeMigrationStorage(): void + { + Storage::fake(config('filesystems.default', 'local')); + } + + /** + * 指定フレームのHTML/INIが保存されていないことを検証する + * + * @param int $page_id + * @param int $frame_index + * @return void + */ + private function assertFrameNotSaved(int $page_id, int $frame_index): void + { + $frame = sprintf('%04d', $frame_index); + Storage::assertMissing("migration/import/pages/{$page_id}/frame_{$frame}.html"); + Storage::assertMissing("migration/import/pages/{$page_id}/frame_{$frame}.ini"); + } + + /** + * 指定フレームのHTML/INIが保存され、HTMLに期待文字列を含むことを検証する + * + * @param int $page_id + * @param int $frame_index + * @param string $expected_fragment + * @return void + */ + private function assertNc3FrameSavedContains(int $page_id, int $frame_index, string $expected_fragment): void + { + $frame = sprintf('%04d', $frame_index); + Storage::assertExists("migration/import/pages/{$page_id}/frame_{$frame}.html"); + Storage::assertExists("migration/import/pages/{$page_id}/frame_{$frame}.ini"); + $this->assertStringContainsString($expected_fragment, Storage::get("migration/import/pages/{$page_id}/frame_{$frame}.html")); + } + + /** + * テスト用の最小NC3 HTMLを生成する + * + * @param array $sections + * @return string + */ + private function buildNc3Html(array $sections): string + { + $section_html = ''; + foreach ($sections as $section) { + $section_html .= '
    '; + $section_html .= '
    ' . $section['title'] . '
    '; + $section_html .= '
    ' . $section['content'] . '
    '; + $section_html .= '
    '; + } + + return '
    ' . $section_html . '
    '; + } + + /** + * 1x1 PNGのバイナリを返す + * + * @return string + */ + private function tinyPngBinary(): string + { + return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0r8AAAAASUVORK5CYII='); + } +} + +// phpcs:ignore PSR1.Classes.ClassDeclaration.MultipleClasses -- Test double is intentionally colocated with the test. +class TestableMigrationExportNc3PageTraitTarget +{ + use MigrationExportNc3PageTrait { + migrationNC3Page as private traitMigrationNc3Page; + } + + /** @var array */ + private $http_get_calls = []; + + /** @var array */ + private $queued_get_results = []; + + /** @var array */ + private $download_calls = []; + + /** @var array */ + private $queued_download_results = []; + + public function runMigrationNc3Page(string $url, int $page_id): void + { + $this->traitMigrationNc3Page($url, $page_id); + } + + /** + * GETリクエストされたURL一覧を返す + * + * @return array + */ + public function requestedGetUrls(): array + { + return $this->http_get_calls; + } + + /** + * ページ取得レスポンスをキューに積む + * + * @param array $response + * @return void + */ + public function queueGetResponse(array $response): void + { + $this->queued_get_results[] = ['type' => 'response', 'value' => $response]; + } + + /** + * ページ取得例外をキューに積む + * + * @param \RuntimeException $exception + * @return void + */ + public function queueGetException(RuntimeException $exception): void + { + $this->queued_get_results[] = ['type' => 'exception', 'value' => $exception]; + } + + /** + * ダウンロード応答をキューに積む + * + * @param array $response + * @return void + */ + public function queueDownloadResponse(array $response): void + { + $this->queued_download_results[] = ['type' => 'response', 'value' => $response]; + } + + /** + * ダウンロード例外をキューに積む + * + * @param \RuntimeException $exception + * @return void + */ + public function queueDownloadException(RuntimeException $exception): void + { + $this->queued_download_results[] = ['type' => 'exception', 'value' => $exception]; + } + + /** + * ダウンロードURL一覧を返す + * + * @return array + */ + public function requestedDownloadUrls(): array + { + return $this->download_calls; + } + + protected function migrationHttpCreateClientForNc3(array $http_options = []) + { + return new Client(); + } + + protected function migrationHttpGetForNc3($http_client, string $url, array $http_options = []): array + { + $this->http_get_calls[] = $url; + + if (empty($this->queued_get_results)) { + throw new RuntimeException('No queued migrationHttpGetForNc3 result.'); + } + + $queued = array_shift($this->queued_get_results); + if ($queued['type'] === 'exception') { + throw $queued['value']; + } + + return $queued['value']; + } + + protected function migrationHttpDownloadToFileForNc3($http_client, string $url, string $sink_path, array $http_options = []): array + { + $this->download_calls[] = $url; + + if (empty($this->queued_download_results)) { + throw new RuntimeException('No queued migrationHttpDownloadToFileForNc3 result.'); + } + + $queued = array_shift($this->queued_download_results); + if ($queued['type'] === 'exception') { + throw $queued['value']; + } + + $response = $queued['value']; + if (isset($response['url']) && $response['url'] !== $url) { + throw new RuntimeException('Unexpected download URL. expected=' . $response['url'] . ' actual=' . $url); + } + + $bytes = (string) ($response['bytes'] ?? ''); + + // trait内のgetimagesize()用に実ファイルも作成する(storage_path/app直参照のため) + $directory = dirname($sink_path); + if (!is_dir($directory)) { + mkdir($directory, 0777, true); + } + file_put_contents($sink_path, $bytes); + + // Storage::fake() 側のファイル操作(exists/move/delete)でも見えるように同時に保存する + $storage_app_prefix = storage_path('app') . DIRECTORY_SEPARATOR; + if (strpos($sink_path, $storage_app_prefix) === 0) { + $relative_path = str_replace(DIRECTORY_SEPARATOR, '/', substr($sink_path, strlen($storage_app_prefix))); + Storage::put($relative_path, $bytes); + } + + return [ + 'body' => '', + 'http_code' => (int) ($response['http_code'] ?? 200), + 'location' => (string) ($response['location'] ?? ''), + 'content_disposition' => (string) ($response['content_disposition'] ?? ''), + ]; + } +} diff --git a/tests/Unit/Utilities/Migration/MigrationHttpClientUtilsTest.php b/tests/Unit/Utilities/Migration/MigrationHttpClientUtilsTest.php new file mode 100644 index 000000000..3cccf1506 --- /dev/null +++ b/tests/Unit/Utilities/Migration/MigrationHttpClientUtilsTest.php @@ -0,0 +1,470 @@ + 7, + 'connect.HTTPPROXYTUNNEL' => true, + 'connect.PROXY' => 'proxy.example.local', + 'connect.PROXYPORT' => '8080', + 'connect.PROXYUSERPWD' => 'user:pass', + ]); + + $options = MigrationHttpClientUtils::buildClientOptions(); + + $this->assertSame(false, $options['http_errors']); + $this->assertSame(false, $options['allow_redirects']); + $this->assertSame(7.0, $options['timeout']); + $this->assertSame('http://user:pass@proxy.example.local:8080', $options['proxy']); + } + + /** + * ランタイム指定でプロキシを無効化できること + * + * @return void + */ + public function testClientOptionsCanDisableProxyWithRuntimeOption() + { + config([ + 'connect.CURL_TIMEOUT' => 7, + 'connect.HTTPPROXYTUNNEL' => true, + 'connect.PROXY' => 'proxy.example.local', + 'connect.PROXYPORT' => '8080', + 'connect.PROXYUSERPWD' => 'user:pass', + ]); + + $options = MigrationHttpClientUtils::buildClientOptions(['use_proxy' => false]); + + $this->assertSame(false, $options['http_errors']); + $this->assertSame(false, $options['allow_redirects']); + $this->assertSame(7.0, $options['timeout']); + $this->assertArrayNotHasKey('proxy', $options); + } + + /** + * HTTPクライアントを生成できること + * + * @return void + */ + public function testHttpClientCanBeCreated() + { + config(['connect.HTTPPROXYTUNNEL' => false]); + + $client = MigrationHttpClientUtils::createClient(); + + $this->assertInstanceOf(Client::class, $client); + } + + /** + * 文字列GETでレスポンス情報が返ること + * + * @return void + */ + public function testGetReturnsResponseInformation() + { + config(['connect.HTTPPROXYTUNNEL' => false]); + + $captured_request = []; + $client = $this->createMock(Client::class); + $client->expects($this->once()) + ->method('request') + ->willReturnCallback(function ($method, $url, $options) use (&$captured_request) { + $captured_request = compact('method', 'url', 'options'); + $options['on_stats']($this->fakeStats(['primary_ip' => '8.8.8.8'])); + + return new Response(200, ['Location' => '/next'], 'hello'); + }); + + $response = MigrationHttpClientUtils::get($client, 'https://8.8.8.8'); + + $this->assertSame('GET', $captured_request['method']); + $this->assertSame('https://8.8.8.8', $captured_request['url']); + $this->assertArrayHasKey('on_stats', $captured_request['options']); + $this->assertTrue(is_callable($captured_request['options']['on_stats'])); + $this->assertSame('hello', $response['body']); + $this->assertSame(200, $response['http_code']); + $this->assertSame('/next', $response['location']); + $this->assertSame('', $response['content_disposition']); + } + + /** + * DNS pinning では dual-stack の解決結果をまとめて固定し、IPv4/IPv6 の片方へ強制しないこと + * + * @return void + */ + public function testGetAppliesDnsPinningWithAllGlobalResolvedAddresses() + { + if (!defined('CURLOPT_RESOLVE')) { + $this->markTestSkipped('CURLOPT_RESOLVE が利用できません。'); + } + + config(['connect.HTTPPROXYTUNNEL' => false]); + + MigrationHttpClientUtilsDnsFunctionMocks::setDnsGetRecordCallback(function ($host, $type) { + $this->assertSame('example.com', $host); + $this->assertSame(DNS_A + DNS_AAAA, $type); + + return [ + ['ipv6' => '2001:4860:4860::8888'], + ['ip' => '8.8.8.8'], + ]; + }); + MigrationHttpClientUtilsDnsFunctionMocks::setGethostbynamelCallback(function ($host) { + $this->fail('dns_get_record() 成功時に gethostbynamel() は呼ばれない想定です。'); + return false; + }); + + $captured_request = []; + $client = $this->createMock(Client::class); + $client->expects($this->once()) + ->method('request') + ->willReturnCallback(function ($method, $url, $options) use (&$captured_request) { + $captured_request = compact('method', 'url', 'options'); + $options['on_stats']($this->fakeStats(['primary_ip' => '2001:4860:4860::8888'])); + + return new Response(200, [], 'hello'); + }); + + $response = MigrationHttpClientUtils::get($client, 'https://example.com/path'); + + $this->assertSame('hello', $response['body']); + $this->assertArrayHasKey('curl', $captured_request['options']); + $this->assertArrayHasKey(CURLOPT_RESOLVE, $captured_request['options']['curl']); + $this->assertContains( + 'example.com:443:[2001:4860:4860::8888],8.8.8.8', + $captured_request['options']['curl'][CURLOPT_RESOLVE] + ); + } + + /** + * IDN URL は DNS pinning 用にも punycode ホスト名へ正規化されること + * + * @return void + */ + public function testGetNormalizesIdnHostnameForDnsPinning() + { + if (!defined('CURLOPT_RESOLVE')) { + $this->markTestSkipped('CURLOPT_RESOLVE が利用できません。'); + } + if (!function_exists('idn_to_ascii')) { + $this->markTestSkipped('idn_to_ascii() が利用できません。'); + } + + config(['connect.HTTPPROXYTUNNEL' => false]); + + $unicode_host = '例え.テスト'; + $idn_flags = defined('IDNA_DEFAULT') ? IDNA_DEFAULT : 0; + $idn_variant = defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : 0; + $ascii_host = idn_to_ascii($unicode_host, $idn_flags, $idn_variant); + $this->assertNotFalse($ascii_host); + $ascii_host = strtolower($ascii_host); + + $captured_dns_host = ''; + MigrationHttpClientUtilsDnsFunctionMocks::setDnsGetRecordCallback(function ($host, $type) use (&$captured_dns_host, $ascii_host) { + $captured_dns_host = $host; + $this->assertSame($ascii_host, $host); + $this->assertSame(DNS_A + DNS_AAAA, $type); + + return [ + ['ip' => '8.8.8.8'], + ]; + }); + + $captured_request = []; + $client = $this->createMock(Client::class); + $client->expects($this->once()) + ->method('request') + ->willReturnCallback(function ($method, $url, $options) use (&$captured_request) { + $captured_request = compact('method', 'url', 'options'); + $options['on_stats']($this->fakeStats(['primary_ip' => '8.8.8.8'])); + + return new Response(200, [], 'hello'); + }); + + $response = MigrationHttpClientUtils::get($client, 'https://' . $unicode_host . '/path'); + + $this->assertSame('hello', $response['body']); + $this->assertSame($ascii_host, $captured_dns_host); + $this->assertContains( + $ascii_host . ':443:8.8.8.8', + $captured_request['options']['curl'][CURLOPT_RESOLVE] + ); + } + + /** + * ファイルダウンロード時にsinkが利用され、Content-Dispositionが既存互換形式で返ること + * + * @return void + */ + public function testDownloadToFileReturnsCompatibleContentDisposition() + { + config(['connect.HTTPPROXYTUNNEL' => false]); + + $sink_path = '/tmp/migration-http-client-utils-test.bin'; + $captured_request = []; + $client = $this->createMock(Client::class); + $client->expects($this->once()) + ->method('request') + ->willReturnCallback(function ($method, $url, $options) use ($sink_path, &$captured_request) { + $captured_request = compact('method', 'url', 'options'); + $options['on_stats']($this->fakeStats(['primary_ip' => '1.1.1.1'])); + + return new Response(200, [ + 'Content-Disposition' => "attachment;filename*=UTF-8''sample.txt", + ], 'ignored-body'); + }); + + $response = MigrationHttpClientUtils::downloadToFile($client, 'https://8.8.8.8/file', $sink_path); + + $this->assertSame('GET', $captured_request['method']); + $this->assertSame('https://8.8.8.8/file', $captured_request['url']); + $this->assertSame($sink_path, $captured_request['options']['sink']); + $this->assertArrayHasKey('on_stats', $captured_request['options']); + $this->assertSame('', $response['body']); + $this->assertSame(200, $response['http_code']); + $this->assertSame('', $response['location']); + $this->assertSame("Content-Disposition: attachment;filename*=UTF-8''sample.txt", $response['content_disposition']); + } + + /** + * 非グローバルIPへ接続された場合は例外にすること + * + * @return void + */ + public function testGetFailsWhenConnectedToNonGlobalIp() + { + config(['connect.HTTPPROXYTUNNEL' => false]); + + $client = $this->createMock(Client::class); + $client->method('request') + ->willReturnCallback(function ($method, $url, $options) { + $options['on_stats']($this->fakeStats(['primary_ip' => '127.0.0.1'])); + return new Response(200, [], 'hello'); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Rejected non-global primary_ip'); + + MigrationHttpClientUtils::get($client, 'https://8.8.8.8'); + } + + /** + * DNSリバインディング相当(検証時OK/接続時NG)の状態を拒否すること + * + * 実際のDNS操作は行わず、TOCTOUの本質である + * 「事前検証ではグローバル、実接続では内部IP」を擬似的に再現する。 + * + * @return void + */ + public function testGetRejectsDnsRebindingStylePrimaryIpChange() + { + config(['connect.HTTPPROXYTUNNEL' => false]); + + $url = 'http://8.8.8.8/example'; + $this->assertTrue(UrlUtils::isGlobalHttpUrl($url), '事前URL検証は通る前提'); + + $captured_request = []; + $client = $this->createMock(Client::class); + $client->method('request') + ->willReturnCallback(function ($method, $request_url, $options) use ($url, &$captured_request) { + $captured_request = [ + 'method' => $method, + 'url' => $request_url, + 'options' => $options, + ]; + // 実接続時に内部IPへ変化した状態を再現(DNSリバインディング相当) + $options['on_stats']($this->fakeStats(['primary_ip' => '169.254.169.254'])); + + return new Response(200, [], 'hello'); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Rejected non-global primary_ip'); + + try { + MigrationHttpClientUtils::get($client, $url); + } finally { + $this->assertSame('GET', $captured_request['method']); + $this->assertSame($url, $captured_request['url']); + $this->assertArrayHasKey('on_stats', $captured_request['options']); + } + } + + /** + * HTTPPROXYTUNNEL=true でも実際にproxy未設定ならprimary_ip再検証を行うこと + * + * @return void + */ + public function testGetFailsWhenProxyTunnelEnabledButProxyIsNotConfigured() + { + config([ + 'connect.HTTPPROXYTUNNEL' => true, + 'connect.PROXY' => '', + ]); + + $client = $this->createMock(Client::class); + $client->method('request') + ->willReturnCallback(function ($method, $url, $options) { + // primary_ip を渡さない(直結通信かつ on_stats 未取得相当) + $options['on_stats']($this->fakeStats([])); + return new Response(200, [], 'direct'); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to verify primary_ip'); + + MigrationHttpClientUtils::get($client, 'https://8.8.8.8'); + } + + /** + * IPv4-mapped IPv6形式のprimary_ipでも元のIPv4がグローバルなら許可すること + * + * @return void + */ + public function testGetAcceptsIpv4MappedIpv6PrimaryIpWhenMappedIpv4IsGlobal() + { + config(['connect.HTTPPROXYTUNNEL' => false]); + + $client = $this->createMock(Client::class); + $client->method('request') + ->willReturnCallback(function ($method, $url, $options) { + $options['on_stats']($this->fakeStats(['primary_ip' => '::ffff:8.8.8.8'])); + return new Response(200, [], 'hello'); + }); + + $response = MigrationHttpClientUtils::get($client, 'https://8.8.8.8'); + + $this->assertSame('hello', $response['body']); + $this->assertSame(200, $response['http_code']); + } + + /** + * プロキシ設定が有効な経由時はprimary_ip再検証をスキップすること + * + * @return void + */ + public function testGetAllowsMissingPrimaryIpWhenUsingProxyTunnel() + { + config([ + 'connect.HTTPPROXYTUNNEL' => true, + 'connect.PROXY' => 'proxy.example.local:8080', + ]); + + $client = $this->createMock(Client::class); + $client->method('request') + ->willReturnCallback(function ($method, $url, $options) { + // primary_ip を渡さない(プロキシ経由時想定) + $options['on_stats']($this->fakeStats([])); + return new Response(200, [], 'proxied'); + }); + + $response = MigrationHttpClientUtils::get($client, 'https://8.8.8.8'); + + $this->assertSame('proxied', $response['body']); + $this->assertSame(200, $response['http_code']); + } + + /** + * ランタイム指定でプロキシを無効化した場合はprimary_ip再検証を行うこと + * + * @return void + */ + public function testGetFailsWhenRuntimeOptionDisablesProxyEvenIfProxyConfigExists() + { + config([ + 'connect.HTTPPROXYTUNNEL' => true, + 'connect.PROXY' => 'proxy.example.local:8080', + ]); + + $client = $this->createMock(Client::class); + $client->method('request') + ->willReturnCallback(function ($method, $url, $options) { + // use_proxy=false では直結相当として primary_ip 検証対象 + $options['on_stats']($this->fakeStats([])); + return new Response(200, [], 'direct-runtime'); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to verify primary_ip'); + + MigrationHttpClientUtils::get($client, 'https://8.8.8.8', ['use_proxy' => false]); + } + + /** + * ランタイム指定でプロキシ利用時はprimary_ip再検証をスキップすること + * + * @return void + */ + public function testGetAllowsMissingPrimaryIpWhenRuntimeUseProxyIsTrue() + { + config([ + 'connect.HTTPPROXYTUNNEL' => true, + 'connect.PROXY' => 'proxy.example.local:8080', + ]); + + $client = $this->createMock(Client::class); + $client->method('request') + ->willReturnCallback(function ($method, $url, $options) { + $options['on_stats']($this->fakeStats([])); + return new Response(200, [], 'proxied-runtime'); + }); + + $response = MigrationHttpClientUtils::get($client, 'https://8.8.8.8', ['use_proxy' => true]); + + $this->assertSame('proxied-runtime', $response['body']); + $this->assertSame(200, $response['http_code']); + } + + /** + * on_stats へ渡す疑似statsオブジェクトを生成する + * + * @param array $handler_stats + * @return object + */ + private function fakeStats(array $handler_stats) + { + return new class ($handler_stats) { + /** @var array */ + private $handler_stats; + + public function __construct(array $handler_stats) + { + $this->handler_stats = $handler_stats; + } + + public function getHandlerStats(): array + { + return $this->handler_stats; + } + }; + } +} From ab3188d45520ead4b4d38c4e82eef7201d7ed7fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 05:23:29 +0000 Subject: [PATCH 09/13] chore(deps): bump minimatch from 3.1.2 to 3.1.5 Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5. - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5) --- updated-dependencies: - dependency-name: minimatch dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8391c7be2..b94e4e85b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8115,9 +8115,9 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { From 4e236656b5569c2a809e937bf9fc12bf36098d90 Mon Sep 17 00:00:00 2001 From: gakigaki Date: Thu, 5 Mar 2026 10:15:47 +0900 Subject: [PATCH 10/13] chore(release): bump version to 1.41.1-rc3 --- config/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.php b/config/version.php index 2c63c55cc..ea9494bc5 100644 --- a/config/version.php +++ b/config/version.php @@ -12,7 +12,7 @@ | */ - 'cc_version' => '1.41.0', + 'cc_version' => '1.41.1-rc3', 'show_cc_version' => true, ]; From 4d44e9ee058dc6b2580f84fa10f81a3526905d97 Mon Sep 17 00:00:00 2001 From: gakigaki <32890286+gakigaki@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:58:00 +0900 Subject: [PATCH 11/13] fix(mypage): restore profile view id for shared auth partials --- app/Plugins/Mypage/ProfileMypage/ProfileMypage.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Plugins/Mypage/ProfileMypage/ProfileMypage.php b/app/Plugins/Mypage/ProfileMypage/ProfileMypage.php index 1c4f158a2..8d56cf252 100644 --- a/app/Plugins/Mypage/ProfileMypage/ProfileMypage.php +++ b/app/Plugins/Mypage/ProfileMypage/ProfileMypage.php @@ -61,6 +61,7 @@ public function index($request, $id = null) 'themes' => $request->themes, "function" => __FUNCTION__, "plugin_name" => "profile", + "id" => $user->id, "user" => $user, "users_columns" => $users_columns, "users_columns_id_select" => $users_columns_id_select, From a3dc5e4675f987911da7647f810fa8367207ea81 Mon Sep 17 00:00:00 2001 From: gakigaki Date: Wed, 11 Mar 2026 21:02:41 +0900 Subject: [PATCH 12/13] chore(release): bump version to 1.41.1-rc4 --- config/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.php b/config/version.php index ea9494bc5..6abe8b101 100644 --- a/config/version.php +++ b/config/version.php @@ -12,7 +12,7 @@ | */ - 'cc_version' => '1.41.1-rc3', + 'cc_version' => '1.41.1-rc4', 'show_cc_version' => true, ]; From 04ffb2bfe4a4421f19bad8c9b8c75e2a6d931681 Mon Sep 17 00:00:00 2001 From: gakigaki Date: Mon, 23 Mar 2026 10:42:45 +0900 Subject: [PATCH 13/13] update: update version to 1.41.1 --- config/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.php b/config/version.php index 6abe8b101..c18003334 100644 --- a/config/version.php +++ b/config/version.php @@ -12,7 +12,7 @@ | */ - 'cc_version' => '1.41.1-rc4', + 'cc_version' => '1.41.1', 'show_cc_version' => true, ];