ActivityPubの修正
守矢諏訪子 <suwako@076.moe>、2026-11-10 15:53
コミット 8ebe52c4a0e66524fa83f1b4232ff572d8218269
・親 efb632f
・ファイルを見る
2個のファイル、+187 -29
| @@ -90,20 +90,32 @@ | ||
| 90 | 90 | exit; |
| 91 | 91 | } |
| 92 | 92 | |
| 93 | - $input = file_get_contents('php://input'); | |
| 93 | + $maxBody = 65536; | |
| 94 | + $input = file_get_contents('php://input', false, null, 0, $maxBody + 1); | |
| 95 | + if ($input === false || strlen($input) > $maxBody) { | |
| 96 | + header('HTTP/1.1 413 Payload Too Large'); | |
| 97 | + exit; | |
| 98 | + } | |
| 99 | + | |
| 94 | 100 | $activity = json_decode($input, true); |
| 95 | - if (!$activity || !isset($activity['type'])) { | |
| 101 | + if (!is_array($activity) || !isset($activity['type']) || !is_string($activity['type'])) { | |
| 96 | 102 | header('HTTP/1.1 400 Bad Request'); |
| 97 | 103 | header('Content-Type: application/activity+json'); |
| 98 | 104 | echo json_encode(['error' => '不正なアクティビティ']); |
| 99 | 105 | exit; |
| 100 | 106 | } |
| 101 | 107 | |
| 102 | - logger(LogType::ActivityPub, "受付に入れた:".json_encode($activity)); | |
| 103 | - | |
| 104 | 108 | try { |
| 105 | 109 | header('Content-Type: application/activity+json'); |
| 106 | 110 | $ap = new Activitypub(); |
| 111 | + | |
| 112 | + if (!$ap->verifyInboxSignature($input, $activity)) { | |
| 113 | + header('HTTP/1.1 401 Unauthorized'); | |
| 114 | + echo json_encode(['error' => '署名の検証に失敗']); | |
| 115 | + exit; | |
| 116 | + } | |
| 117 | + | |
| 118 | + logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity)); | |
| 107 | 119 | $ap->postInbox($activity); |
| 108 | 120 | exit; |
| 109 | 121 | } catch (\Exception $e) { |
| @@ -423,9 +423,160 @@ | ||
| 423 | 423 | foreach ($f as $inbox) $this->sendActivity($inbox, $update); |
| 424 | 424 | } |
| 425 | 425 | |
| 426 | + /** | |
| 427 | + * 受付ボックスへのリクエストのHTTP署名を検証する | |
| 428 | + * | |
| 429 | + * @param string $body リクエストの本文(生データ) | |
| 430 | + * @param array $activity デコード済みのアクティビティ | |
| 431 | + * @return bool 署名が正しく、署名者がactorと一致すればtrue | |
| 432 | + */ | |
| 433 | + public function verifyInboxSignature(string $body, array $activity): bool { | |
| 434 | + if (!ACTIVITYPUB_ENABLED) return false; | |
| 435 | + | |
| 436 | + // Signature: keyId="…",algorithm="…",headers="…",signature="…" | |
| 437 | + $sigHeader = $_SERVER['HTTP_SIGNATURE'] ?? ''; | |
| 438 | + if ($sigHeader === '') return false; | |
| 439 | + preg_match_all('/(\w+)="([^"]*)"/', $sigHeader, $m, PREG_SET_ORDER); | |
| 440 | + $sig = []; | |
| 441 | + foreach ($m as $kv) $sig[$kv[1]] = $kv[2]; | |
| 442 | + if (empty($sig['keyId']) || empty($sig['signature'])) return false; | |
| 443 | + if (isset($sig['algorithm']) | |
| 444 | + && !in_array(strtolower($sig['algorithm']), ['rsa-sha256', 'hs2019'], true)) return false; | |
| 445 | + | |
| 446 | + // 最低限これらのヘッダーが署名されている事 | |
| 447 | + $signed = explode(' ', strtolower(trim($sig['headers'] ?? 'date'))); | |
| 448 | + foreach (['(request-target)', 'host', 'date', 'digest'] as $req) { | |
| 449 | + if (!in_array($req, $signed, true)) return false; | |
| 450 | + } | |
| 451 | + | |
| 452 | + // 再送攻撃対策:Dateは前後5分以内 | |
| 453 | + $date = strtotime($_SERVER['HTTP_DATE'] ?? ''); | |
| 454 | + if (!$date || abs(time() - $date) > 300) return false; | |
| 455 | + | |
| 456 | + // Digestが本文と一致する事 / Digest must match the body | |
| 457 | + $expected = base64_encode(hash('sha256', $body, true)); | |
| 458 | + $digestOk = false; | |
| 459 | + foreach (explode(',', $_SERVER['HTTP_DIGEST'] ?? '') as $d) { | |
| 460 | + $d = trim($d); | |
| 461 | + if (stripos($d, 'SHA-256=') === 0 && hash_equals($expected, substr($d, 8))) $digestOk = true; | |
| 462 | + } | |
| 463 | + if (!$digestOk) return false; | |
| 464 | + | |
| 465 | + // 署名対象の文字列を組み立てる | |
| 466 | + $lines = []; | |
| 467 | + foreach ($signed as $h) { | |
| 468 | + if ($h === '(request-target)') { | |
| 469 | + $lines[] = '(request-target): '.strtolower($_SERVER['REQUEST_METHOD']).' '.$_SERVER['REQUEST_URI']; | |
| 470 | + continue; | |
| 471 | + } | |
| 472 | + $key = match ($h) { | |
| 473 | + 'content-type' => 'CONTENT_TYPE', | |
| 474 | + 'content-length' => 'CONTENT_LENGTH', | |
| 475 | + default => 'HTTP_'.strtoupper(str_replace('-', '_', $h)), | |
| 476 | + }; | |
| 477 | + if (!isset($_SERVER[$key])) return false; | |
| 478 | + $lines[] = "{$h}: {$_SERVER[$key]}"; | |
| 479 | + } | |
| 480 | + | |
| 481 | + // 公開鍵を取得し、鍵の持ち主がアクティビティのactorと一致する事を確認する | |
| 482 | + $actor = $activity['actor'] ?? null; | |
| 483 | + if (is_array($actor)) $actor = $actor['id'] ?? null; | |
| 484 | + if (!is_string($actor)) return false; | |
| 485 | + | |
| 486 | + $doc = $this->fetchRemoteJson(strtok($sig['keyId'], '#')); | |
| 487 | + if (!$doc) return false; | |
| 488 | + $keys = $doc['publicKey'] ?? $doc; | |
| 489 | + if (array_is_list($keys)) { | |
| 490 | + $keys = array_values(array_filter($keys, fn($k) => is_array($k) && ($k['id'] ?? '') === $sig['keyId']))[0] ?? []; | |
| 491 | + } | |
| 492 | + if (($keys['id'] ?? '') !== $sig['keyId']) return false; | |
| 493 | + if (($keys['owner'] ?? $doc['id'] ?? null) !== $actor) return false; | |
| 494 | + | |
| 495 | + $pub = openssl_pkey_get_public($keys['publicKeyPem'] ?? ''); | |
| 496 | + $raw = base64_decode($sig['signature'], true); | |
| 497 | + if (!$pub || $raw === false) return false; | |
| 498 | + | |
| 499 | + return openssl_verify(implode("\n", $lines), $raw, $pub, OPENSSL_ALGO_SHA256) === 1; | |
| 500 | + } | |
| 501 | + | |
| 426 | 502 | // 機能性メソッド |
| 427 | 503 | |
| 428 | 504 | /** |
| 505 | + * リモートのURLに接続しても安全か確認する(SSRF対策) | |
| 506 | + * check a remote URL is safe to connect to (SSRF protection) | |
| 507 | + * | |
| 508 | + * httpsのみ、443番ポートのみ、認証情報なし、制御文字・空白なし(CRLFインジェクション対策)、 | |
| 509 | + * 全ての解決先IPが公開アドレスである事。 | |
| 510 | + * https only, port 443 only, no credentials, no control chars or spaces (CRLF injection), | |
| 511 | + * and every resolved IP must be a public address. | |
| 512 | + * 注意:Curlは改めて名前解決するので、DNSリバインディングまでは防げない。 | |
| 513 | + * note: Curl resolves the name again, so this does not stop DNS rebinding. | |
| 514 | + * | |
| 515 | + * @param mixed $url 確認するURL | |
| 516 | + * @return bool 安全ならtrue | |
| 517 | + */ | |
| 518 | + private function isSafeRemoteUrl(mixed $url): bool { | |
| 519 | + if (!is_string($url) || strlen($url) > 2048) return false; | |
| 520 | + if (preg_match('/[\x00-\x20\x7f]/', $url)) return false; | |
| 521 | + | |
| 522 | + $p = parse_url($url); | |
| 523 | + if (!$p || strtolower($p['scheme'] ?? '') !== 'https' || empty($p['host'])) return false; | |
| 524 | + if (isset($p['user']) || isset($p['pass'])) return false; | |
| 525 | + if (isset($p['port']) && (int)$p['port'] !== 443) return false; | |
| 526 | + | |
| 527 | + $host = trim($p['host'], '[]'); | |
| 528 | + if (filter_var($host, FILTER_VALIDATE_IP)) { | |
| 529 | + $ips = [$host]; | |
| 530 | + } else { | |
| 531 | + $ips = []; | |
| 532 | + foreach ((@dns_get_record($host, DNS_A | DNS_AAAA) ?: []) as $r) { | |
| 533 | + if (isset($r['ip'])) $ips[] = $r['ip']; | |
| 534 | + if (isset($r['ipv6'])) $ips[] = $r['ipv6']; | |
| 535 | + } | |
| 536 | + } | |
| 537 | + if (empty($ips)) return false; | |
| 538 | + | |
| 539 | + foreach ($ips as $ip) { | |
| 540 | + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return false; | |
| 541 | + } | |
| 542 | + | |
| 543 | + return true; | |
| 544 | + } | |
| 545 | + | |
| 546 | + /** | |
| 547 | + * リモートのActivityPubオブジェクトを取得する | |
| 548 | + * fetch a remote ActivityPub object | |
| 549 | + * | |
| 550 | + * @param string $url 取得するURL | |
| 551 | + * @return array|null デコード済みのJSON、失敗した場合はnull | |
| 552 | + */ | |
| 553 | + private function fetchRemoteJson(string $url): ?array { | |
| 554 | + if (!$this->isSafeRemoteUrl($url)) { | |
| 555 | + logger(\LogType::ActivityPub, "安全ではないURLを拒否: {$url}"); | |
| 556 | + return null; | |
| 557 | + } | |
| 558 | + | |
| 559 | + $curl = new Curl($url); | |
| 560 | + // リダイレクト先は検証されないので追わない / redirect targets aren't re-checked, so don't follow them | |
| 561 | + $curl->setHeaders(['Accept: application/activity+json']) | |
| 562 | + ->setFollowRedirects(false) | |
| 563 | + ->setTimeout(10) | |
| 564 | + ->setCaInfo('/etc/ssl/cert.pem'); | |
| 565 | + | |
| 566 | + logger(\LogType::ActivityPub, "リモートにリクエスト: {$url}"); | |
| 567 | + $res = $curl->execute(); | |
| 568 | + if (!$res->isSuccess || $curl->getResponseCode() !== 200) { | |
| 569 | + logger(\LogType::ActivityPub, "取得に失敗: HTTP {$curl->getResponseCode()}, エラー: {$curl->getError()}"); | |
| 570 | + return null; | |
| 571 | + } | |
| 572 | + | |
| 573 | + $body = $curl->getResponseBody(); | |
| 574 | + if (strlen($body) > 1048576) return null; | |
| 575 | + $data = json_decode($body, true); | |
| 576 | + return is_array($data) ? $data : null; | |
| 577 | + } | |
| 578 | + | |
| 579 | + /** | |
| 429 | 580 | * 指定されたインボックスURLにアクティビティを送信する |
| 430 | 581 | * |
| 431 | 582 | * @param string $inboxUrl 送信先のインボックスURL |
| @@ -433,6 +584,11 @@ | ||
| 433 | 584 | * @return void |
| 434 | 585 | */ |
| 435 | 586 | private function sendActivity(string $inboxUrl, array $activity): void { |
| 587 | + if (!$this->isSafeRemoteUrl($inboxUrl)) { | |
| 588 | + logger(\LogType::ActivityPub, "安全ではない受付ボックスへの送信を拒否: {$inboxUrl}"); | |
| 589 | + return; | |
| 590 | + } | |
| 591 | + | |
| 436 | 592 | $privFile = FEDIINFO['privkey']; |
| 437 | 593 | $priv = file_get_contents($privFile); |
| 438 | 594 | if ($priv === false) { |
| @@ -501,14 +657,21 @@ | ||
| 501 | 657 | */ |
| 502 | 658 | private function acceptFollower(array $activity): void { |
| 503 | 659 | $followerActor = $activity['actor'] ?? null; |
| 504 | - if (!$followerActor) { | |
| 660 | + if (!is_string($followerActor) || !$this->isSafeRemoteUrl($followerActor)) { | |
| 505 | 661 | header('HTTP/1.1 400 Bad Request'); |
| 506 | 662 | header('Content-Type: application/activity+json'); |
| 507 | 663 | echo json_encode(['error' => 'アクターがない']); |
| 508 | 664 | exit; |
| 509 | 665 | } |
| 510 | 666 | |
| 511 | - $this->storeFollower($followerActor); | |
| 667 | + $object = $activity['object'] ?? null; | |
| 668 | + if (is_array($object)) $object = $object['id'] ?? null; | |
| 669 | + if ($object !== "{$this->domain}/ap/actor") { | |
| 670 | + header('HTTP/1.1 400 Bad Request'); | |
| 671 | + header('Content-Type: application/activity+json'); | |
| 672 | + echo json_encode(['error' => '不正なフォロー対象']); | |
| 673 | + exit; | |
| 674 | + } | |
| 512 | 675 | |
| 513 | 676 | $inbox = $this->getInboxFromActor($followerActor); |
| 514 | 677 | if (!$inbox) { |
| @@ -518,6 +681,8 @@ | ||
| 518 | 681 | exit; |
| 519 | 682 | } |
| 520 | 683 | |
| 684 | + $this->storeFollower($followerActor); | |
| 685 | + | |
| 521 | 686 | $accept = [ |
| 522 | 687 | '@context' => [ |
| 523 | 688 | 'https://www.w3.org/ns/activitystreams', |
| @@ -567,29 +732,10 @@ | ||
| 567 | 732 | * @return string|null インボックスURL、取得に失敗した場合はnull |
| 568 | 733 | */ |
| 569 | 734 | private function getInboxFromActor(string $actor): ?string { |
| 570 | - $curl = new Curl($actor); | |
| 571 | - $curl->setHeaders(['Accept: application/activity+json']) | |
| 572 | - ->setFollowRedirects(true) | |
| 573 | - ->setMaxRedirects(5) | |
| 574 | - ->setCaInfo('/etc/ssl/cert.pem'); | |
| 735 | + $data = $this->fetchRemoteJson($actor); | |
| 736 | + if (!$data) return null; | |
| 575 | 737 | |
| 576 | - logger(LogType::ActivityPub, "アクターURLにリクエスト: {$actor}"); | |
| 577 | - $success = $curl->execute(); | |
| 578 | - if (!$success) { | |
| 579 | - logger(LogType::ActivityPub, "アクターリクエストに失敗: ".$curl->getError()); | |
| 580 | - return null; | |
| 581 | - } | |
| 582 | - | |
| 583 | - $res = $curl->getResponseBody(); | |
| 584 | - $code = $curl->getResponseCode(); | |
| 585 | - $err = $curl->getError(); | |
| 586 | - | |
| 587 | - if ($code !== 200) { | |
| 588 | - logger(LogType::ActivityPub, "アクター取得に失敗: HTTP {$code}, エラー: {$err}"); | |
| 589 | - return null; | |
| 590 | - } | |
| 591 | - | |
| 592 | - $data = json_decode($res, true); | |
| 593 | - return $data['inbox'] ?? null; | |
| 738 | + $inbox = $data['inbox'] ?? null; | |
| 739 | + return $this->isSafeRemoteUrl($inbox) ? $inbox : null; | |
| 594 | 740 | } |
| 595 | 741 | } |