Connect CMS has SSRF in the External Page Migration Feature of its Page Management Plugin
Description
Connect-CMS is a content management system. In versions on the 1.x series up to and including 1.41.0 and versions on the 2.x series up to and including 2.41.0, a Server-Side Request Forgery (SSRF) issue exists in the external page migration feature of the Page Management Plugin. Versions 1.41.1 and 2.41.1 contain a patch.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Server-Side Request Forgery (SSRF) in Connect-CMS page migration plugin allows attackers to make internal requests; patched in versions 1.41.1 and 2.41.1.
Connect-CMS, a content management system, contains a Server-Side Request Forgery (SSRF) vulnerability in the external page migration feature of its Page Management Plugin. The issue affects versions 1.x up to 1.41.0 and 2.x up to 2.41.0. The root cause is the lack of proper URL validation before making HTTP requests to user-supplied URLs, allowing an attacker to force the server to send requests to arbitrary destinations [1][2].
An attacker can exploit this by providing a malicious URL during the page migration process, such as a URL pointing to internal network resources (e.g., http://localhost/, http://192.168.x.x/). No authentication is required if the migration feature is exposed to unauthenticated users, though the exact prerequisites depend on the deployment configuration. The server will then fetch the content from the attacker-controlled URL, effectively acting as a proxy [1].
The impact of successful exploitation includes the ability to scan internal networks, access internal services (e.g., databases, cloud metadata endpoints), and potentially retrieve sensitive information that would otherwise be inaccessible from the external network. This SSRF can be leveraged for further attacks, such as port scanning or interacting with internal APIs [2].
Mitigation is available in versions 1.41.1 and 2.41.1, which introduce URL validation using UrlUtils::isGlobalHttpUrl() and enforce redirect validation during HTTP requests [1][4]. Users are strongly advised to update to the latest patched version. No workarounds have been officially documented, but restricting access to the migration feature or disabling it entirely may reduce risk until an update can be applied.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
opensource-workshop/connect-cmsPackagist | < 1.41.1 | 1.41.1 |
opensource-workshop/connect-cmsPackagist | >= 2.0.0, < 2.41.1 | 2.41.1 |
Affected products
2- Range: <=1.41.0, <=2.41.0
- opensource-workshop/connect-cmsv5Range: < 1.41.1
Patches
24a1a64a8f768Fix: GHSA-jh46-85jr-6ph9
12 files changed · +2323 −148
app/Plugins/Manage/PageManage/PageManage.php+16 −2 modified@@ -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); } // リクエストを送信した時間をファイルに書き込む
app/Traits/Migration/MigrationExportHtmlPageTrait.php+67 −118 modified@@ -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 ヘッダー処理コールバック関数 */
app/Traits/Migration/MigrationExportNc3PageTrait.php+95 −27 modified@@ -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); + } + } + /** * フレームデザインの取得 */
app/Utilities/Migration/MigrationHttpClientUtils.php+491 −0 added@@ -0,0 +1,491 @@ +<?php + +namespace App\Utilities\Migration; + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use Illuminate\Support\Facades\Log; + +use App\Utilities\Url\UrlUtils; + +class MigrationHttpClientUtils +{ + /** + * 移行処理用のHTTPクライアントを生成する + * + * @return Client + */ + public static function createClient(array $runtime_options = []): Client + { + return new Client(self::buildClientOptions($runtime_options)); + } + + /** + * connect設定から移行処理用のHTTPクライアントオプションを生成する + * (主にテストでの検証用に公開) + * + * @return array + */ + public static function buildClientOptions(array $runtime_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 (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); + } +}
app/Utilities/Url/UrlUtils.php+1 −1 modified@@ -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;
resources/views/plugins/manage/page/migration_order.blade.php+16 −0 modified@@ -73,6 +73,22 @@ </div> </div> + @if (config('connect.HTTPPROXYTUNNEL')) + <div class="form-group row"> + <label for="use_proxy" class="col-md-3 col-form-label text-md-right">プロキシ使用</label> + <div class="col-md-9"> + <div class="custom-control custom-checkbox"> + <input type="checkbox" id="use_proxy" name="use_proxy" class="custom-control-input" @if(old('use_proxy')) checked @endif> + <label class="custom-control-label" for="use_proxy">プロキシを使う</label> + </div> + <div class="alert alert-warning mt-2 mb-0 py-2"> + プロキシ使用時は、接続先IP固定(DNS pinning)および接続先IP検証(primary_ip)を行いません。SSRF対策が弱くなるため、必要時のみ使用してください。 + </div> + @include('plugins.common.errors_inline', ['name' => 'use_proxy']) + </div> + </div> + @endif + {{-- UI的に、セレクトボックスは不要だったのでとりあえず、コメントアウト <div class="form-group row"> <label for="page_name" class="col-md-3 col-form-label text-md-right">移行先ページ</label>
tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetSecurityTest.php+87 −0 added@@ -0,0 +1,87 @@ +<?php + +namespace Tests\Feature\Plugins\Manage\PageManage; + +use App\Enums\WebsiteType; +use App\Models\Common\Page; +use App\Models\Core\UsersRoles; +use App\User; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; +use Tests\TestCase; + +/** + * 外部ページ移行(URL入力)のセキュリティ検証。 + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + */ +class PageManageMigrationGetSecurityTest extends TestCase +{ + use RefreshDatabase; + + /** + * 各テスト前のセットアップ + */ + protected function setUp(): void + { + parent::setUp(); + $this->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<string, array{0: string, 1: string}> + */ + 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'], + ]; + } +}
tests/Feature/Plugins/Manage/PageManage/PageManageMigrationGetValidationTest.php+133 −0 added@@ -0,0 +1,133 @@ +<?php + +namespace Tests\Feature\Plugins\Manage\PageManage; + +use App\Enums\WebsiteType; +use App\Models\Common\Page; +use App\Models\Core\UsersRoles; +use App\User; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; +use Tests\TestCase; + +/** + * 外部ページ移行(URL入力)の正常系バリデーション検証。 + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + */ +class PageManageMigrationGetValidationTest extends TestCase +{ + use RefreshDatabase; + + /** + * 各テスト前のセットアップ + */ + protected function setUp(): void + { + parent::setUp(); + $this->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'); + } +}
tests/Support/Migration/MigrationHttpClientUtilsDnsFunctionMocks.php+90 −0 added@@ -0,0 +1,90 @@ +<?php + +namespace Tests\Support\Migration; + +class MigrationHttpClientUtilsDnsFunctionMocks +{ + /** @var callable|null */ + private static $dns_get_record_callback; + + /** @var callable|null */ + private static $gethostbynamel_callback; + + public static function reset(): void + { + self::$dns_get_record_callback = null; + self::$gethostbynamel_callback = null; + } + + public static function setDnsGetRecordCallback(?callable $callback): void + { + self::$dns_get_record_callback = $callback; + } + + public static function setGethostbynamelCallback(?callable $callback): void + { + self::$gethostbynamel_callback = $callback; + } + + /** + * @param string $host + * @param int $type + * @return array|false + */ + public static function dnsGetRecord(string $host, int $type) + { + if (is_callable(self::$dns_get_record_callback)) { + return call_user_func(self::$dns_get_record_callback, $host, $type); + } + + if (\function_exists('dns_get_record')) { + return \dns_get_record($host, $type); + } + + return false; + } + + /** + * @param string $host + * @return array|false + */ + public static function gethostbynamel(string $host) + { + if (is_callable(self::$gethostbynamel_callback)) { + return call_user_func(self::$gethostbynamel_callback, $host); + } + + if (\function_exists('gethostbynamel')) { + return \gethostbynamel($host); + } + + return false; + } +} + +namespace App\Utilities\Migration; + +use Tests\Support\Migration\MigrationHttpClientUtilsDnsFunctionMocks; + +if (!function_exists(__NAMESPACE__ . '\\dns_get_record')) { + /** + * @param string $host + * @param int $type + * @return array|false + */ + function dns_get_record($host, $type) + { + return MigrationHttpClientUtilsDnsFunctionMocks::dnsGetRecord((string) $host, (int) $type); + } +} + +if (!function_exists(__NAMESPACE__ . '\\gethostbynamel')) { + /** + * @param string $host + * @return array|false + */ + function gethostbynamel($host) + { + return MigrationHttpClientUtilsDnsFunctionMocks::gethostbynamel((string) $host); + } +}
tests/Unit/Traits/Migration/MigrationExportHtmlPageTraitTest.php+302 −0 added@@ -0,0 +1,302 @@ +<?php + +namespace Tests\Unit\Traits\Migration; + +use App\Traits\Migration\MigrationExportHtmlPageTrait; +use GuzzleHttp\Client; +use Illuminate\Support\Facades\Storage; +use Tests\TestCase; + +class MigrationExportHtmlPageTraitTest extends TestCase +{ + /** + * 非グローバルURLは事前検証で拒否し、HTTPヘルパーを呼ばないこと + * + * @return void + */ + public function testNonGlobalUrlIsNotImported() + { + $this->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' => '<html><body><p>ok</p></body></html>', + '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, '<p>ok</p>'); + } + + /** + * DOMDocumentの解析警告が発生するHTMLでも取り込みを継続して保存すること + * + * @return void + */ + public function testImportContinuesWhenDomDocumentEmitsHtmlParseWarnings() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $target->queueGetResponse([ + 'body' => '<html><body><p>before &nobr; after</p><p>ok</p></body></html>', + '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, '<p>ok</p>'); + } + + /** + * リダイレクト先が非グローバル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' => '<html><body><h1>redirected</h1></body></html>', + '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, '<h1>redirected</h1>'); + } + + /** + * 画像ダウンロード失敗時は画像をスキップし、本文の保存は継続すること + * + * @return void + */ + public function testImportSkipsImageWhenImageDownloadFailsAndStillSavesBody() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $image_url = 'http://8.8.8.8/files/missing.png'; + $target->queueGetResponse([ + 'body' => '<html><body><p><img src="' . $image_url . '" alt="img"></p><p>KeepBody</p></body></html>', + '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.'); + } +}
tests/Unit/Traits/Migration/MigrationExportNc3PageTraitTest.php+555 −0 added@@ -0,0 +1,555 @@ +<?php + +namespace Tests\Unit\Traits\Migration; + +use App\Traits\Migration\MigrationExportNc3PageTrait; +use GuzzleHttp\Client; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; +use RuntimeException; +use Tests\TestCase; + +class MigrationExportNc3PageTraitTest extends TestCase +{ + /** + * @var int[] + */ + private $real_storage_page_ids_to_cleanup = [2005, 2006, 2007, 2008]; + + protected function tearDown(): void + { + foreach ($this->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' => '<p>BodyA</p>', + ], + ]), + '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, '<p>BodyA</p>'); + $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' => '<p>before &nobr; after</p><p>BodyWarn</p>', + ], + ]), + '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, '<p>BodyWarn</p>'); + } + + /** + * 複数セクションを複数フレームとして保存できること + * + * @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' => '<p>Body1</p>', + ], + [ + 'id' => 'frame-202', + 'class' => 'panel panel-success', + 'title' => 'Frame2', + 'content' => '<p>Body2</p>', + ], + ]), + '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, '<p>Body1</p>'); + $this->assertNc3FrameSavedContains($page_id, 2, '<p>Body2</p>'); + $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' => '<p><img src="' . $image_url . '" alt="img"></p>', + ], + ]), + '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' => '<p><a href="' . $file_url . '">download</a></p>', + ], + ]), + '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' => '<p><img src="' . $image_url . '" alt="img"></p><p>KeepBody</p>', + ], + ]), + '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' => '<p><a href="' . $file_url . '">download</a></p><p>KeepBody</p>', + ], + ]), + '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 id="' . $section['id'] . '" class="' . $section['class'] . '">'; + $section_html .= '<div class="panel-heading"><span>' . $section['title'] . '</span></div>'; + $section_html .= '<div class="panel-body"><article>' . $section['content'] . '</article></div>'; + $section_html .= '</section>'; + } + + return '<html><body><div id="container-main">' . $section_html . '</div></body></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'] ?? ''), + ]; + } +}
tests/Unit/Utilities/Migration/MigrationHttpClientUtilsTest.php+470 −0 added@@ -0,0 +1,470 @@ +<?php + +namespace Tests\Unit\Utilities\Migration; + +use App\Utilities\Migration\MigrationHttpClientUtils; +use App\Utilities\Url\UrlUtils; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Response; +use RuntimeException; +use Tests\TestCase; +use Tests\Support\Migration\MigrationHttpClientUtilsDnsFunctionMocks; + +class MigrationHttpClientUtilsTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + MigrationHttpClientUtilsDnsFunctionMocks::reset(); + } + + protected function tearDown(): void + { + MigrationHttpClientUtilsDnsFunctionMocks::reset(); + parent::tearDown(); + } + + /** + * HTTPクライアント生成時に connect 設定が反映されること + * + * @return void + */ + public function testClientOptionsReflectConnectConfig() + { + config([ + 'connect.CURL_TIMEOUT' => 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; + } + }; + } +}
617a874e14b8Fix: GHSA-jh46-85jr-6ph9
5 files changed · +465 −10
app/Plugins/Manage/PageManage/PageManage.php+10 −1 modified@@ -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([
app/Traits/Migration/MigrationExportHtmlPageTrait.php+221 −9 modified@@ -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として取り出す */
app/Traits/Migration/MigrationExportNc3PageTrait.php+16 −0 modified@@ -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;
app/Utilities/Url/UrlUtils.php+169 −0 added@@ -0,0 +1,169 @@ +<?php + +namespace App\Utilities\Url; + +class UrlUtils +{ + /** + * Check if URL is http/https and resolves only to global (non private/reserved) IPs. + */ + public static function isGlobalHttpUrl(string $url): bool + { + $url = trim($url); + if ($url === '') { + return false; + } + + $parsed_url = parse_url($url); + if ($parsed_url === false || !isset($parsed_url['scheme']) || !isset($parsed_url['host'])) { + return false; + } + + $scheme = strtolower($parsed_url['scheme']); + if (!in_array($scheme, ['http', 'https'], true)) { + return false; + } + + $host = self::normalizeHost($parsed_url['host']); + if ($host === '' || self::isLocalhost($host)) { + return false; + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return self::isGlobalIp($host); + } + + $resolved_ips = self::resolveHostIps($host); + if (empty($resolved_ips)) { + return false; + } + + foreach ($resolved_ips as $ip) { + if (!self::isGlobalIp($ip)) { + return false; + } + } + + return true; + } + + /** + * URLホストを正規化する(IPv6[]除去、末尾ドット除去、IDN変換) + * + * @param string $host + * @return string + */ + private static function normalizeHost(string $host): string + { + // IPv6 literal host is returned as "[::1]" by parse_url. + 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; + } + + /** + * localhost または *.localhost かどうかを判定する + * + * @param string $host + * @return bool + */ + private static function isLocalhost(string $host): bool + { + return $host === 'localhost' || preg_match('/\.localhost$/', $host) === 1; + } + + /** + * ホスト名をDNS解決し、取得できたIP一覧を返す + * + * @param string $host + * @return array + */ + private static function resolveHostIps(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); + } + + /** + * グローバル到達可能なIPかを判定する + * + * @param string $ip + * @return bool + */ + private static function isGlobalIp(string $ip): bool + { + if (self::isIpv4MappedIpv6($ip)) { + return false; + } + + $flags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE; + return filter_var($ip, FILTER_VALIDATE_IP, $flags) !== false; + } + + /** + * IPv4-mapped IPv6 (::ffff:x.x.x.x) かどうかを判定する + * + * @param string $ip + * @return bool + */ + private static function isIpv4MappedIpv6(string $ip): bool + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return false; + } + + $binary_ip = @inet_pton($ip); + if ($binary_ip === false || strlen($binary_ip) !== 16) { + return false; + } + + return substr($binary_ip, 0, 12) === str_repeat("\x00", 10) . "\xff\xff"; + } +}
tests/Unit/Utilities/Url/UrlUtilsTest.php+49 −0 added@@ -0,0 +1,49 @@ +<?php + +namespace Tests\Unit\Utilities\Url; + +use App\Utilities\Url\UrlUtils; +use Tests\TestCase; + +class UrlUtilsTest extends TestCase +{ + /** + * @dataProvider globalHttpUrlProvider + */ + public function testIsGlobalHttpUrl(string $url, bool $expected) + { + $this->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,<h1>Hi</h1>', false], + 'deny_file_scheme' => ['file:///etc/passwd', false], + 'deny_invalid_text' => ['not-a-url', false], + ]; + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-jh46-85jr-6ph9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32279ghsaADVISORY
- github.com/opensource-workshop/connect-cms/commit/4a1a64a8f768a53e06a4239e25782d9e2e88fc63ghsax_refsource_MISCWEB
- github.com/opensource-workshop/connect-cms/commit/617a874e14b8476da7c0760a06384b9da21bdd4fghsax_refsource_MISCWEB
- github.com/opensource-workshop/connect-cms/releases/tag/v1.41.1ghsax_refsource_MISCWEB
- github.com/opensource-workshop/connect-cms/releases/tag/v2.41.1ghsax_refsource_MISCWEB
- github.com/opensource-workshop/connect-cms/security/advisories/GHSA-jh46-85jr-6ph9ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.