CURLと認証の修正

守矢諏訪子 <suwako@076.moe>、2026-11-10 16:53
コミット db6dc6f5aa25907512038029d12a38d95c2c95a7 ・親 b76f9dd ・ファイルを見る

5個のファイル、+261 -57

@@ -59,7 +59,7 @@
5959 $nyuU = '';
6060
6161 if ($doLogin) {
62- if (!\verify_csrf_token($_POST['csrf_token'])) {
62+ if (!is_string($_POST['csrf_token'] ?? null) || !\verify_csrf_token($_POST['csrf_token'])) {
6363 header('Location: /');
6464 exit();
6565 }
@@ -81,16 +81,25 @@
8181 exit();
8282 }
8383
84- $auth = new Auth($a[0]);
85- $res = $auth->isUserExist($a[0]);
86- if (!$res->isSuccess) {
87- $error = $res->message;
84+ $loginError = 'エラー:ユーザー名又はパスワードが一致していません。';
85+ $username = is_string($a[0] ?? null) ? $a[0] : '';
86+ $password = is_string($a[1] ?? null) ? $a[1] : '';
87+
88+ if ($username === '' || $password === '') {
89+ $error = $loginError;
90+ } else if ($auth->isLoginThrottled($username)) {
91+ $nyuU = $username;
92+ $error = 'エラー:ログインの試行回数が多過ぎます。暫くしてから再度お試し下さい。';
8893 } else {
89- $result = $auth->setToken($a[0], $a[1]);
94+ $auth = new Auth($username);
95+ $res = $auth->isUserExist($username);
96+ $result = $res->isSuccess ? $auth->setToken($username, $password) : \Result::Error($loginError);
9097 if (!$result->isSuccess) {
91- $nyuU = $a[0];
92- $error = $result->message;
98+ $auth->recordLoginFailure($username);
99+ $nyuU = $username;
100+ $error = $res->isSuccess ? $result->message : $loginError;
93101 } else {
102+ $auth->clearLoginFailures();
94103 header('Location: /');
95104 exit();
96105 }
@@ -64,7 +64,7 @@
6464 public function __construct(array $posts = []) {
6565 if (!ACTIVITYPUB_ENABLED) return;
6666 $this->https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://';
67- $this->domain = $this->https.($_SERVER['SERVER_NAME'] ?? '127.0.0.1:8000');
67+ $this->domain = $this->https.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000');
6868 $this->actor = FEDIINFO['actor'];
6969 $this->actorNick = FEDIINFO['actorNick'];
7070 $this->desc = FEDIINFO['desc'];
@@ -119,18 +119,13 @@
119119 }
120120
121121 $ip = $_SERVER['REMOTE_ADDR'];
122- if (!empty($_SERVER['HTTP_X_FORMARDED_FOR'])) {
123- $ipList = explode(',', $_SERVER['HTTP_X_FORMARDED_FOR']);
124- $ip = trim($ipList[0]);
125- }
126-
127122 $res = $this->purgeOldTokens($userData);
128123 if (!$res->isSuccess) return Result::Error($res->message);
129124 $userData = $res->data;
130125 $token = bin2hex(random_bytes(64));
131126 $expire = time() + $this->tokenDuration;
132127 $newToken = [
133- 'token' => $token,
128+ 'token' => hash('sha256', $token),
134129 'ip' => $ip,
135130 'createDate' => time(),
136131 'expDate' => $expire,
@@ -151,12 +146,12 @@
151146 if (!setcookie('kerozen', $token, [
152147 'expires' => $expire,
153148 'path' => '/',
154- 'domain' => $_SERVER['SERVER_NAME'],
149+ 'domain' => $_SERVER['HTTP_HOST'],
155150 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
156151 'httponly' => true,
157152 'samesite' => 'Strict'
158153 ])) {
159- logger(LogType::Auth, '【setToken】クッキーを設定出来なかった。トークン:'.$token);
154+ logger(LogType::Auth, '【setToken】クッキーを設定出来なかった。');
160155 return Result::error('エラー:クッキーを設定に失敗。');
161156 }
162157
@@ -178,8 +173,9 @@
178173 $token = $token ?? $this->token;
179174
180175 if ($userData instanceof \stdClass && $token) {
181- $userData->tokens = array_values(array_filter($userData->tokens ?? [], function($t) use ($token): bool {
182- return isset($t->token) && !hash_equals((string)$t->token, $token) && ($t->expDate ?? 0) > time();
176+ $hashed = hash('sha256', $token);
177+ $userData->tokens = array_values(array_filter($userData->tokens ?? [], function($t) use ($hashed): bool {
178+ return isset($t->token) && !hash_equals((string)$t->token, $hashed) && ($t->expDate ?? 0) > time();
183179 }));
184180
185181 $path = $this->dataDir.$this->id.'.'.$userData->username.'.json';
@@ -192,7 +188,7 @@
192188 setcookie('kerozen', '', [
193189 'expires' => 0,
194190 'path' => '/',
195- 'domain' => $_SERVER['SERVER_NAME'],
191+ 'domain' => $_SERVER['HTTP_HOST'],
196192 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
197193 'httponly' => true,
198194 'samesite' => 'Strict'
@@ -201,6 +197,68 @@
201197 return Result::success();
202198 }
203199
200+ /**
201+ * ログインの試行回数を確認する(IP毎:15分で5回、ユーザー名毎:1時間で20回)
202+ *
203+ * @param string $username ユーザー名
204+ * @return bool 制限中ならtrue
205+ */
206+ public function isLoginThrottled(string $username): bool {
207+ $now = time();
208+ $ip = $this->countFailures('ip-'.($_SERVER['REMOTE_ADDR'] ?? ''), $now - 900);
209+ $usr = $this->countFailures('user-'.$username, $now - 3600);
210+ return $ip >= 5 || $usr >= 20;
211+ }
212+
213+ /**
214+ * ログインの失敗を記録する
215+ *
216+ * @param string $username ユーザー名
217+ * @return void
218+ */
219+ public function recordLoginFailure(string $username): void {
220+ $this->addFailure('ip-'.($_SERVER['REMOTE_ADDR'] ?? ''), time() - 900);
221+ $this->addFailure('user-'.$username, time() - 3600);
222+ }
223+
224+ /**
225+ * ログイン成功時に、そのIPの失敗記録を消す
226+ *
227+ * @return void
228+ */
229+ public function clearLoginFailures(): void {
230+ $path = $this->throttlePath('ip-'.($_SERVER['REMOTE_ADDR'] ?? ''));
231+ if (is_file($path)) unlink($path);
232+ }
233+
234+ private function throttlePath(string $key): string {
235+ $dir = ROOT.'/data/ratelimit/';
236+ if (!is_dir($dir)) mkdir($dir, 0700, true);
237+ return $dir.hash('sha256', $key).'.json';
238+ }
239+
240+ private function countFailures(string $key, int $since): int {
241+ $path = $this->throttlePath($key);
242+ if (!is_file($path)) return 0;
243+ $list = json_decode((string)file_get_contents($path), true);
244+ if (!is_array($list)) return 0;
245+ return count(array_filter($list, fn($t) => is_int($t) && $t > $since));
246+ }
247+
248+ private function addFailure(string $key, int $since): void {
249+ $fp = fopen($this->throttlePath($key), 'c+');
250+ if (!$fp) return;
251+ flock($fp, LOCK_EX);
252+ $list = json_decode((string)stream_get_contents($fp), true);
253+ $list = is_array($list) ? array_values(array_filter($list, fn($t) => is_int($t) && $t > $since)) : [];
254+ $list[] = time();
255+ ftruncate($fp, 0);
256+ rewind($fp);
257+ fwrite($fp, json_encode($list));
258+ flock($fp, LOCK_UN);
259+ fclose($fp);
260+ }
261+
204262 public function isUserExist(?string $username = null): Result {
205263 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
206264 if (null === $username) return Result::Error('エラー:ユーザー名をご入力下さい。');
@@ -327,6 +385,7 @@
327385
328386 $file = scandir($this->dataDir);
329387 $now = time();
388+ $hashed = $token ? hash('sha256', $token) : '';
330389
331390 foreach ($file as $f) {
332391 if (!preg_match('/^(\d+)\.[A-Za-z0-9]+\.json$/', $f, $m)) continue;
@@ -343,7 +402,7 @@
343402
344403 foreach (($data->tokens ?? []) as $t) {
345404 if (!isset($t->token, $t->expDate)) continue;
346- if ($t->expDate > $now && hash_equals((string)$t->token, $token)) return (int)$m[1];
405+ if ($t->expDate > $now && hash_equals((string)$t->token, $hashed)) return (int)$m[1];
347406 }
348407 }
349408
@@ -64,6 +64,8 @@
6464 private string $token = '';
6565 private string $referer = '';
6666 private string $authType = '';
67+ private int $maxResponseSize = 10485760; // 10 MiB
68+ private bool $blockPrivate = true;
6769
6870 // デバッグ用
6971 private string $DEBUG_REQ_HDR = '';
@@ -316,6 +318,29 @@
316318 }
317319
318320 /**
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+ /**
319344 * リクエストを実行する
320345 *
321346 * @return Result 成功または失敗
@@ -355,14 +380,38 @@
355380 fwrite($this->stderr, "* 接続中: {$currentUrl}\n");
356381 }
357382
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()) {
360385 $this->responseError = "無効なURL: {$currentUrl}";
386+ $this->method = $originalMethod;
361387 return Result::Error($this->responseError);
362388 }
363389
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();
365397 $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;
366415 $path = $uri->getPath();
367416 if ($path === '') $path = '/';
368417 if (NULL !== $uri->getQuery()) {
@@ -371,7 +420,9 @@
371420
372421 // 認証
373422 $authHeader = '';
374- if (!empty($this->authType) && $this->authType === 'Basic') {
423+ if (!$sameOrigin) {
424+ // 送らない
425+ } else if (!empty($this->authType) && $this->authType === 'Basic') {
375426 assert_not_null($this->username);
376427 assert_not_null($this->password);
377428 $authHeader = "Authorization: Basic "
@@ -398,14 +449,25 @@
398449 }
399450
400451 // 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);
404458 }
405459
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+
406468 $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";
409471 $request .= "{$accept}\r\n";
410472 $request .= "Connection: close\r\n";
411473
@@ -415,7 +477,7 @@
415477 }
416478
417479 // ヘッダーを追加
418- $ext = $this->buildHeaderString();
480+ $ext = $this->buildHeaderString($hdrs, $sameOrigin);
419481 if (!empty($ext)) {
420482 $request .= $ext;
421483 }
@@ -441,7 +503,8 @@
441503 if ($scheme === 'https') {
442504 $sslOptions = [
443505 'verify_peer' => $this->verifySSL,
444- 'verify_peer_name' => $this->verifySSL
506+ 'verify_peer_name' => $this->verifySSL,
507+ 'peer_name' => trim($host, '[]'),
445508 ];
446509
447510 if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) {
@@ -451,7 +514,7 @@
451514 $context = stream_context_create(['ssl' => $sslOptions]);
452515
453516 $socket = @stream_socket_client(
454- "tls://{$uri->getHost()}:{$port}",
517+ "tls://{$connectHost}:{$port}",
455518 $errno,
456519 $errstr,
457520 $this->timeout,
@@ -459,7 +522,7 @@
459522 $context
460523 );
461524 } else {
462- $socket = @fsockopen($uri->getHost(), $port, $errno, $errstr, $this->timeout);
525+ $socket = @fsockopen($connectHost, $port, $errno, $errstr, $this->timeout);
463526 }
464527
465528 if (!$socket) {
@@ -467,6 +530,8 @@
467530 if ($this->verbose && $this->stderr) {
468531 fwrite($this->stderr, "* エラー: {$this->responseError}\n");
469532 }
533+
534+ $this->method = $originalMethod;
470535 return Result::Error($this->responseError);
471536 }
472537
@@ -482,10 +547,32 @@
482547 $body = '';
483548 $headersComplete = false;
484549
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+
487568 fclose($socket);
488569
570+ if ($readError !== '') {
571+ $this->responseError = $readError;
572+ $this->method = $originalMethod;
573+ return Result::Error($this->responseError);
574+ }
575+
489576 if ($rawResponse === '') {
490577 $this->responseError = 'リスポンスエラー';
491578 return Result::Error($this->responseError);
@@ -511,7 +598,9 @@
511598 $value = trim($value);
512599 $this->responseHeaders[$name] = $value;
513600
514- $redirectUrl = $this->checkReds($name, $value, $currentUrl);
601+ // 以前はLocationの後のヘッダーで空文字に上書きされていた
602+ $red = $this->checkReds($name, $value, $uri);
603+ if ($red !== '') $redirectUrl = $red;
515604 }
516605 }
517606
@@ -616,6 +705,59 @@
616705 // 機能性メソッド
617706
618707 /**
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+ /**
619761 * リダイレクトURLを確認する
620762 *
621763 * @param string $name ヘッダー名
@@ -623,24 +765,18 @@
623765 * @param string $currentUrl 現在のURL
624766 * @return string リダイレクトURL、リダイレクトがない場合は空文字
625767 */
626- private function checkReds(string $name, string $value, string $currentUrl): string {
768+ private function checkReds(string $name, string $value, Uri $current): string {
627769 if (!$this->followRedirects) return '';
628770 if (strtolower($name) !== 'location') return '';
629771 if ($this->responseCode < 300 || $this->responseCode >= 400) return '';
630- $redirectUrl = $value;
772+ if ($value === '') return '';
631773
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 '';
641779 }
642-
643- return $redirectUrl;
644780 }
645781
646782 /**
@@ -648,21 +784,21 @@
648784 *
649785 * @return string 構築されたヘッダー文字列
650786 */
651- private function buildHeaderString(): string {
787+ private function buildHeaderString(array $hdrs, bool $sameOrigin = true): string {
652788 $request = '';
653789
654- // ユーザー指定のヘッダーを追加
655- foreach ($this->headers as $name => $value) {
790+ // ユーザー指定のヘッダーを追加(normalizedHeaders()で確認済み)
791+ foreach ($hdrs as [$name, $value]) {
656792 $request .= "{$name}: {$value}\r\n";
657793 }
658794
659795 // リファラーが設定されていれば追加
660- if (!empty($this->referer) && !isset($this->headers['Referer'])) {
796+ if (!empty($this->referer) && !isset($hdrs['referer'])) {
661797 $request .= "Referer: {$this->referer}\r\n";
662798 }
663799
664- // 必要に応じてクッキーヘッダーを追加
665- if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
800+ // 必要に応じてクッキーヘッダーを追加(別のオリジンには送らない)
801+ if ($sameOrigin && !empty($this->cookies) && !isset($hdrs['cookie'])) {
666802 $cookieStrings = [];
667803 foreach ($this->cookies as $name => $value) {
668804 $cookieStrings[] = $name.'='.urlencode($value);
変更 util.php
@@ -373,7 +373,7 @@
373373 setcookie('csrf_token', $token, [
374374 'expires' => time() + 300, // 5分
375375 'path' => '/',
376- 'domain' => $_SERVER['SERVER_NAME'],
376+ 'domain' => $_SERVER['HTTP_HOST'],
377377 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
378378 'httponly' => true,
379379 'samesite' => 'Strict'