url = $url; } } /** * リクエスト先のURLを設定する * * @param string $url リクエスト先のURL * @return Curl このインスタンス(メソッドチェーン用) */ public function setUrl(string $url): Curl { $this->url = $url; return $this; } /** * リクエストメソッドを設定する * * @param string $method GE又はPOST等のHTTPメソッド * @return Curl このインスタンス(メソッドチェーン用) */ public function setMethod(string $method): Curl { $this->method = strtoupper($method); return $this; } /** * リクエストのタイムアウト秒数を設定する * * @param int $seconds タイムアウト秒数 * @return Curl このインスタンス(メソッドチェーン用) */ public function setTimeout(int $seconds): Curl { $this->timeout = (int)$seconds; return $this; } /** * リクエストヘッダーを設定する * * @param array $headers リクエストヘッダーの配列 * @return Curl このインスタンス(メソッドチェーン用) */ public function setHeaders(array $headers): Curl { $this->headers = $headers; return $this; } /** * 単一のヘッダーを追加する * * @param string $name ヘッダー名 * @param mixed $value ヘッダー値 * @return Curl このインスタンス(メソッドチェーン用) */ public function addHeader(string $name, mixed $value): Curl { $this->headers[$name] = $value; return $this; } /** * リクエストのクッキーを設定する * * @param array $cookies クッキーの配列 * @return Curl このインスタンス(メソッドチェーン用) */ public function setCookies(array $cookies): Curl { $this->cookies = $cookies; return $this; } /** * 単一のクッキーを追加する * * @param string $name クッキー名 * @param mixed $value クッキー値 * @return Curl このインスタンス(メソッドチェーン用) */ public function addCookie(string $name, mixed $value): Curl { $this->cookies[$name] = $value; return $this; } /** * POSTフィールドを設定する * * @param array $fields POSTデータの配列 * @return Curl このインスタンス(メソッドチェーン用) */ public function setPostFields(array $fields): Curl { $this->postFields = $fields; return $this; } /** * 生のPOSTデータを設定する * * @param string $data 生のPOSTデータ * @return Curl このインスタンス(メソッドチェーン用) */ public function setPostRaw(string $data): Curl { $this->postRaw = $data; return $this; } /** * ユーザーエージェントを設定する * * @param string $userAgent カスタムユーザーエージェント * @return Curl このインスタンス(メソッドチェーン用) */ public function setUserAgent(string $userAgent): Curl { $this->userAgent = $userAgent; return $this; } /** * リダイレクトを追跡するかどうかを設定する * * @param bool $follow 追跡するかどうか(デフォルトはtrue) * @return Curl このインスタンス(メソッドチェーン用) */ public function setFollowRedirects(bool $follow): Curl { $this->followRedirects = (bool)$follow; return $this; } /** * 追跡するリダイレクトの最大数を設定する * * @param int $max リダイレクトの最大数 * @return Curl このインスタンス(メソッドチェーン用) */ public function setMaxRedirects(int $max): Curl { $this->maxRedirects = (int)$max; return $this; } /** * SSL証明書を検証するかどうかを設定する * * @param bool $verify SSL検証を行うかどうか(デフォルトはtrue) * @return Curl このインスタンス(メソッドチェーン用) */ public function setVerifySSL(bool $verify): Curl { $this->verifySSL = (bool)$verify; return $this; } /** * 基本認証の資格情報を設定する * * @param string $username ユーザー名 * @param string $password パスワード * @return Curl このインスタンス(メソッドチェーン用) */ public function setBasicAuth(string $username = '', string $password = ''): Curl { if ($username !== '') $this->username = $username; if ($password !== '') $this->password = $password; $this->authType = 'Basic'; return $this; } /** * トークン認証の資格情報を設定する * * @param string $token セッショントークン * @return Curl このインスタンス(メソッドチェーン用) */ public function setBearerAuth(string $token = ''): Curl { if ($token !== '') $this->token = $token; $this->authType = 'Bearer'; return $this; } /** * 認証を取りけソ * * @return Curl このインスタンス(メソッドチェーン用) */ public function unsetAuth(): Curl { $this->username = ''; $this->password = ''; $this->token = ''; $this->authType = ''; return $this; } /** * リファラーURLを設定する * * @param string $referer リファラーURL * @return Curl このインスタンス(メソッドチェーン用) */ public function setReferer(string $referer): Curl { $this->referer = $referer; return $this; } /** * 詳細ログを有効にする * * @param bool $verbose 詳細ログを有効にするかどうか * @return Curl このインスタンス(メソッドチェーン用) */ public function setVerbose(bool $verbose): Curl { $this->verbose = (bool)$verbose; return $this; } /** * エラー出力先を設定する * * @param resource $handle エラー出力先のファイルハンドル * @return Curl このインスタンス(メソッドチェーン用) */ public function setStderr($handle): Curl { $this->stderr = $handle; return $this; } /** * SSL証明書のファイルパスを設定する * * @param string $path 証明書ファイルのパス * @return Curl このインスタンス(メソッドチェーン用) */ public function setCaInfo(string $path): Curl { $this->caInfoPath = $path; return $this; } /** * レスポンスの最大バイト数を設定する * * @param int $bytes 最大バイト数 * @return Curl このインスタンス(メソッドチェーン用) */ public function setMaxResponseSize(int $bytes): Curl { $this->maxResponseSize = $bytes; return $this; } /** * ローカル・プライベートアドレスへの接続を拒否するかどうかを設定する(デフォルトはtrue) * 有効の場合、名前解決は1回だけ行い、確認したIPに直接接続する(DNSリバインディング対策) * * @param bool $block 拒否するかどうか * @return Curl このインスタンス(メソッドチェーン用) */ public function setBlockPrivateAddresses(bool $block): Curl { $this->blockPrivate = $block; return $this; } /** * リクエストを実行する * * @return Result 成功または失敗 */ public function execute(): Result { if (!CURL_ENABLED) return Result::Error('エラー:Curlは無効です。'); if (empty($this->url)) { $this->responseError = 'URLがありません'; return Result::Error($this->responseError); } // レスポンスデータのリセット $this->responseHeaders = []; $this->responseBody = ''; $this->responseCode = 0; $this->responseError = ''; $this->info = [ 'url' => $this->url, 'content_type' => '', 'http_code' => 0, 'header_size' => 0, 'request_size' => 0, 'total_time' => 0, 'redirect_count' => 0, 'redirect_url' => '', ]; $startTime = microtime(true); // ソケットベースの実装を使用する $redirectCount = 0; $currentUrl = $this->url; $originalMethod = $this->method; do { if ($this->verbose && $this->stderr) { fwrite($this->stderr, "* 接続中: {$currentUrl}\n"); } $uri = preg_match('/[\x00-\x20\x7f]/', $currentUrl) ? null : Uri::parse($currentUrl); if (!$uri || !$uri->getHost()) { $this->responseError = "無効なURL: {$currentUrl}"; $this->method = $originalMethod; return Result::Error($this->responseError); } $scheme = strtolower($uri->getScheme() ?? 'http'); if ($scheme !== 'http' && $scheme !== 'https') { $this->responseError = "未対応のスキーム: {$scheme}"; $this->method = $originalMethod; return Result::Error($this->responseError); } $host = $uri->getHost(); $port = $uri->getPort() ?? ($scheme === 'https' ? 443 : 80); // 名前解決は1回だけ行い、確認したIPに直接接続する(DNSリバインディング対策) $connectHost = $host; if ($this->blockPrivate) { $ip = $this->resolvePublicIp($host, $unresolved); if ($ip === null) { // 以前は名前解決の失敗も「非公開アドレス」と表示していた $this->responseError = $unresolved ? "名前解決に失敗: {$host}" : "非公開アドレスへの接続を拒否: {$host}"; $this->method = $originalMethod; return Result::Error($this->responseError); } $connectHost = $ip; } // 最初と違うオリジンへリダイレクトされた場合、認証情報とクッキーを送らない $origin = "{$scheme}://{$host}:{$port}"; if (!isset($firstOrigin)) $firstOrigin = $origin; $sameOrigin = $origin === $firstOrigin; $path = $uri->getPath(); if ($path === '') $path = '/'; if (NULL !== $uri->getQuery()) { $path .= '?'.$uri->getQuery(); } // 認証 $authHeader = ''; if (!$sameOrigin) { // 送らない } else if (!empty($this->authType) && $this->authType === 'Basic') { assert_not_null($this->username); assert_not_null($this->password); $authHeader = "Authorization: Basic " .base64_encode($this->username.':'.$this->password)."\r\n"; } else if (!empty($this->authType) && $this->authType === 'Bearer') { assert_not_null($this->token); $authHeader = "Authorization: Bearer ".$this->token."\r\n"; } // 送信するHTTPリクエストを構築 $method = $this->method; $httpData = ''; if ($method === 'POST' || $method === 'PUT') { if (!empty($this->postRaw)) { $httpData = $this->postRaw; } else if (!empty($this->postFields)) { $httpData = http_build_query($this->postFields); if (!isset($this->headers['Content-Type'])) { $this->headers['Content-Type'] = 'application/x-www-form-urlencoded'; } } $this->headers['Content-Length'] = strlen($httpData); } // HTTPリクエストを構築 // ['Name: value']形式と['Name' => 'value']形式の両方を受け付け、改行等を含むヘッダーは拒否する $hdrs = $this->normalizedHeaders(); if ($hdrs === null || preg_match('/[\r\n\0]/', $authHeader === '' ? '' : rtrim($authHeader, "\r\n"))) { $this->responseError = '不正なヘッダー(改行等を含む)'; $this->method = $originalMethod; return Result::Error($this->responseError); } $accept = 'Accept: '.($hdrs['accept'][1] ?? '*/*'); $userAgent = $hdrs['user-agent'][1] ?? $this->userAgent; // 自分で付けるヘッダーは重複させない unset($hdrs['accept'], $hdrs['user-agent'], $hdrs['host'], $hdrs['connection']); // 302/303でGETに変わった後に古いContent-Lengthを送らない if ($method !== 'POST' && $method !== 'PUT') unset($hdrs['content-length']); if (!$sameOrigin) unset($hdrs['authorization'], $hdrs['cookie']); $request = "{$method} {$path} HTTP/1.1\r\n"; $request .= "Host: {$host}\r\n"; $request .= "User-Agent: {$userAgent}\r\n"; $request .= "{$accept}\r\n"; $request .= "Connection: close\r\n"; // 認証ヘッダー if (!empty($authHeader)) { $request .= $authHeader; } // ヘッダーを追加 $ext = $this->buildHeaderString($hdrs, $sameOrigin); if (!empty($ext)) { $request .= $ext; } // ヘッダー終了 $request .= "\r\n"; // POSTデータを追加 if ($httpData !== '') { $request .= $httpData; } $this->DEBUG_REQ_HDR = $request; if ($this->verbose && $this->stderr) { fwrite($this->stderr, "* リクエストヘッダー:\n{$request}\n"); } // ソケット接続を確立 $errno = 0; $errstr = ''; if ($scheme === 'https') { // PHP純正のTLS 1.3・1.2(Std\Lib\Tls) $sslOptions = [ 'verify_peer' => $this->verifySSL, 'verify_peer_name' => $this->verifySSL, 'peer_name' => trim($host, '[]'), 'alpn' => ['http/1.1'], ]; if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) { $sslOptions['cafile'] = $this->caInfoPath; } try { $socket = Tls::connect($connectHost, (int)$port, $sslOptions, $this->timeout); } catch (Tls\TlsException $e) { $socket = false; $errstr = $e->getMessage(); $errno = $e->alert; } } else { $socket = @fsockopen($connectHost, $port, $errno, $errstr, $this->timeout); } if (!$socket) { $this->responseError = "接続出来ません: {$errstr} ({$errno})"; if ($this->verbose && $this->stderr) { fwrite($this->stderr, "* エラー: {$this->responseError}\n"); } $this->method = $originalMethod; return Result::Error($this->responseError); } // タイムアウトを設定 stream_set_timeout($socket, $this->timeout); // リクエストを送信 fwrite($socket, $request); // レスポンスを読み込む $rawResponse = ''; $headers = ''; $body = ''; $headersComplete = false; // 読み込み(大きさと合計時間に上限を付ける) // 以前はタイムアウトしてもfeof()がfalseのままで、無限ループになった $deadline = microtime(true) + $this->timeout; $readError = ''; while (!feof($socket)) { $chunk = fread($socket, 8192); if ($chunk !== false) $rawResponse .= $chunk; if (strlen($rawResponse) > $this->maxResponseSize) { $readError = "レスポンスが大き過ぎます(上限:{$this->maxResponseSize}バイト)"; break; } if (stream_get_meta_data($socket)['timed_out'] || Tls::timedOut($socket) || microtime(true) > $deadline) { $readError = "タイムアウト({$this->timeout}秒)"; break; } if ($chunk === false) break; } // 読み込み中のTLSのエラー(改ざん等) $tlsError = Tls::error($socket); if ($readError === '' && $tlsError !== null) $readError = $tlsError->getMessage(); fclose($socket); if ($readError !== '') { $this->responseError = $readError; $this->method = $originalMethod; return Result::Error($this->responseError); } if ($rawResponse === '') { $this->responseError = 'リスポンスエラー'; return Result::Error($this->responseError); } list($headerLines, $body) = $this->parseResponse($rawResponse); // ヘッダーを解析 $this->responseHeaders = []; $this->responseCode = 0; $redirectUrl = ''; foreach ($headerLines as $index => $header) { if ($index === 0) { if (preg_match('#HTTP/\d\.\d (\d+)#', $header, $m)) $this->responseCode = (int)$m[1]; continue; } if (strpos($header, ':') !== false) { [$name, $value] = explode(':', $header, 2); $name = trim($name); $value = trim($value); $this->responseHeaders[$name] = $value; // 以前はLocationの後のヘッダーで空文字に上書きされていた $red = $this->checkReds($name, $value, $uri); if ($red !== '') $redirectUrl = $red; } } $this->info['http_code'] = $this->responseCode; if (isset($this->responseHeaders['Transfer-Encoding']) && stripos($this->responseHeaders['Transfer-Encoding'], 'chunked') !== false) { $body = $this->decodeChunkedBody($body); } $this->info['header_size'] += strlen($headers); $this->responseBody = $body; if ($this->verbose && $this->stderr) { fwrite($this->stderr, "* レスポンスコード: {$this->responseCode}\n"); fwrite($this->stderr, "* レスポンスヘッダー:\n{$headers}\n"); if (!empty($redirectUrl)) { fwrite($this->stderr, "* リダイレクト先: {$redirectUrl}\n"); } } // リダイレクトが必要な場合 if ($redirectUrl !== '' && $redirectCount < $this->maxRedirects) { $currentUrl = $redirectUrl; $redirectCount++; $this->info['redirect_count'] = $redirectCount; $this->info['redirect_url'] = $redirectUrl; // 302や303リダイレクトはGETにメソッドを変更 if (in_array($this->responseCode, [302, 303], true)) { $this->method = 'GET'; $this->postRaw = ''; $this->postFields = []; } continue; } break; } while (true); // リクエスト完了後、元のメソッドに戻す $this->method = $originalMethod; $this->info['total_time'] = microtime(true) - $startTime; return Result::Success(); } /** * レスポンスボディを取得する * * @return string レスポンスボディ */ public function getResponseBody(): string { return $this->responseBody; } /** * レスポンスヘッダーを取得する * * @return array レスポンスヘッダーの配列 */ public function getResponseHeaders(): array { return $this->responseHeaders; } /** * HTTPレスポンスコードを取得する * * @return int HTTPレスポンスコード */ public function getResponseCode(): int { return $this->responseCode; } /** * エラーメッセージがあれば取得する * * @return string エラーメッセージ */ public function getError(): string { return $this->responseError; } /** * リクエスト/レスポンス情報を取得する * * @return array 情報の配列 */ public function getInfo(): array { return $this->info; } /** * リクエスト情報を取得する * * @return string 情報の配列 */ public function getRequestHeader(): string { return $this->DEBUG_REQ_HDR; } // 機能性メソッド /** * ヘッダーを「小文字の名前 => [名前, 値]」の形にまとめ、改行等の注入を拒否する * * @return array|null ヘッダーの配列、不正な文字を含む場合はnull */ private function normalizedHeaders(): ?array { $out = []; foreach ($this->headers as $name => $value) { if (is_int($name)) { if (!is_string($value) || !str_contains($value, ':')) continue; [$name, $value] = explode(':', $value, 2); } $name = trim((string)$name); $value = trim((string)$value); if (!preg_match('/^[A-Za-z0-9!#$%&\'*+.^_`|~-]+$/', $name)) return null; if (preg_match('/[\r\n\0]/', $value)) return null; $out[strtolower($name)] = [$name, $value]; } if (preg_match('/[\r\n\0]/', $this->referer)) return null; foreach ($this->cookies as $name => $value) { if (preg_match('/[\x00-\x20\x7f;=,]/', (string)$name)) return null; } return $out; } /** * ホスト名を解決し、全てのIPが公開アドレスの場合だけ接続先のIPを返す * * @param string $host ホスト名又はIPアドレス * @param bool|null $unresolved 名前解決に失敗した場合true * @return string|null 接続先のIP(IPv6は[]付き)、拒否する場合はnull */ private function resolvePublicIp(string $host, ?bool &$unresolved = null): ?string { $unresolved = false; $host = trim($host, '[]'); if (filter_var($host, FILTER_VALIDATE_IP)) { $ips = [$host]; } else { $ips = @gethostbynamel($host) ?: []; foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $r) { if (isset($r['ipv6'])) $ips[] = $r['ipv6']; } } if (empty($ips)) { $unresolved = true; return null; } foreach ($ips as $ip) { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return null; } $v4 = array_values(array_filter($ips, fn($ip) => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4))); return $v4[0] ?? '['.$ips[0].']'; } /** * リダイレクトURLを確認する * * @param string $name ヘッダー名 * @param string $value ヘッダー値 * @param string $currentUrl 現在のURL * @return string リダイレクトURL、リダイレクトがない場合は空文字 */ private function checkReds(string $name, string $value, Uri $current): string { if (!$this->followRedirects) return ''; if (strtolower($name) !== 'location') return ''; if ($this->responseCode < 300 || $this->responseCode >= 400) return ''; if ($value === '') return ''; // 相対URL(「/x」「x」「//host/x」等)はRFC 3986の通りに解決する try { return $current->resolve($value)->toString(); } catch (\Throwable $e) { return ''; } } /** * ヘッダー文字列を構築する * * @return string 構築されたヘッダー文字列 */ private function buildHeaderString(array $hdrs, bool $sameOrigin = true): string { $request = ''; // ユーザー指定のヘッダーを追加(normalizedHeaders()で確認済み) foreach ($hdrs as [$name, $value]) { $request .= "{$name}: {$value}\r\n"; } // リファラーが設定されていれば追加 if (!empty($this->referer) && !isset($hdrs['referer'])) { $request .= "Referer: {$this->referer}\r\n"; } // 必要に応じてクッキーヘッダーを追加(別のオリジンには送らない) if ($sameOrigin && !empty($this->cookies) && !isset($hdrs['cookie'])) { $cookieStrings = []; foreach ($this->cookies as $name => $value) { $cookieStrings[] = $name.'='.urlencode($value); } $request .= 'Cookie: '.implode('; ', $cookieStrings)."\r\n"; } return $request; } /** * レスポンスを解析してヘッダーとボディに分割する * * @param string $response 完全なHTTPレスポンス * @return array [ヘッダー配列, ボディ文字列] */ private function parseResponse(string $response): array { $parts = explode("\r\n\r\n", $response, 2); if (count($parts) < 2) return [explode("\r\n", $response), '']; $headers = explode("\r\n", $parts[0]); $body = $parts[1]; return [$headers, $body]; } /** * チャンク転送エンコーディングされたボディをデコードする * * @param string $body チャンクエンコードされたボディ * @return string デコードされたボディ */ private function decodeChunkedBody(string $body): string { $decodedBody = ''; $position = 0; while ($position < strlen($body)) { $lineEnd = strpos($body, "\r\n", $position); if ($lineEnd === false) break; $chunkSize = hexdec(substr($body, $position, $lineEnd - $position)); if ($chunkSize === 0) break; $position = $lineEnd + 2; $decodedBody .= substr($body, $position, $chunkSize); $position += $chunkSize + 2; // チャンクサイズ + CRLF } return $decodedBody; } }