| | @@ -64,6 +64,8 @@ |
| 64 | 64 | private string $token = ''; |
| 65 | 65 | private string $referer = ''; |
| 66 | 66 | private string $authType = ''; |
| 67 | + private int $maxResponseSize = 10485760; // 10 MiB |
| 68 | + private bool $blockPrivate = true; |
| 67 | 69 | |
| 68 | 70 | // デバッグ用 |
| 69 | 71 | private string $DEBUG_REQ_HDR = ''; |
| | @@ -316,6 +318,29 @@ |
| 316 | 318 | } |
| 317 | 319 | |
| 318 | 320 | /** |
| 321 | + * レスポンスの最大バイト数を設定する |
| 322 | + * |
| 323 | + * @param int $bytes 最大バイト数 |
| 324 | + * @return Curl このインスタンス(メソッドチェーン用) |
| 325 | + */ |
| 326 | + public function setMaxResponseSize(int $bytes): Curl { |
| 327 | + $this->maxResponseSize = $bytes; |
| 328 | + return $this; |
| 329 | + } |
| 330 | + |
| 331 | + /** |
| 332 | + * ローカル・プライベートアドレスへの接続を拒否するかどうかを設定する(デフォルトはtrue) |
| 333 | + * 有効の場合、名前解決は1回だけ行い、確認したIPに直接接続する(DNSリバインディング対策) |
| 334 | + * |
| 335 | + * @param bool $block 拒否するかどうか |
| 336 | + * @return Curl このインスタンス(メソッドチェーン用) |
| 337 | + */ |
| 338 | + public function setBlockPrivateAddresses(bool $block): Curl { |
| 339 | + $this->blockPrivate = $block; |
| 340 | + return $this; |
| 341 | + } |
| 342 | + |
| 343 | + /** |
| 319 | 344 | * リクエストを実行する |
| 320 | 345 | * |
| 321 | 346 | * @return Result 成功または失敗 |
| | @@ -355,14 +380,38 @@ |
| 355 | 380 | fwrite($this->stderr, "* 接続中: {$currentUrl}\n"); |
| 356 | 381 | } |
| 357 | 382 | |
| 358 | | - $uri = new Uri($currentUrl); |
| 359 | | - if (!$uri) { |
| 383 | + $uri = preg_match('/[\x00-\x20\x7f]/', $currentUrl) ? null : Uri::parse($currentUrl); |
| 384 | + if (!$uri || !$uri->getHost()) { |
| 360 | 385 | $this->responseError = "無効なURL: {$currentUrl}"; |
| 386 | + $this->method = $originalMethod; |
| 361 | 387 | return Result::Error($this->responseError); |
| 362 | 388 | } |
| 363 | 389 | |
| 364 | | - $scheme = $uri->getScheme() ?? 'http'; |
| 390 | + $scheme = strtolower($uri->getScheme() ?? 'http'); |
| 391 | + if ($scheme !== 'http' && $scheme !== 'https') { |
| 392 | + $this->responseError = "未対応のスキーム: {$scheme}"; |
| 393 | + $this->method = $originalMethod; |
| 394 | + return Result::Error($this->responseError); |
| 395 | + } |
| 396 | + $host = $uri->getHost(); |
| 365 | 397 | $port = $uri->getPort() ?? ($scheme === 'https' ? 443 : 80); |
| 398 | + |
| 399 | + // 名前解決は1回だけ行い、確認したIPに直接接続する(DNSリバインディング対策) |
| 400 | + $connectHost = $host; |
| 401 | + if ($this->blockPrivate) { |
| 402 | + $ip = $this->resolvePublicIp($host); |
| 403 | + if ($ip === null) { |
| 404 | + $this->responseError = "非公開アドレスへの接続を拒否: {$host}"; |
| 405 | + $this->method = $originalMethod; |
| 406 | + return Result::Error($this->responseError); |
| 407 | + } |
| 408 | + $connectHost = $ip; |
| 409 | + } |
| 410 | + |
| 411 | + // 最初と違うオリジンへリダイレクトされた場合、認証情報とクッキーを送らない |
| 412 | + $origin = "{$scheme}://{$host}:{$port}"; |
| 413 | + if (!isset($firstOrigin)) $firstOrigin = $origin; |
| 414 | + $sameOrigin = $origin === $firstOrigin; |
| 366 | 415 | $path = $uri->getPath(); |
| 367 | 416 | if ($path === '') $path = '/'; |
| 368 | 417 | if (NULL !== $uri->getQuery()) { |
| | @@ -371,7 +420,9 @@ |
| 371 | 420 | |
| 372 | 421 | // 認証 |
| 373 | 422 | $authHeader = ''; |
| 374 | | - if (!empty($this->authType) && $this->authType === 'Basic') { |
| 423 | + if (!$sameOrigin) { |
| 424 | + // 送らない |
| 425 | + } else if (!empty($this->authType) && $this->authType === 'Basic') { |
| 375 | 426 | assert_not_null($this->username); |
| 376 | 427 | assert_not_null($this->password); |
| 377 | 428 | $authHeader = "Authorization: Basic " |
| | @@ -398,14 +449,25 @@ |
| 398 | 449 | } |
| 399 | 450 | |
| 400 | 451 | // HTTPリクエストを構築 |
| 401 | | - $accept = 'Accept: */*'; |
| 402 | | - foreach ($this->headers as $h) { |
| 403 | | - if (str_contains($h, 'Accept:')) $accept = $h; |
| 452 | + // ['Name: value']形式と['Name' => 'value']形式の両方を受け付け、改行等を含むヘッダーは拒否する |
| 453 | + $hdrs = $this->normalizedHeaders(); |
| 454 | + if ($hdrs === null || preg_match('/[\r\n\0]/', $authHeader === '' ? '' : rtrim($authHeader, "\r\n"))) { |
| 455 | + $this->responseError = '不正なヘッダー(改行等を含む)'; |
| 456 | + $this->method = $originalMethod; |
| 457 | + return Result::Error($this->responseError); |
| 404 | 458 | } |
| 405 | 459 | |
| 460 | + $accept = 'Accept: '.($hdrs['accept'][1] ?? '*/*'); |
| 461 | + $userAgent = $hdrs['user-agent'][1] ?? $this->userAgent; |
| 462 | + // 自分で付けるヘッダーは重複させない |
| 463 | + unset($hdrs['accept'], $hdrs['user-agent'], $hdrs['host'], $hdrs['connection']); |
| 464 | + // 302/303でGETに変わった後に古いContent-Lengthを送らない |
| 465 | + if ($method !== 'POST' && $method !== 'PUT') unset($hdrs['content-length']); |
| 466 | + if (!$sameOrigin) unset($hdrs['authorization'], $hdrs['cookie']); |
| 467 | + |
| 406 | 468 | $request = "{$method} {$path} HTTP/1.1\r\n"; |
| 407 | | - $request .= "Host: {$uri->getHost()}\r\n"; |
| 408 | | - $request .= "User-Agent: {$this->userAgent}\r\n"; |
| 469 | + $request .= "Host: {$host}\r\n"; |
| 470 | + $request .= "User-Agent: {$userAgent}\r\n"; |
| 409 | 471 | $request .= "{$accept}\r\n"; |
| 410 | 472 | $request .= "Connection: close\r\n"; |
| 411 | 473 | |
| | @@ -415,7 +477,7 @@ |
| 415 | 477 | } |
| 416 | 478 | |
| 417 | 479 | // ヘッダーを追加 |
| 418 | | - $ext = $this->buildHeaderString(); |
| 480 | + $ext = $this->buildHeaderString($hdrs, $sameOrigin); |
| 419 | 481 | if (!empty($ext)) { |
| 420 | 482 | $request .= $ext; |
| 421 | 483 | } |
| | @@ -441,7 +503,8 @@ |
| 441 | 503 | if ($scheme === 'https') { |
| 442 | 504 | $sslOptions = [ |
| 443 | 505 | 'verify_peer' => $this->verifySSL, |
| 444 | | - 'verify_peer_name' => $this->verifySSL |
| 506 | + 'verify_peer_name' => $this->verifySSL, |
| 507 | + 'peer_name' => trim($host, '[]'), |
| 445 | 508 | ]; |
| 446 | 509 | |
| 447 | 510 | if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) { |
| | @@ -451,7 +514,7 @@ |
| 451 | 514 | $context = stream_context_create(['ssl' => $sslOptions]); |
| 452 | 515 | |
| 453 | 516 | $socket = @stream_socket_client( |
| 454 | | - "tls://{$uri->getHost()}:{$port}", |
| 517 | + "tls://{$connectHost}:{$port}", |
| 455 | 518 | $errno, |
| 456 | 519 | $errstr, |
| 457 | 520 | $this->timeout, |
| | @@ -459,7 +522,7 @@ |
| 459 | 522 | $context |
| 460 | 523 | ); |
| 461 | 524 | } else { |
| 462 | | - $socket = @fsockopen($uri->getHost(), $port, $errno, $errstr, $this->timeout); |
| 525 | + $socket = @fsockopen($connectHost, $port, $errno, $errstr, $this->timeout); |
| 463 | 526 | } |
| 464 | 527 | |
| 465 | 528 | if (!$socket) { |
| | @@ -467,6 +530,8 @@ |
| 467 | 530 | if ($this->verbose && $this->stderr) { |
| 468 | 531 | fwrite($this->stderr, "* エラー: {$this->responseError}\n"); |
| 469 | 532 | } |
| 533 | + |
| 534 | + $this->method = $originalMethod; |
| 470 | 535 | return Result::Error($this->responseError); |
| 471 | 536 | } |
| 472 | 537 | |
| | @@ -482,10 +547,32 @@ |
| 482 | 547 | $body = ''; |
| 483 | 548 | $headersComplete = false; |
| 484 | 549 | |
| 485 | | - // ヘッダーとボディを分けて読み込む |
| 486 | | - while (!feof($socket)) $rawResponse .= fread($socket, 8192); |
| 550 | + // 読み込み(大きさと合計時間に上限を付ける) |
| 551 | + // 以前はタイムアウトしてもfeof()がfalseのままで、無限ループになった |
| 552 | + $deadline = microtime(true) + $this->timeout; |
| 553 | + $readError = ''; |
| 554 | + while (!feof($socket)) { |
| 555 | + $chunk = fread($socket, 8192); |
| 556 | + if ($chunk !== false) $rawResponse .= $chunk; |
| 557 | + if (strlen($rawResponse) > $this->maxResponseSize) { |
| 558 | + $readError = "レスポンスが大き過ぎます(上限:{$this->maxResponseSize}バイト)"; |
| 559 | + break; |
| 560 | + } |
| 561 | + if (stream_get_meta_data($socket)['timed_out'] || microtime(true) > $deadline) { |
| 562 | + $readError = "タイムアウト({$this->timeout}秒)"; |
| 563 | + break; |
| 564 | + } |
| 565 | + if ($chunk === false) break; |
| 566 | + } |
| 567 | + |
| 487 | 568 | fclose($socket); |
| 488 | 569 | |
| 570 | + if ($readError !== '') { |
| 571 | + $this->responseError = $readError; |
| 572 | + $this->method = $originalMethod; |
| 573 | + return Result::Error($this->responseError); |
| 574 | + } |
| 575 | + |
| 489 | 576 | if ($rawResponse === '') { |
| 490 | 577 | $this->responseError = 'リスポンスエラー'; |
| 491 | 578 | return Result::Error($this->responseError); |
| | @@ -511,7 +598,9 @@ |
| 511 | 598 | $value = trim($value); |
| 512 | 599 | $this->responseHeaders[$name] = $value; |
| 513 | 600 | |
| 514 | | - $redirectUrl = $this->checkReds($name, $value, $currentUrl); |
| 601 | + // 以前はLocationの後のヘッダーで空文字に上書きされていた |
| 602 | + $red = $this->checkReds($name, $value, $uri); |
| 603 | + if ($red !== '') $redirectUrl = $red; |
| 515 | 604 | } |
| 516 | 605 | } |
| 517 | 606 | |
| | @@ -616,6 +705,59 @@ |
| 616 | 705 | // 機能性メソッド |
| 617 | 706 | |
| 618 | 707 | /** |
| 708 | + * ヘッダーを「小文字の名前 => [名前, 値]」の形にまとめ、改行等の注入を拒否する |
| 709 | + * |
| 710 | + * @return array|null ヘッダーの配列、不正な文字を含む場合はnull |
| 711 | + */ |
| 712 | + private function normalizedHeaders(): ?array { |
| 713 | + $out = []; |
| 714 | + foreach ($this->headers as $name => $value) { |
| 715 | + if (is_int($name)) { |
| 716 | + if (!is_string($value) || !str_contains($value, ':')) continue; |
| 717 | + [$name, $value] = explode(':', $value, 2); |
| 718 | + } |
| 719 | + $name = trim((string)$name); |
| 720 | + $value = trim((string)$value); |
| 721 | + if (!preg_match('/^[A-Za-z0-9!#$%&\'*+.^_`|~-]+$/', $name)) return null; |
| 722 | + if (preg_match('/[\r\n\0]/', $value)) return null; |
| 723 | + $out[strtolower($name)] = [$name, $value]; |
| 724 | + } |
| 725 | + |
| 726 | + if (preg_match('/[\r\n\0]/', $this->referer)) return null; |
| 727 | + foreach ($this->cookies as $name => $value) { |
| 728 | + if (preg_match('/[\x00-\x20\x7f;=,]/', (string)$name)) return null; |
| 729 | + } |
| 730 | + |
| 731 | + return $out; |
| 732 | + } |
| 733 | + |
| 734 | + /** |
| 735 | + * ホスト名を解決し、全てのIPが公開アドレスの場合だけ接続先のIPを返す |
| 736 | + * |
| 737 | + * @param string $host ホスト名又はIPアドレス |
| 738 | + * @return string|null 接続先のIP(IPv6は[]付き)、拒否する場合はnull |
| 739 | + */ |
| 740 | + private function resolvePublicIp(string $host): ?string { |
| 741 | + $host = trim($host, '[]'); |
| 742 | + if (filter_var($host, FILTER_VALIDATE_IP)) { |
| 743 | + $ips = [$host]; |
| 744 | + } else { |
| 745 | + $ips = @gethostbynamel($host) ?: []; |
| 746 | + foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $r) { |
| 747 | + if (isset($r['ipv6'])) $ips[] = $r['ipv6']; |
| 748 | + } |
| 749 | + } |
| 750 | + if (empty($ips)) return null; |
| 751 | + |
| 752 | + foreach ($ips as $ip) { |
| 753 | + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return null; |
| 754 | + } |
| 755 | + |
| 756 | + $v4 = array_values(array_filter($ips, fn($ip) => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4))); |
| 757 | + return $v4[0] ?? '['.$ips[0].']'; |
| 758 | + } |
| 759 | + |
| 760 | + /** |
| 619 | 761 | * リダイレクトURLを確認する |
| 620 | 762 | * |
| 621 | 763 | * @param string $name ヘッダー名 |
| | @@ -623,24 +765,18 @@ |
| 623 | 765 | * @param string $currentUrl 現在のURL |
| 624 | 766 | * @return string リダイレクトURL、リダイレクトがない場合は空文字 |
| 625 | 767 | */ |
| 626 | | - private function checkReds(string $name, string $value, string $currentUrl): string { |
| 768 | + private function checkReds(string $name, string $value, Uri $current): string { |
| 627 | 769 | if (!$this->followRedirects) return ''; |
| 628 | 770 | if (strtolower($name) !== 'location') return ''; |
| 629 | 771 | if ($this->responseCode < 300 || $this->responseCode >= 400) return ''; |
| 630 | | - $redirectUrl = $value; |
| 772 | + if ($value === '') return ''; |
| 631 | 773 | |
| 632 | | - if (strpos($redirectUrl, 'http') !== 0) { |
| 633 | | - if ($redirectUrl[0] === '/') { |
| 634 | | - $uri = new Uri($currentUrl); |
| 635 | | - $redirectUrl = $uri->getScheme().'://'.$uri->getHost() |
| 636 | | - .(NULL !== $uri->getPort() ? ':'.$uri->getPort() : '') |
| 637 | | - .$redirectUrl; |
| 638 | | - } else { |
| 639 | | - $redirectUrl = dirname($currentUrl).'/'.$redirectUrl; |
| 640 | | - } |
| 774 | + // 相対URL(「/x」「x」「//host/x」等)はRFC 3986の通りに解決する |
| 775 | + try { |
| 776 | + return $current->resolve($value)->toString(); |
| 777 | + } catch (\Throwable $e) { |
| 778 | + return ''; |
| 641 | 779 | } |
| 642 | | - |
| 643 | | - return $redirectUrl; |
| 644 | 780 | } |
| 645 | 781 | |
| 646 | 782 | /** |
| | @@ -648,21 +784,21 @@ |
| 648 | 784 | * |
| 649 | 785 | * @return string 構築されたヘッダー文字列 |
| 650 | 786 | */ |
| 651 | | - private function buildHeaderString(): string { |
| 787 | + private function buildHeaderString(array $hdrs, bool $sameOrigin = true): string { |
| 652 | 788 | $request = ''; |
| 653 | 789 | |
| 654 | | - // ユーザー指定のヘッダーを追加 |
| 655 | | - foreach ($this->headers as $name => $value) { |
| 790 | + // ユーザー指定のヘッダーを追加(normalizedHeaders()で確認済み) |
| 791 | + foreach ($hdrs as [$name, $value]) { |
| 656 | 792 | $request .= "{$name}: {$value}\r\n"; |
| 657 | 793 | } |
| 658 | 794 | |
| 659 | 795 | // リファラーが設定されていれば追加 |
| 660 | | - if (!empty($this->referer) && !isset($this->headers['Referer'])) { |
| 796 | + if (!empty($this->referer) && !isset($hdrs['referer'])) { |
| 661 | 797 | $request .= "Referer: {$this->referer}\r\n"; |
| 662 | 798 | } |
| 663 | 799 | |
| 664 | | - // 必要に応じてクッキーヘッダーを追加 |
| 665 | | - if (!empty($this->cookies) && !isset($this->headers['Cookie'])) { |
| 800 | + // 必要に応じてクッキーヘッダーを追加(別のオリジンには送らない) |
| 801 | + if ($sameOrigin && !empty($this->cookies) && !isset($hdrs['cookie'])) { |
| 666 | 802 | $cookieStrings = []; |
| 667 | 803 | foreach ($this->cookies as $name => $value) { |
| 668 | 804 | $cookieStrings[] = $name.'='.urlencode($value); |