ActivityPubの修正

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

2個のファイル、+187 -29

@@ -90,20 +90,32 @@
9090 exit;
9191 }
9292
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+
94100 $activity = json_decode($input, true);
95- if (!$activity || !isset($activity['type'])) {
101+ if (!is_array($activity) || !isset($activity['type']) || !is_string($activity['type'])) {
96102 header('HTTP/1.1 400 Bad Request');
97103 header('Content-Type: application/activity+json');
98104 echo json_encode(['error' => '不正なアクティビティ']);
99105 exit;
100106 }
101107
102- logger(LogType::ActivityPub, "受付に入れた:".json_encode($activity));
103-
104108 try {
105109 header('Content-Type: application/activity+json');
106110 $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));
107119 $ap->postInbox($activity);
108120 exit;
109121 } catch (\Exception $e) {
@@ -423,9 +423,160 @@
423423 foreach ($f as $inbox) $this->sendActivity($inbox, $update);
424424 }
425425
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+
426502 // 機能性メソッド
427503
428504 /**
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+ /**
429580 * 指定されたインボックスURLにアクティビティを送信する
430581 *
431582 * @param string $inboxUrl 送信先のインボックスURL
@@ -433,6 +584,11 @@
433584 * @return void
434585 */
435586 private function sendActivity(string $inboxUrl, array $activity): void {
587+ if (!$this->isSafeRemoteUrl($inboxUrl)) {
588+ logger(\LogType::ActivityPub, "安全ではない受付ボックスへの送信を拒否: {$inboxUrl}");
589+ return;
590+ }
591+
436592 $privFile = FEDIINFO['privkey'];
437593 $priv = file_get_contents($privFile);
438594 if ($priv === false) {
@@ -501,14 +657,21 @@
501657 */
502658 private function acceptFollower(array $activity): void {
503659 $followerActor = $activity['actor'] ?? null;
504- if (!$followerActor) {
660+ if (!is_string($followerActor) || !$this->isSafeRemoteUrl($followerActor)) {
505661 header('HTTP/1.1 400 Bad Request');
506662 header('Content-Type: application/activity+json');
507663 echo json_encode(['error' => 'アクターがない']);
508664 exit;
509665 }
510666
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+ }
512675
513676 $inbox = $this->getInboxFromActor($followerActor);
514677 if (!$inbox) {
@@ -518,6 +681,8 @@
518681 exit;
519682 }
520683
684+ $this->storeFollower($followerActor);
685+
521686 $accept = [
522687 '@context' => [
523688 'https://www.w3.org/ns/activitystreams',
@@ -567,29 +732,10 @@
567732 * @return string|null インボックスURL、取得に失敗した場合はnull
568733 */
569734 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;
575737
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;
594740 }
595741 }