From 427ea00890ee1367683fca2f0ff5692b54aec872 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 14 May 2026 23:36:41 +0900 Subject: [PATCH 01/14] Add split-domain WebFinger support When the new HANDLE_HOST and WEB_ORIGIN environment variables are set, Hollo wires Fedify's origin configuration so that fediverse handles (e.g. @alice@example.com) and ActivityPub actor URIs (e.g. https://ap.example.com/@alice) can live on different domains. The Mastodon-compatible /api/v1/instance and /api/v2/instance endpoints now report HANDLE_HOST as the canonical instance domain, the dashboard and home page surface it in their UIs, and account creation stores the local owner's handle and instanceHost under HANDLE_HOST rather than the request host. Local-account lookups (acct=username on /api/v1/accounts) and the local-emoji stripping logic in /api/v1/statuses are anchored to the same canonical host. Both variables must be set together; setting only one is a startup error. They must be configured before the first account is created, since changing the handle domain after federation has begun breaks existing follow relationships. At startup, Hollo logs a warning when HANDLE_HOST disagrees with the stored handle of an existing account. Operators are responsible for redirecting /.well-known/webfinger on the handle domain to WEB_ORIGIN at the reverse proxy layer. A dedicated documentation page covers the setup with nginx and Caddy snippets in all four supported languages. Resolves https://github.com/fedify-dev/hollo/issues/161 Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- AGENTS.md | 2 + CHANGES.md | 21 ++ bin/server.ts | 4 + docs/astro.config.mjs | 9 + docs/src/content/docs/install/env.mdx | 28 +++ .../src/content/docs/install/split-domain.mdx | 177 +++++++++++++++++ docs/src/content/docs/ja/install/env.mdx | 28 +++ .../content/docs/ja/install/split-domain.mdx | 180 ++++++++++++++++++ docs/src/content/docs/ko/install/env.mdx | 27 +++ .../content/docs/ko/install/split-domain.mdx | 176 +++++++++++++++++ docs/src/content/docs/zh-cn/install/env.mdx | 26 +++ .../docs/zh-cn/install/split-domain.mdx | 164 ++++++++++++++++ src/api/v1/accounts.ts | 3 +- src/api/v1/instance.ts | 10 +- src/api/v1/statuses.ts | 9 +- src/api/v2/instance.ts | 8 +- src/entities/account.ts | 3 +- src/env.ts | 21 ++ src/federation/federation.ts | 2 + src/handle-host-check.ts | 27 +++ src/instance-host.ts | 6 + src/pages/accounts.tsx | 24 +-- src/pages/home/index.tsx | 3 +- 23 files changed, 935 insertions(+), 23 deletions(-) create mode 100644 docs/src/content/docs/install/split-domain.mdx create mode 100644 docs/src/content/docs/ja/install/split-domain.mdx create mode 100644 docs/src/content/docs/ko/install/split-domain.mdx create mode 100644 docs/src/content/docs/zh-cn/install/split-domain.mdx create mode 100644 src/handle-host-check.ts create mode 100644 src/instance-host.ts diff --git a/AGENTS.md b/AGENTS.md index 3ff81d5e..371b5460 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -428,6 +428,8 @@ STORAGE_URL_BASE=https://your-bucket.s3.amazonaws.com | `REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS` | 300 | Completed scrape deduplication window | | `MEDIA_PROXY` | off | Remote media proxy: `off`, `proxy`, `cache` (booleans accepted: `true`→`proxy`, `false`→`off`) | | `REMOTE_MEDIA_THUMBNAILS` | on | Generate local sharp thumbnails for remote attachments (boolean) | +| `HANDLE_HOST` | - | Split-domain WebFinger handle host (e.g. `example.com`); must be set together with `WEB_ORIGIN` | +| `WEB_ORIGIN` | - | Split-domain ActivityPub server origin (e.g. `https://ap.example.com`); must be set together with `HANDLE_HOST` | Adding new environment variables diff --git a/CHANGES.md b/CHANGES.md index 6b99e449..cee9b1b5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,24 @@ Version 0.9.0 To be released. + - Added optional split-domain WebFinger support. When the new + `HANDLE_HOST` and `WEB_ORIGIN` environment variables are set, + Hollo uses Fedify's `origin` configuration so that fediverse + handles (e.g. `@alice@example.com`) and ActivityPub actor URIs + (e.g. `https://ap.example.com/@alice`) can live on different + domains. The Mastodon-compatible `/api/v1/instance` and + `/api/v2/instance` endpoints expose `HANDLE_HOST` as the instance + domain when this is configured, so clients display the correct + `@user@HANDLE_HOST` handle. Both variables must be set together; + setting only one is a startup error. They must be configured + *before* the first account is created — changing the handle + domain after federation has begun breaks remote follow + relationships; on startup Hollo logs a warning when the + configured `HANDLE_HOST` does not match the existing account's + stored handle. Operators must also configure their reverse + proxy on the handle domain to redirect `/.well-known/webfinger` + to `WEB_ORIGIN`. See the [Split-domain WebFinger guide]. [[#161], [#484]] + - Added a media proxy that re-serves remote avatars, headers, post attachments, custom emojis, and preview-card images from Hollo's own origin. This sidesteps CORS configurations on remote object stores @@ -187,6 +205,8 @@ To be released. - Upgraded Fedify to 2.2.1. [FEP-044f]: https://w3id.org/fep/044f +[Split-domain WebFinger guide]: https://docs.hollo.social/install/split-domain/ +[#161]: https://github.com/fedify-dev/hollo/issues/161 [@hollo@hollo.social]: https://hollo.social/@hollo [#67]: https://github.com/fedify-dev/hollo/issues/67 [#127]: https://github.com/fedify-dev/hollo/issues/127 @@ -200,6 +220,7 @@ To be released. [#481]: https://github.com/fedify-dev/hollo/issues/481 [#482]: https://github.com/fedify-dev/hollo/pull/482 [#483]: https://github.com/fedify-dev/hollo/pull/483 +[#484]: https://github.com/fedify-dev/hollo/pull/484 Version 0.8.4 diff --git a/bin/server.ts b/bin/server.ts index a53273e3..1354d167 100644 --- a/bin/server.ts +++ b/bin/server.ts @@ -4,6 +4,7 @@ import { serve } from "@hono/node-server"; import { behindProxy } from "x-forwarded-fetch"; import "../src/logging"; +import { checkHandleHostConsistency } from "../src/handle-host-check"; import { configureSentry } from "../src/sentry"; // oxlint-disable-next-line typescript/dot-notation @@ -40,6 +41,9 @@ if (!["all", "web", "worker"].includes(NODE_TYPE)) { process.exit(1); } +// Warn if the configured HANDLE_HOST disagrees with an existing account. +await checkHandleHostConsistency(); + // Start web server if running as web or all node if (NODE_TYPE === "web" || NODE_TYPE === "all") { const { default: app } = await import("../src/index"); diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 0f3653ad..a57290c1 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -108,6 +108,15 @@ export default defineConfig({ }, slug: "install/workers", }, + { + label: "Split-domain WebFinger", + translations: { + ko: "도메인 분리 WebFinger", + ja: "ドメイン分離 WebFinger", + "zh-CN": "分域 WebFinger", + }, + slug: "install/split-domain", + }, { label: "Setting up", translations: { diff --git a/docs/src/content/docs/install/env.mdx b/docs/src/content/docs/install/env.mdx index 444612a3..d07e6bf5 100644 --- a/docs/src/content/docs/install/env.mdx +++ b/docs/src/content/docs/install/env.mdx @@ -80,6 +80,34 @@ Turned off by default. security reasons. +### `HANDLE_HOST` + +The fediverse handle domain when running a [split-domain WebFinger +setup][split-domain]. When set, fediverse handles take the form +`@user@HANDLE_HOST` even though Hollo itself is served at +[`WEB_ORIGIN`](#web_origin). + +Must be set together with `WEB_ORIGIN`; setting only one is a startup +error. Configure both *before* creating the first account — changing +the handle domain after federation has begun breaks remote follow +relationships. + +Not set by default. + +[split-domain]: /install/split-domain/ + +### `WEB_ORIGIN` + +The origin (scheme + host) where Hollo's ActivityPub server actually +runs in a [split-domain WebFinger setup][split-domain], e.g. +`https://ap.example.com`. Actor URIs, inbox URLs, and other federation +endpoints are built under this origin. + +Must be set together with [`HANDLE_HOST`](#handle_host); setting only +one is a startup error. + +Not set by default. + ### `ALLOW_PRIVATE_ADDRESS` Setting this to `true` disables SSRF (Server-Side Request Forgery) protection. diff --git a/docs/src/content/docs/install/split-domain.mdx b/docs/src/content/docs/install/split-domain.mdx new file mode 100644 index 00000000..0d5a0847 --- /dev/null +++ b/docs/src/content/docs/install/split-domain.mdx @@ -0,0 +1,177 @@ +--- +title: Split-domain WebFinger +description: Run Hollo at one domain while addressing accounts under another. +--- + +import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components'; + +Hollo supports a *split-domain WebFinger* setup: your fediverse handles +live under one domain (e.g. `@alice@example.com`) while the actual +ActivityPub server runs under another (e.g. `https://ap.example.com`). +This is the same pattern Mastodon documents under "[hosting WebFinger +at the root domain][mastodon-webfinger]" and that GoToSocial calls +*host-meta*-based host swapping. + +This is implemented on top of [Fedify's `origin` configuration option]. + +[mastodon-webfinger]: https://docs.joinmastodon.org/admin/config/#web_domain +[Fedify's `origin` configuration option]: https://fedify.dev/manual/federation#separating-webfinger-host-from-the-server-origin + + +Why split the domain? +--------------------- + +The typical reason is that you already own a nice short domain +(`example.com`) and want your handles to look like +`@alice@example.com`, but you don't want to host Hollo at the apex of +that domain — the apex is reserved for a homepage, a different web app, +or an existing service. + +In a split-domain setup: + + - Users address you as `@alice@example.com` everywhere in the + fediverse. + - Hollo itself is served from `https://ap.example.com`. The web + UI, the Mastodon-compatible API, OAuth, and the actor URIs all + live there. + - The apex domain `example.com` only needs to handle one thing: + redirecting `/.well-known/webfinger` requests over to + `ap.example.com`. + + + + +Configuration +------------- + +Set both of these environment variables on your Hollo instance: + + - [`HANDLE_HOST`](/install/env/#handle_host) — the bare hostname + used in handles (no scheme, no path), e.g. `example.com`. + - [`WEB_ORIGIN`](/install/env/#web_origin) — the scheme + host + where Hollo runs, e.g. `https://ap.example.com`. + +Both must be set together; setting only one causes Hollo to refuse to +start. + +~~~~ env +HANDLE_HOST=example.com +WEB_ORIGIN=https://ap.example.com +~~~~ + +With this in place, Fedify takes care of the rest: + + - WebFinger responses use `acct:alice@example.com` as the subject. + - Actor URIs are built under `https://ap.example.com/@alice`. + - The Mastodon-compatible `/api/v1/instance` and `/api/v2/instance` + endpoints report `example.com` as the instance domain, so clients + display the correct handle. + + +Reverse proxy redirect +---------------------- + +Hollo itself only listens on the `WEB_ORIGIN` host. Remote servers +that look up `@alice@example.com` will send their WebFinger query to +`https://example.com/.well-known/webfinger`, so you must configure +that domain's reverse proxy to redirect those requests to Hollo. + +A 301 redirect that preserves the query string is enough. Send +`/.well-known/nodeinfo` and `/.well-known/host-meta` along as well, +since some implementations probe those during discovery. + + + +~~~~ nginx +server { + listen 443 ssl; + server_name example.com; + # … your normal site config … + + location /.well-known/webfinger { + return 301 https://ap.example.com$request_uri; + } + location /.well-known/nodeinfo { + return 301 https://ap.example.com$request_uri; + } + location /.well-known/host-meta { + return 301 https://ap.example.com$request_uri; + } +} +~~~~ + + +~~~~ caddy +example.com { + # … your normal site config … + + redir /.well-known/webfinger* https://ap.example.com{uri} permanent + redir /.well-known/nodeinfo* https://ap.example.com{uri} permanent + redir /.well-known/host-meta* https://ap.example.com{uri} permanent +} +~~~~ + + + +You do **not** need to redirect the `/@username` path or any actor +URLs — those URLs live on `ap.example.com` and remote servers will go +straight there after resolving the WebFinger response. + + +Verifying the setup +------------------- + +After deploying, three quick checks confirm everything is wired up: + + 1. WebFinger from the handle domain redirects: + + ~~~~ sh frame="none" + curl -i "https://example.com/.well-known/webfinger?resource=acct:alice@example.com" + ~~~~ + + should return a 301 to `https://ap.example.com/.well-known/webfinger?...`. + + 2. WebFinger from the server domain responds: + + ~~~~ sh frame="none" + curl "https://ap.example.com/.well-known/webfinger?resource=acct:alice@example.com" + ~~~~ + + should return a JRD whose `subject` is `acct:alice@example.com` + and whose `self` link points at `https://ap.example.com/@alice`. + + 3. The instance endpoint reports the handle domain: + + ~~~~ sh frame="none" + curl https://ap.example.com/api/v2/instance | jq .domain + ~~~~ + + should print `"example.com"`. + +For a more thorough audit, point Julian Fietkau's [WebFinger Canary] +at your handle domain. Mastodon and Fedify-based servers handle +split-domain setups correctly today; Misskey and Pixelfed currently +do not. + +[WebFinger Canary]: https://correct.webfinger-canary.fietkau.software/ + + + diff --git a/docs/src/content/docs/ja/install/env.mdx b/docs/src/content/docs/ja/install/env.mdx index 70ed591d..16891190 100644 --- a/docs/src/content/docs/ja/install/env.mdx +++ b/docs/src/content/docs/ja/install/env.mdx @@ -80,6 +80,34 @@ HolloがL7ロードバランサーの後ろにある場合(通常はそうす この動作はセキュリティ上注意が必要です。 +### `HANDLE_HOST` + +[ドメイン分離 WebFinger 設定][split-domain]で使用するフェディバースの +ハンドルドメインです。このオプションを設定すると、フェディバースの +ハンドルは`@user@HANDLE_HOST`の形式になりますが、Hollo自体は +[`WEB_ORIGIN`](#web_origin)で動作します。 + +`WEB_ORIGIN`と一緒に設定する必要があり、片方だけを設定すると +Holloは起動に失敗します。**最初のアカウントを作成する前に** +両方の変数を設定してください。連合が始まった後にハンドルドメインを +変更すると、リモートフォロー関係が壊れます。 + +デフォルトでは設定されていません。 + +[split-domain]: /ja/install/split-domain/ + +### `WEB_ORIGIN` + +[ドメイン分離 WebFinger 設定][split-domain]でHolloの ActivityPub +サーバーが実際に動作するオリジン(スキーム + ホスト)です。例: +`https://ap.example.com`。アクターURI、インボックスURLなどの +連合エンドポイントは、すべてこのオリジンを基に構築されます。 + +[`HANDLE_HOST`](#handle_host)と一緒に設定する必要があり、 +片方だけを設定するとHolloは起動に失敗します。 + +デフォルトでは設定されていません。 + ### `ALLOW_PRIVATE_ADDRESS` このオプションを`true`に設定すると、サーバーサイドリクエストフォージェリ(SSRF)攻撃の防止を解除します。 diff --git a/docs/src/content/docs/ja/install/split-domain.mdx b/docs/src/content/docs/ja/install/split-domain.mdx new file mode 100644 index 00000000..cf39077a --- /dev/null +++ b/docs/src/content/docs/ja/install/split-domain.mdx @@ -0,0 +1,180 @@ +--- +title: ドメイン分離 WebFinger +description: Holloをあるドメインで運用しつつ、別のドメインでアカウントハンドルを公開する方法。 +--- + +import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components'; + +Holloは*ドメイン分離 WebFinger*の設定をサポートしています。 +フェディバースのハンドルは1つのドメイン(例: `@alice@example.com`) +にあり、実際のActivityPubサーバーは別のドメイン +(例: `https://ap.example.com`)で動作する形式です。 +Mastodonが「[ルートドメインでのWebFingerホスティング][mastodon-webfinger]」 +として説明し、GoToSocialが*host-meta*ベースのホストスワッピングと +呼ぶパターンと同じです。 + +この機能は [Fedifyの `origin` オプション] の上に実装されています。 + +[mastodon-webfinger]: https://docs.joinmastodon.org/admin/config/#web_domain +[Fedifyの `origin` オプション]: https://fedify.dev/manual/federation#separating-webfinger-host-from-the-server-origin + + +なぜドメインを分離するのか? +---------------------------- + +典型的な動機は、すでに短くて良いドメイン(`example.com`)を持っていて、 +ハンドルを`@alice@example.com`のように見せたいけれども、そのドメインの +ルートにHolloを置くわけにはいかない場合です。ルートドメインは +ホームページや別のWebアプリ、既存サービスが使っていることが多いものです。 + +ドメイン分離設定では: + + - ユーザーはフェディバースのどこでも`@alice@example.com`という + 形で呼んでくれます。 + - Hollo自体は`https://ap.example.com`で動作します。Web UI、 + Mastodon互換 API、OAuth、アクターURIはすべてこちらにあります。 + - ルートドメイン`example.com`はたった一つの仕事をすればよいです: + `/.well-known/webfinger`リクエストを`ap.example.com`に + リダイレクトすることです。 + + + + +設定方法 +-------- + +以下の2つの環境変数をHolloインスタンスに設定してください: + + - [`HANDLE_HOST`](/ja/install/env/#handle_host) — ハンドルに使う + ホスト名のみ(スキームなし、パスなし)。例: `example.com`。 + - [`WEB_ORIGIN`](/ja/install/env/#web_origin) — Holloが実際に + 動作するスキーム+ホスト。例: `https://ap.example.com`。 + +両方を一緒に設定する必要があります。片方だけ設定するとHolloは +起動に失敗します。 + +~~~~ env +HANDLE_HOST=example.com +WEB_ORIGIN=https://ap.example.com +~~~~ + +これで残りはFedifyが処理してくれます: + + - WebFingerレスポンスの`subject`は`acct:alice@example.com`。 + - アクターURIは`https://ap.example.com/@alice`の形式で構築されます。 + - Mastodon互換の`/api/v1/instance`、`/api/v2/instance`エンドポイントは + インスタンスドメインを`example.com`として返すため、クライアントは + 正しいハンドルを表示します。 + + +リバースプロキシのリダイレクト +------------------------------ + +Hollo自体は`WEB_ORIGIN`ホストでしかリッスンしません。 +リモートサーバーが`@alice@example.com`を解決する際は +`https://example.com/.well-known/webfinger`にWebFingerクエリを +送るので、そのドメインのリバースプロキシでHolloへリダイレクト +させる必要があります。 + +クエリ文字列を保持する301リダイレクトで十分です。 +一部の実装はディスカバリ時に`/.well-known/nodeinfo`と +`/.well-known/host-meta`も探るので、これらも一緒に +リダイレクトしておくのが良いでしょう。 + + + +~~~~ nginx +server { + listen 443 ssl; + server_name example.com; + # … 通常のサイト設定 … + + location /.well-known/webfinger { + return 301 https://ap.example.com$request_uri; + } + location /.well-known/nodeinfo { + return 301 https://ap.example.com$request_uri; + } + location /.well-known/host-meta { + return 301 https://ap.example.com$request_uri; + } +} +~~~~ + + +~~~~ caddy +example.com { + # … 通常のサイト設定 … + + redir /.well-known/webfinger* https://ap.example.com{uri} permanent + redir /.well-known/nodeinfo* https://ap.example.com{uri} permanent + redir /.well-known/host-meta* https://ap.example.com{uri} permanent +} +~~~~ + + + +`/@username`パスやアクターURLはリダイレクト**不要**です。 +これらのURLは`ap.example.com`上にあり、リモートサーバーは +WebFingerレスポンスを解決した後そちらに直接アクセスします。 + + +設定の検証 +---------- + +デプロイ後、次の3つを確認すればすべて正しく接続されていることが +わかります: + + 1. ハンドルドメインからのWebFingerがリダイレクトされるか: + + ~~~~ sh frame="none" + curl -i "https://example.com/.well-known/webfinger?resource=acct:alice@example.com" + ~~~~ + + `https://ap.example.com/.well-known/webfinger?...`への301を + 返す必要があります。 + + 2. サーバードメインからのWebFingerが応答するか: + + ~~~~ sh frame="none" + curl "https://ap.example.com/.well-known/webfinger?resource=acct:alice@example.com" + ~~~~ + + `subject`が`acct:alice@example.com`で、`self`リンクが + `https://ap.example.com/@alice`を指すJRDが返ってくる必要があります。 + + 3. インスタンスエンドポイントがハンドルドメインを返すか: + + ~~~~ sh frame="none" + curl https://ap.example.com/api/v2/instance | jq .domain + ~~~~ + + `"example.com"`が出力されるはずです。 + +より入念に監査したい場合は、Julian Fietkau の [WebFinger Canary] を +ハンドルドメインに対して実行してみてください。現在 Mastodon と +Fedify ベースのサーバーはドメイン分離設定を正しく扱えますが、 +Misskey と Pixelfed は今のところそうではありません。 + +[WebFinger Canary]: https://correct.webfinger-canary.fietkau.software/ + + + diff --git a/docs/src/content/docs/ko/install/env.mdx b/docs/src/content/docs/ko/install/env.mdx index 2b5dc914..be923267 100644 --- a/docs/src/content/docs/ko/install/env.mdx +++ b/docs/src/content/docs/ko/install/env.mdx @@ -78,6 +78,33 @@ Hollo가 L7 로드 밸런서 뒤에 위치할 경우 (일반적으로 그래야 이 동작은 보안상 주의를 기울여야 합니다. +### `HANDLE_HOST` + +[도메인 분리 WebFinger 설정][split-domain]에서 사용할 페디버스 핸들 +도메인입니다. 이 옵션을 설정하면 페디버스 핸들이 +`@user@HANDLE_HOST` 형식이 되지만, Hollo 자체는 여전히 +[`WEB_ORIGIN`](#web_origin)에서 동작합니다. + +`WEB_ORIGIN`과 함께 설정해야 하며, 한 쪽만 설정하면 Hollo가 시작에 +실패합니다. **계정을 처음 만들기 전에** 두 변수를 설정해야 합니다. +연합이 시작된 뒤 핸들 도메인을 바꾸면 원격 팔로워 관계가 끊어집니다. + +기본적으로 설정되어 있지 않습니다. + +[split-domain]: /ko/install/split-domain/ + +### `WEB_ORIGIN` + +[도메인 분리 WebFinger 설정][split-domain]에서 Hollo의 ActivityPub +서버가 실제로 동작하는 오리진(스킴 + 호스트)입니다. 예: +`https://ap.example.com`. 액터 URI, 인박스 URL을 비롯한 모든 +연합 엔드포인트가 이 오리진을 기반으로 만들어집니다. + +[`HANDLE_HOST`](#handle_host)와 함께 설정해야 하며, 한 쪽만 +설정하면 Hollo가 시작에 실패합니다. + +기본적으로 설정되어 있지 않습니다. + ### `ALLOW_PRIVATE_ADDRESS` 이 옵션을 `true`로 설정하면 서버 측 요청 위조(SSRF) 공격 방지를 풉니다. diff --git a/docs/src/content/docs/ko/install/split-domain.mdx b/docs/src/content/docs/ko/install/split-domain.mdx new file mode 100644 index 00000000..715180dc --- /dev/null +++ b/docs/src/content/docs/ko/install/split-domain.mdx @@ -0,0 +1,176 @@ +--- +title: 도메인 분리 WebFinger +description: Hollo를 한 도메인에서 운영하면서 다른 도메인으로 계정 핸들을 노출하는 방법. +--- + +import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components'; + +Hollo는 *도메인 분리 WebFinger* 설정을 지원합니다. 페디버스 +핸들은 한 도메인 아래(예: `@alice@example.com`)에 있고, +실제 ActivityPub 서버는 다른 도메인(예: `https://ap.example.com`) +에서 동작하는 방식입니다. Mastodon이 "[루트 도메인에서 +WebFinger 호스팅하기][mastodon-webfinger]"라는 문서로 설명하는 +패턴, 그리고 GoToSocial이 *host-meta* 기반 호스트 스왑이라고 +부르는 것과 같은 패턴입니다. + +이 기능은 [Fedify의 `origin` 옵션] 위에 구현되어 있습니다. + +[mastodon-webfinger]: https://docs.joinmastodon.org/admin/config/#web_domain +[Fedify의 `origin` 옵션]: https://fedify.dev/manual/federation#separating-webfinger-host-from-the-server-origin + + +왜 도메인을 분리할까요? +----------------------- + +전형적인 동기는 이미 짧고 좋은 도메인(`example.com`)을 가지고 있고 +핸들을 `@alice@example.com`처럼 보여주고 싶지만, 그 도메인의 루트 +자체에는 Hollo를 두기 어려운 경우입니다. 루트 도메인은 홈페이지, +다른 웹 앱, 또는 기존 서비스가 차지하고 있기 마련이지요. + +도메인 분리 설정에서는: + + - 사용자는 페디버스 어디서든 `@alice@example.com`이라는 형태로 + 여러분을 부릅니다. + - Hollo 자체는 `https://ap.example.com`에서 동작합니다. 웹 UI, + Mastodon 호환 API, OAuth, 액터 URI가 모두 이 도메인에 있습니다. + - 루트 도메인 `example.com`은 단 한 가지 일만 하면 됩니다: + `/.well-known/webfinger` 요청을 `ap.example.com`으로 + 리다이렉트하는 것. + + + + +설정 방법 +--------- + +다음 두 환경 변수를 Hollo 인스턴스에 설정하세요: + + - [`HANDLE_HOST`](/ko/install/env/#handle_host) — 핸들에 사용할 + 호스트명만(스킴 없음, 경로 없음). 예: `example.com`. + - [`WEB_ORIGIN`](/ko/install/env/#web_origin) — Hollo가 실제로 + 동작하는 스킴+호스트. 예: `https://ap.example.com`. + +두 변수는 반드시 함께 설정해야 합니다. 한 쪽만 설정하면 Hollo가 +시작에 실패합니다. + +~~~~ env +HANDLE_HOST=example.com +WEB_ORIGIN=https://ap.example.com +~~~~ + +이렇게 설정하면 나머지는 Fedify가 알아서 처리합니다: + + - WebFinger 응답의 `subject`는 `acct:alice@example.com`. + - 액터 URI는 `https://ap.example.com/@alice` 형식으로 만들어집니다. + - Mastodon 호환 `/api/v1/instance`, `/api/v2/instance` 엔드포인트는 + 인스턴스 도메인을 `example.com`으로 알려주므로, 클라이언트가 + 정확한 핸들을 표시합니다. + + +리버스 프록시 리다이렉트 +------------------------ + +Hollo는 `WEB_ORIGIN` 호스트에서만 응답합니다. 원격 서버가 +`@alice@example.com`을 조회할 때는 `https://example.com/.well-known/webfinger`로 +WebFinger 요청을 보내므로, 해당 도메인의 리버스 프록시가 그 요청을 +Hollo로 리다이렉트하도록 설정해야 합니다. + +쿼리스트링을 보존하는 301 리다이렉트면 충분합니다. 일부 구현체가 +디스커버리 중에 `/.well-known/nodeinfo`와 `/.well-known/host-meta`도 +조회하므로 함께 리다이렉트해 두는 것이 좋습니다. + + + +~~~~ nginx +server { + listen 443 ssl; + server_name example.com; + # … 일반적인 사이트 설정 … + + location /.well-known/webfinger { + return 301 https://ap.example.com$request_uri; + } + location /.well-known/nodeinfo { + return 301 https://ap.example.com$request_uri; + } + location /.well-known/host-meta { + return 301 https://ap.example.com$request_uri; + } +} +~~~~ + + +~~~~ caddy +example.com { + # … 일반적인 사이트 설정 … + + redir /.well-known/webfinger* https://ap.example.com{uri} permanent + redir /.well-known/nodeinfo* https://ap.example.com{uri} permanent + redir /.well-known/host-meta* https://ap.example.com{uri} permanent +} +~~~~ + + + +`/@username` 경로나 액터 URL은 리다이렉트하지 **않아도** 됩니다. +이 URL들은 `ap.example.com`에 있고, 원격 서버는 WebFinger 응답을 +해석한 뒤 곧장 그쪽으로 갑니다. + + +설정 검증 +--------- + +배포 후 다음 세 가지를 확인하면 모든 것이 제대로 연결됐는지 알 수 있습니다: + + 1. 핸들 도메인의 WebFinger가 리다이렉트되는지: + + ~~~~ sh frame="none" + curl -i "https://example.com/.well-known/webfinger?resource=acct:alice@example.com" + ~~~~ + + `https://ap.example.com/.well-known/webfinger?...`로 가는 + 301 응답이 반환되어야 합니다. + + 2. 서버 도메인의 WebFinger가 응답하는지: + + ~~~~ sh frame="none" + curl "https://ap.example.com/.well-known/webfinger?resource=acct:alice@example.com" + ~~~~ + + `subject`가 `acct:alice@example.com`이고 `self` 링크가 + `https://ap.example.com/@alice`를 가리키는 JRD 응답이 + 반환되어야 합니다. + + 3. 인스턴스 엔드포인트가 핸들 도메인을 알려주는지: + + ~~~~ sh frame="none" + curl https://ap.example.com/api/v2/instance | jq .domain + ~~~~ + + `"example.com"`이 출력되어야 합니다. + +더 꼼꼼히 확인하고 싶다면 Julian Fietkau가 만든 [WebFinger Canary]를 +핸들 도메인에 돌려보세요. 현재 Mastodon과 Fedify 기반 서버는 +도메인 분리 설정을 잘 처리하지만, Misskey와 Pixelfed는 아직 그렇지 +않습니다. + +[WebFinger Canary]: https://correct.webfinger-canary.fietkau.software/ + + + diff --git a/docs/src/content/docs/zh-cn/install/env.mdx b/docs/src/content/docs/zh-cn/install/env.mdx index 70f7fa34..d9797702 100644 --- a/docs/src/content/docs/zh-cn/install/env.mdx +++ b/docs/src/content/docs/zh-cn/install/env.mdx @@ -71,6 +71,32 @@ openssl rand -hex 32 启用此选项后,Hollo将信任来自反向代理的`X-Forwarded-For`、`X-Forwarded-Proto`和`X-Forwarded-Host`头。这对于安全来说非常重要。 +### `HANDLE_HOST` + +在[分域 WebFinger 配置][split-domain]中用作联邦宇宙账号句柄的域名。 +设置后,联邦宇宙账号的形式为 `@user@HANDLE_HOST`,但 Hollo 本身 +仍在 [`WEB_ORIGIN`](#web_origin) 上运行。 + +必须与 `WEB_ORIGIN` 一起设置;只设置其中一个会导致 Hollo 启动失败。 +**请务必在创建第一个账号之前**完成配置——一旦联邦开始后再更改 +句柄域名,会破坏所有远程关注关系。 + +默认情况下不设置。 + +[split-domain]: /zh-cn/install/split-domain/ + +### `WEB_ORIGIN` + +在[分域 WebFinger 配置][split-domain]中 Hollo 的 ActivityPub +服务器实际运行的来源(scheme + host),例如 +`https://ap.example.com`。所有 actor URI、收件箱 URL 等联邦端点 +都将基于此来源构建。 + +必须与 [`HANDLE_HOST`](#handle_host) 一起设置; +只设置其中一个会导致 Hollo 启动失败。 + +默认情况下不设置。 + ### `ALLOW_PRIVATE_ADDRESS` 将此选项设置为`true`将禁用 SSRF(服务器端请求伪造)保护。 diff --git a/docs/src/content/docs/zh-cn/install/split-domain.mdx b/docs/src/content/docs/zh-cn/install/split-domain.mdx new file mode 100644 index 00000000..fff0b401 --- /dev/null +++ b/docs/src/content/docs/zh-cn/install/split-domain.mdx @@ -0,0 +1,164 @@ +--- +title: 分域 WebFinger +description: 在一个域名运行 Hollo 的同时,使用另一个域名作为账号句柄。 +--- + +import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components'; + +Hollo 支持*分域 WebFinger* 配置:联邦宇宙账号句柄使用一个域名 +(例如 `@alice@example.com`),而实际的 ActivityPub 服务器运行在 +另一个域名(例如 `https://ap.example.com`)上。这与 Mastodon 文档中 +"[在根域名上托管 WebFinger][mastodon-webfinger]" 描述的模式,以及 +GoToSocial 所称的基于 *host-meta* 的主机互换模式相同。 + +此功能基于 [Fedify 的 `origin` 配置选项] 实现。 + +[mastodon-webfinger]: https://docs.joinmastodon.org/admin/config/#web_domain +[Fedify 的 `origin` 配置选项]: https://fedify.dev/manual/federation#separating-webfinger-host-from-the-server-origin + + +为什么要分域? +-------------- + +最常见的动机是:你已经拥有一个简短好记的域名(`example.com`), +希望句柄看起来像 `@alice@example.com`,但又不想把 Hollo 部署在该 +域名的根上——根域名往往被首页、其他 Web 应用或既有服务占用。 + +在分域配置下: + + - 用户在联邦宇宙的任何地方都以 `@alice@example.com` 称呼你。 + - Hollo 本身运行在 `https://ap.example.com`。Web UI、 + Mastodon 兼容 API、OAuth 和 actor URI 都位于此域名。 + - 根域名 `example.com` 只需做一件事:把 + `/.well-known/webfinger` 请求重定向到 `ap.example.com`。 + + + + +配置方法 +-------- + +在 Hollo 实例上设置以下两个环境变量: + + - [`HANDLE_HOST`](/zh-cn/install/env/#handle_host) — 用于句柄的 + 主机名(不含 scheme,也不含路径),例如 `example.com`。 + - [`WEB_ORIGIN`](/zh-cn/install/env/#web_origin) — Hollo 实际运行的 + scheme + host,例如 `https://ap.example.com`。 + +两者必须同时设置;只设置其中一个会导致 Hollo 启动失败。 + +~~~~ env +HANDLE_HOST=example.com +WEB_ORIGIN=https://ap.example.com +~~~~ + +完成此配置后,剩下的由 Fedify 处理: + + - WebFinger 响应的 `subject` 为 `acct:alice@example.com`。 + - Actor URI 形如 `https://ap.example.com/@alice`。 + - Mastodon 兼容的 `/api/v1/instance` 与 `/api/v2/instance` + 端点将实例域名报告为 `example.com`,因此客户端会显示正确的句柄。 + + +反向代理重定向 +-------------- + +Hollo 自身只监听 `WEB_ORIGIN` 主机。解析 `@alice@example.com` 的 +远程服务器会向 `https://example.com/.well-known/webfinger` 发送 +WebFinger 请求,因此你需要在该域名的反向代理上把这些请求重定向到 +Hollo。 + +保留查询字符串的 301 重定向即可。一些实现会在发现阶段探测 +`/.well-known/nodeinfo` 和 `/.well-known/host-meta`,建议一并 +重定向。 + + + +~~~~ nginx +server { + listen 443 ssl; + server_name example.com; + # … 你的常规站点配置 … + + location /.well-known/webfinger { + return 301 https://ap.example.com$request_uri; + } + location /.well-known/nodeinfo { + return 301 https://ap.example.com$request_uri; + } + location /.well-known/host-meta { + return 301 https://ap.example.com$request_uri; + } +} +~~~~ + + +~~~~ caddy +example.com { + # … 你的常规站点配置 … + + redir /.well-known/webfinger* https://ap.example.com{uri} permanent + redir /.well-known/nodeinfo* https://ap.example.com{uri} permanent + redir /.well-known/host-meta* https://ap.example.com{uri} permanent +} +~~~~ + + + +**不需要**重定向 `/@username` 路径或任何 actor URL;这些 URL 位于 +`ap.example.com`,远程服务器在解析 WebFinger 响应后会直接访问那里。 + + +验证配置 +-------- + +部署后,以下三项检查可以快速确认是否一切正常: + + 1. 句柄域名上的 WebFinger 是否被重定向: + + ~~~~ sh frame="none" + curl -i "https://example.com/.well-known/webfinger?resource=acct:alice@example.com" + ~~~~ + + 应返回 `https://ap.example.com/.well-known/webfinger?...` 的 + 301 响应。 + + 2. 服务器域名上的 WebFinger 是否响应: + + ~~~~ sh frame="none" + curl "https://ap.example.com/.well-known/webfinger?resource=acct:alice@example.com" + ~~~~ + + 应返回 JRD,其中 `subject` 为 `acct:alice@example.com`, + `self` 链接指向 `https://ap.example.com/@alice`。 + + 3. 实例端点是否报告句柄域名: + + ~~~~ sh frame="none" + curl https://ap.example.com/api/v2/instance | jq .domain + ~~~~ + + 应输出 `"example.com"`。 + +若需更全面的审计,可以用 Julian Fietkau 的 [WebFinger Canary] 对 +你的句柄域名进行检查。目前 Mastodon 与基于 Fedify 的服务器能够 +正确处理分域配置;Misskey 和 Pixelfed 尚不支持。 + +[WebFinger Canary]: https://correct.webfinger-canary.fietkau.software/ + + + diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts index 95ea162c..eca5cb2b 100644 --- a/src/api/v1/accounts.ts +++ b/src/api/v1/accounts.ts @@ -39,6 +39,7 @@ import { REMOTE_ACTOR_FETCH_POSTS, unfollowAccount, } from "../../federation/account"; +import { getInstanceHost } from "../../instance-host"; import { scopeRequired, tokenRequired, @@ -347,7 +348,7 @@ app.get( accounts.handle, acct.includes("@") ? `@${acct}` - : `@${acct}@${new URL(c.req.url).host}`, + : `@${acct}@${getInstanceHost(new URL(c.req.url))}`, ), with: { owner: true, successor: true }, })) ?? null; diff --git a/src/api/v1/instance.ts b/src/api/v1/instance.ts index 922969a5..92ef8043 100644 --- a/src/api/v1/instance.ts +++ b/src/api/v1/instance.ts @@ -4,12 +4,14 @@ import { Hono } from "hono"; import metadata from "../../../package.json" with { type: "json" }; import { db } from "../../db"; import { serializeAccountOwner } from "../../entities/account"; +import { getInstanceHost } from "../../instance-host"; import { accountOwners, instances, posts } from "../../schema"; const app = new Hono(); app.get("/", async (c) => { const url = new URL(c.req.url); + const instanceHost = getInstanceHost(url); const credential = await db.query.credentials.findFirst(); if (credential == null) return c.notFound(); const accountOwner = await db.query.accountOwners.findFirst({ @@ -40,10 +42,10 @@ app.get("/", async (c) => { .select({ domainCount: count() }) .from(instances); return c.json({ - uri: url.host, - title: url.host, - short_description: `A Hollo instance at ${url.host}`, - description: `A Hollo instance at ${url.host}`, + uri: instanceHost, + title: instanceHost, + short_description: `A Hollo instance at ${instanceHost}`, + description: `A Hollo instance at ${instanceHost}`, email: credential.email, version: metadata.version, urls: {}, // TODO: streaming_api URL diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 4948b2d8..088addc2 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -45,6 +45,7 @@ import { } from "../../federation/post"; import { appendPostToTimelines } from "../../federation/timeline"; import { requestBody } from "../../helpers"; +import { getInstanceHost } from "../../instance-host"; import { getAccessToken } from "../../oauth/helpers"; import { scopeRequired, @@ -1381,7 +1382,9 @@ async function addEmojiReaction( if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); let emoji = c.req.param("emoji"); const url = new URL(c.req.url); - if (emoji.endsWith(`@${url.host}`)) emoji = emoji.replace(/@[^@]+$/, ""); + if (emoji.endsWith(`@${getInstanceHost(url)}`)) { + emoji = emoji.replace(/@[^@]+$/, ""); + } let emojiCode = ""; let tag: Emoji | null = null; if (emoji.includes("@")) { @@ -1527,7 +1530,9 @@ async function removeEmojiReaction( if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); let emoji = c.req.param("emoji"); const url = new URL(c.req.url); - if (emoji.endsWith(`@${url.host}`)) emoji = emoji.replace(/@[^@]+$/, ""); + if (emoji.endsWith(`@${getInstanceHost(url)}`)) { + emoji = emoji.replace(/@[^@]+$/, ""); + } const unicode = /^[\p{Emoji}]+$/u.test(emoji); const deleted = await db .delete(reactions) diff --git a/src/api/v2/instance.ts b/src/api/v2/instance.ts index dc4eea47..1ff53428 100644 --- a/src/api/v2/instance.ts +++ b/src/api/v2/instance.ts @@ -4,12 +4,14 @@ import { Hono } from "hono"; import metadata from "../../../package.json" with { type: "json" }; import { db } from "../../db"; import { serializeAccountOwner } from "../../entities/account"; +import { getInstanceHost } from "../../instance-host"; import { accountOwners, posts } from "../../schema"; const app = new Hono(); app.get("/", async (c) => { const url = new URL(c.req.url); + const instanceHost = getInstanceHost(url); const credential = await db.query.credentials.findFirst(); if (credential == null) return c.notFound(); const accountOwner = await db.query.accountOwners.findFirst({ @@ -34,11 +36,11 @@ app.get("/", async (c) => { api_versions: { mastodon: 7, }, - domain: url.host, - title: url.host, + domain: instanceHost, + title: instanceHost, version: metadata.version, source_url: "https://github.com/fedify-dev/hollo", - description: `A Hollo instance at ${url.host}`, + description: `A Hollo instance at ${instanceHost}`, usage: { users: { // TODO: Track active users in the past 4 weeks diff --git a/src/entities/account.ts b/src/entities/account.ts index e1b925c7..1018c193 100644 --- a/src/entities/account.ts +++ b/src/entities/account.ts @@ -1,3 +1,4 @@ +import { getInstanceHost } from "../instance-host"; import { proxyUrl } from "../media-proxy"; import type { Account, AccountOwner, Block, Follow, Mute } from "../schema"; import type { Uuid } from "../uuid"; @@ -19,7 +20,7 @@ export function serializeAccount( baseUrl, ).href; let acct = account.handle.replace(/^@/, ""); - if (acct.endsWith(`@${baseUrl.host}`)) { + if (acct.endsWith(`@${getInstanceHost(baseUrl)}`)) { acct = acct.replace(/@[^@]+$/, ""); } const avatar = proxyUrl(account.avatarUrl, baseUrl) ?? defaultAvatarUrl; diff --git a/src/env.ts b/src/env.ts index 8139760a..7b642461 100644 --- a/src/env.ts +++ b/src/env.ts @@ -14,3 +14,24 @@ if (secretKey.length < SECRET_KEY_MINIMUM_LENGTH) { } export const SECRET_KEY = secretKey; + +// oxlint-disable-next-line typescript/dot-notation +const rawHandleHost = process.env["HANDLE_HOST"]?.trim().toLowerCase(); +// oxlint-disable-next-line typescript/dot-notation +const rawWebOrigin = process.env["WEB_ORIGIN"]?.trim().replace(/\/+$/, ""); + +const handleHostSet = rawHandleHost != null && rawHandleHost !== ""; +const webOriginSet = rawWebOrigin != null && rawWebOrigin !== ""; + +if (handleHostSet !== webOriginSet) { + throw new Error( + "HANDLE_HOST and WEB_ORIGIN must be set together (or both unset).", + ); +} + +export const HANDLE_HOST = handleHostSet ? rawHandleHost : undefined; +export const WEB_ORIGIN = webOriginSet ? rawWebOrigin : undefined; +export const FEDIFY_ORIGIN = + HANDLE_HOST != null && WEB_ORIGIN != null + ? { handleHost: HANDLE_HOST, webOrigin: WEB_ORIGIN } + : undefined; diff --git a/src/federation/federation.ts b/src/federation/federation.ts index 7f47c69b..46dab86e 100644 --- a/src/federation/federation.ts +++ b/src/federation/federation.ts @@ -17,6 +17,7 @@ import { import metadata from "../../package.json" with { type: "json" }; import { postgres } from "../db"; +import { FEDIFY_ORIGIN } from "../env"; // oxlint-disable-next-line typescript/dot-notation const nodeType = process.env["NODE_TYPE"] ?? "all"; @@ -54,6 +55,7 @@ let federation: Federation & { sink?: Sink } = createFederation({ // oxlint-disable-next-line typescript/dot-notation allowPrivateAddress: process.env["ALLOW_PRIVATE_ADDRESS"] === "true", tracerProvider, + origin: FEDIFY_ORIGIN, }); if (fedifyDebug && exporter != null) { diff --git a/src/handle-host-check.ts b/src/handle-host-check.ts new file mode 100644 index 00000000..a6cb12d2 --- /dev/null +++ b/src/handle-host-check.ts @@ -0,0 +1,27 @@ +import { getLogger } from "@logtape/logtape"; + +import { db } from "./db"; +import { HANDLE_HOST } from "./env"; + +const logger = getLogger(["hollo", "config"]); + +export async function checkHandleHostConsistency(): Promise { + if (HANDLE_HOST == null) return; + const owners = await db.query.accountOwners.findMany({ + with: { account: { columns: { handle: true } } }, + }); + for (const owner of owners) { + const handle = owner.account.handle; + const at = handle.lastIndexOf("@"); + if (at < 0) continue; + const existingHost = handle.slice(at + 1).toLowerCase(); + if (existingHost === HANDLE_HOST) continue; + logger.warn( + "Configured HANDLE_HOST ({configured}) does not match existing " + + "account handle host ({existing}) for {handle}. Changing the " + + "handle domain after federation has begun breaks remote follow " + + "relationships. See https://docs.hollo.social/install/split-domain/", + { configured: HANDLE_HOST, existing: existingHost, handle }, + ); + } +} diff --git a/src/instance-host.ts b/src/instance-host.ts new file mode 100644 index 00000000..a90b6a13 --- /dev/null +++ b/src/instance-host.ts @@ -0,0 +1,6 @@ +import { HANDLE_HOST } from "./env"; + +export function getInstanceHost(fallback: URL | string): string { + if (HANDLE_HOST != null) return HANDLE_HOST; + return typeof fallback === "string" ? fallback : fallback.host; +} diff --git a/src/pages/accounts.tsx b/src/pages/accounts.tsx index 6b3fa50b..2ee0a9e6 100644 --- a/src/pages/accounts.tsx +++ b/src/pages/accounts.tsx @@ -35,6 +35,7 @@ import { REMOTE_ACTOR_FETCH_POSTS, unfollowAccount, } from "../federation/account.ts"; +import { getInstanceHost } from "../instance-host.ts"; import { loginRequired } from "../login.ts"; import { type Account, @@ -163,7 +164,7 @@ accounts.post("/", async (c) => { : undefined, }} officialAccount={HOLLO_OFFICIAL_ACCOUNT} - host={new URL(c.req.url).host} + host={getInstanceHost(new URL(c.req.url))} />, 400, ); @@ -190,7 +191,7 @@ accounts.post("/", async (c) => { }} errors={{ avatar: "Avatar must be a JPEG, PNG, or GIF." }} officialAccount={HOLLO_OFFICIAL_ACCOUNT} - host={new URL(c.req.url).host} + host={getInstanceHost(new URL(c.req.url))} />, 400, ); @@ -217,7 +218,7 @@ accounts.post("/", async (c) => { }} errors={{ header: "Header image must be a JPEG, PNG, or GIF." }} officialAccount={HOLLO_OFFICIAL_ACCOUNT} - host={new URL(c.req.url).host} + host={getInstanceHost(new URL(c.req.url))} />, 400, ); @@ -269,11 +270,12 @@ accounts.post("/", async (c) => { uploadedPaths.push(result.path); } } + const handleHost = getInstanceHost(fedCtx.host); dbResult = await db.transaction(async (tx) => { await tx .insert(instances) .values({ - host: fedCtx.host, + host: handleHost, software: "hollo", softwareVersion: null, }) @@ -283,11 +285,11 @@ accounts.post("/", async (c) => { .values({ id: accountId, iri: fedCtx.getActorUri(username).href, - instanceHost: fedCtx.host, + instanceHost: handleHost, type: "Person", name, emojis, - handle: `@${username}@${fedCtx.host}`, + handle: `@${username}@${handleHost}`, bioHtml: bioResult.html, url: fedCtx.getActorUri(username).href, protected: protected_, @@ -399,7 +401,7 @@ accounts.get("/new", (c) => { expandSpoilers: false, }} officialAccount={HOLLO_OFFICIAL_ACCOUNT} - host={new URL(c.req.url).host} + host={getInstanceHost(new URL(c.req.url))} />, ); }); @@ -429,7 +431,7 @@ accounts.get("/:id", async (c) => { accountOwner={accountOwner} news={news != null} officialAccount={HOLLO_OFFICIAL_ACCOUNT} - host={new URL(c.req.url).host} + host={getInstanceHost(new URL(c.req.url))} />, ); }); @@ -549,7 +551,7 @@ accounts.post("/:id", async (c) => { name: name == null || name === "" ? "Display name is required." : "", }} officialAccount={HOLLO_OFFICIAL_ACCOUNT} - host={new URL(c.req.url).host} + host={getInstanceHost(new URL(c.req.url))} />, 400, ); @@ -579,7 +581,7 @@ accounts.post("/:id", async (c) => { }} errors={{ avatar: "Avatar must be a JPEG, PNG, or GIF." }} officialAccount={HOLLO_OFFICIAL_ACCOUNT} - host={new URL(c.req.url).host} + host={getInstanceHost(new URL(c.req.url))} />, 400, ); @@ -609,7 +611,7 @@ accounts.post("/:id", async (c) => { }} errors={{ header: "Header image must be a JPEG, PNG, or GIF." }} officialAccount={HOLLO_OFFICIAL_ACCOUNT} - host={new URL(c.req.url).host} + host={getInstanceHost(new URL(c.req.url))} />, 400, ); diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 564fd377..04d0d3f7 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -4,6 +4,7 @@ import { Hono } from "hono"; import { Layout } from "../../components/Layout.tsx"; import { renderCustomEmojis } from "../../custom-emoji.ts"; import db from "../../db.ts"; +import { getInstanceHost } from "../../instance-host.ts"; import { proxyUrl } from "../../media-proxy.ts"; const homePage = new Hono().basePath("/"); @@ -25,7 +26,7 @@ homePage.get("/", async (c) => { // oxlint-disable-next-line typescript/dot-notation return c.redirect(process.env["HOME_URL"]); } - const host = new URL(c.req.url).host; + const host = getInstanceHost(new URL(c.req.url)); const themeColor = owners[0]?.themeColor; return c.html( From df90b2dffcea5d75eb490723dfac7c03ecb36c54 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 00:12:06 +0900 Subject: [PATCH 02/14] Treat WEB_ORIGIN host as a local handle alias In split-domain mode, local accounts and custom emojis are stored under HANDLE_HOST, but Fedify also recognizes the WEB_ORIGIN host as an equivalent alias for the same account. The Mastodon API endpoints, however, only stripped or normalized the HANDLE_HOST suffix, so a client supplying alice@ap.example.com would 404 against @alice@example.com on /api/v1/accounts/lookup, and a local emoji reaction sent as myemoji@ap.example.com would fall through to the remote-emoji branch and fail. Add an isLocalHost(host, requestUrl) helper in src/instance-host.ts that recognizes the request host, HANDLE_HOST, and the host part of WEB_ORIGIN as local. Use it in /api/v1/accounts/lookup to canonicalize the lookup handle to the stored HANDLE_HOST form, and in the two emoji reaction handlers in /api/v1/statuses.ts to strip a local suffix before deciding whether the emoji is remote. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242252420 https://github.com/fedify-dev/hollo/pull/484#discussion_r3242252426 Assisted-by: Claude Code:claude-opus-4-7 --- src/api/v1/accounts.ts | 21 ++++++++++++++------- src/api/v1/statuses.ts | 12 +++++++----- src/instance-host.ts | 13 ++++++++++++- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts index eca5cb2b..2c397b5c 100644 --- a/src/api/v1/accounts.ts +++ b/src/api/v1/accounts.ts @@ -39,7 +39,7 @@ import { REMOTE_ACTOR_FETCH_POSTS, unfollowAccount, } from "../../federation/account"; -import { getInstanceHost } from "../../instance-host"; +import { getInstanceHost, isLocalHost } from "../../instance-host"; import { scopeRequired, tokenRequired, @@ -337,6 +337,18 @@ app.get( async (c) => { const query = c.req.valid("query"); const acct = query.acct; + const requestUrl = new URL(c.req.url); + const at = acct.lastIndexOf("@"); + let handleLookup: string; + if (at < 0) { + handleLookup = `@${acct}@${getInstanceHost(requestUrl)}`; + } else if (isLocalHost(acct.slice(at + 1), requestUrl)) { + // Normalize WEB_ORIGIN-host aliases to the canonical HANDLE_HOST form + // that local accounts are stored under. + handleLookup = `@${acct.slice(0, at)}@${getInstanceHost(requestUrl)}`; + } else { + handleLookup = `@${acct}`; + } let account: | (Account & { owner: AccountOwner | null; @@ -344,12 +356,7 @@ app.get( }) | null = (await db.query.accounts.findFirst({ - where: eq( - accounts.handle, - acct.includes("@") - ? `@${acct}` - : `@${acct}@${getInstanceHost(new URL(c.req.url))}`, - ), + where: eq(accounts.handle, handleLookup), with: { owner: true, successor: true }, })) ?? null; if (account == null) { diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 088addc2..1cda554c 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -45,7 +45,7 @@ import { } from "../../federation/post"; import { appendPostToTimelines } from "../../federation/timeline"; import { requestBody } from "../../helpers"; -import { getInstanceHost } from "../../instance-host"; +import { isLocalHost } from "../../instance-host"; import { getAccessToken } from "../../oauth/helpers"; import { scopeRequired, @@ -1382,8 +1382,9 @@ async function addEmojiReaction( if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); let emoji = c.req.param("emoji"); const url = new URL(c.req.url); - if (emoji.endsWith(`@${getInstanceHost(url)}`)) { - emoji = emoji.replace(/@[^@]+$/, ""); + const emojiAt = emoji.lastIndexOf("@"); + if (emojiAt >= 0 && isLocalHost(emoji.slice(emojiAt + 1), url)) { + emoji = emoji.slice(0, emojiAt); } let emojiCode = ""; let tag: Emoji | null = null; @@ -1530,8 +1531,9 @@ async function removeEmojiReaction( if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); let emoji = c.req.param("emoji"); const url = new URL(c.req.url); - if (emoji.endsWith(`@${getInstanceHost(url)}`)) { - emoji = emoji.replace(/@[^@]+$/, ""); + const emojiAt = emoji.lastIndexOf("@"); + if (emojiAt >= 0 && isLocalHost(emoji.slice(emojiAt + 1), url)) { + emoji = emoji.slice(0, emojiAt); } const unicode = /^[\p{Emoji}]+$/u.test(emoji); const deleted = await db diff --git a/src/instance-host.ts b/src/instance-host.ts index a90b6a13..232c1ba4 100644 --- a/src/instance-host.ts +++ b/src/instance-host.ts @@ -1,6 +1,17 @@ -import { HANDLE_HOST } from "./env"; +import { HANDLE_HOST, WEB_ORIGIN } from "./env"; + +const WEB_ORIGIN_HOST = + WEB_ORIGIN != null ? new URL(WEB_ORIGIN).host.toLowerCase() : undefined; export function getInstanceHost(fallback: URL | string): string { if (HANDLE_HOST != null) return HANDLE_HOST; return typeof fallback === "string" ? fallback : fallback.host; } + +export function isLocalHost(host: string, requestUrl: URL): boolean { + const lower = host.toLowerCase(); + if (lower === requestUrl.host.toLowerCase()) return true; + if (HANDLE_HOST != null && lower === HANDLE_HOST) return true; + if (WEB_ORIGIN_HOST != null && lower === WEB_ORIGIN_HOST) return true; + return false; +} From 17e2c880004fcd021e012b65fce684bf268ca6c4 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 00:12:56 +0900 Subject: [PATCH 03/14] Tighten handle parsing in startup check The startup consistency check in src/handle-host-check.ts extracted the host portion of each stored handle with lastIndexOf("@") and slice(at + 1), which produced a wrong existingHost on a malformed handle that doesn't follow the canonical "@user@host" form: a bare "@alice" gives at = 0 and existingHost = "alice", which would then never match HANDLE_HOST and trigger a spurious warning. In practice Hollo always writes canonical handles, so no real warning fires today. Tighten the parser defensively: require the leading "@", require lastIndexOf("@") > 0 so the second "@" actually exists and sits past the leading one, and skip empty host segments. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242281286 Assisted-by: Claude Code:claude-opus-4-7 --- src/handle-host-check.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/handle-host-check.ts b/src/handle-host-check.ts index a6cb12d2..66d16a46 100644 --- a/src/handle-host-check.ts +++ b/src/handle-host-check.ts @@ -12,9 +12,13 @@ export async function checkHandleHostConsistency(): Promise { }); for (const owner of owners) { const handle = owner.account.handle; + // Local handles are stored in canonical "@user@host" form; ignore + // anything that doesn't have both the leading "@" and a host segment. + if (!handle.startsWith("@")) continue; const at = handle.lastIndexOf("@"); - if (at < 0) continue; + if (at <= 0) continue; const existingHost = handle.slice(at + 1).toLowerCase(); + if (existingHost === "") continue; if (existingHost === HANDLE_HOST) continue; logger.warn( "Configured HANDLE_HOST ({configured}) does not match existing " + From c8b5e7dab66313a5ebb6840296520078dfa73473 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 00:32:29 +0900 Subject: [PATCH 04/14] Strip leading @ from handle-like API input Gemini Code Assist flagged that /api/v1/accounts/lookup (the acct param) and the two /api/v1/statuses/:id/emoji_reactions/:emoji handlers don't strip a leading @ from user-supplied input. The lookup case is a real bug. If a client passes the user-typed form, e.g. acct=@alice@example.com, every existing branch prepends its own @ when building the DB lookup, so the handle becomes @@alice@example.com and matches no stored row. Many fediverse clients pass through whatever the user typed, so this is reachable in practice. The two emoji cases are theoretical (Mastodon clients don't @-prefix shortcodes), but the same one-line fix applies. Add normalizeHandle(handle) to src/patterns.ts next to HANDLE_PATTERN. It strips one leading @. The lookup endpoint and both emoji handlers call it on the user input before any further parsing. While in src/api/v1/accounts.ts, the same helper also replaces the three existing inline query.q.replace(/^@/, "") calls in /search, in line with the reviewer's single-source-of-truth point. Other .replace(/^@/, "") occurrences across the codebase operate on already-canonical stored handles with a guaranteed leading @, not on user input, so they're a different concern and stay as they are. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242346674 https://github.com/fedify-dev/hollo/pull/484#discussion_r3242346697 https://github.com/fedify-dev/hollo/pull/484#discussion_r3242346704 Assisted-by: Claude Code:claude-opus-4-7 --- src/api/v1/accounts.ts | 11 ++++++----- src/api/v1/statuses.ts | 5 +++-- src/patterns.ts | 12 ++++++++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts index 2c397b5c..522aefb2 100644 --- a/src/api/v1/accounts.ts +++ b/src/api/v1/accounts.ts @@ -336,7 +336,7 @@ app.get( ), async (c) => { const query = c.req.valid("query"); - const acct = query.acct; + const acct = normalizeHandle(query.acct); const requestUrl = new URL(c.req.url); const at = acct.lastIndexOf("@"); let handleLookup: string; @@ -391,7 +391,7 @@ app.get( }, ); -import { HANDLE_PATTERN } from "../../patterns"; +import { HANDLE_PATTERN, normalizeHandle } from "../../patterns"; app.get( "/search", @@ -421,9 +421,10 @@ app.get( ), async (c) => { const query = c.req.valid("query"); + const normalizedQ = normalizeHandle(query.q); if (query.resolve && HANDLE_PATTERN.test(query.q) && query.offset < 1) { const exactMatch = await db.query.accounts.findFirst({ - where: ilike(accounts.handle, `@${query.q.replace(/^@/, "")}`), + where: ilike(accounts.handle, `@${normalizedQ}`), }); if (exactMatch != null) { const fedCtx = federation.createContext(c.req.raw, undefined); @@ -444,9 +445,9 @@ app.get( ), with: { owner: true, successor: true }, orderBy: [ - desc(ilike(accounts.handle, `@${query.q.replace(/^@/, "")}`)), + desc(ilike(accounts.handle, `@${normalizedQ}`)), desc(ilike(accounts.name, query.q)), - desc(ilike(accounts.handle, `@${query.q.replace(/^@/, "")}%`)), + desc(ilike(accounts.handle, `@${normalizedQ}%`)), desc(ilike(accounts.name, `${query.q}%`)), ], offset: query.offset, diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 1cda554c..ede8040d 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -53,6 +53,7 @@ import { withAccountOwner, type AccountOwnerVariables, } from "../../oauth/middleware"; +import { normalizeHandle } from "../../patterns"; import { fetchPreviewCard, type PreviewCard } from "../../previewcard"; import { accountOwners, @@ -1380,7 +1381,7 @@ async function addEmojiReaction( const fedCtx = federation.createContext(c.req.raw, undefined); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); - let emoji = c.req.param("emoji"); + let emoji = normalizeHandle(c.req.param("emoji")); const url = new URL(c.req.url); const emojiAt = emoji.lastIndexOf("@"); if (emojiAt >= 0 && isLocalHost(emoji.slice(emojiAt + 1), url)) { @@ -1529,7 +1530,7 @@ async function removeEmojiReaction( const fedCtx = federation.createContext(c.req.raw, undefined); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); - let emoji = c.req.param("emoji"); + let emoji = normalizeHandle(c.req.param("emoji")); const url = new URL(c.req.url); const emojiAt = emoji.lastIndexOf("@"); if (emojiAt >= 0 && isLocalHost(emoji.slice(emojiAt + 1), url)) { diff --git a/src/patterns.ts b/src/patterns.ts index 480cce3f..0ab30644 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -10,3 +10,15 @@ */ export const HANDLE_PATTERN = /^@?[\p{L}\p{N}._-]+@(?:[\p{L}\p{N}][\p{L}\p{N}_-]*\.)+[\p{L}\p{N}]{2,}$/u; + +/** + * Strip a single leading `@` from a handle-like value. + * + * Use this on values that came from user input before further parsing: many + * fediverse clients send handles in the user-typed `@user@domain` form, but + * Hollo's lookup paths build their own leading `@`, which would otherwise + * produce `@@user@domain` and miss every stored handle. + */ +export function normalizeHandle(handle: string): string { + return handle.replace(/^@/, ""); +} From c6be3f7cc43a8cff8b2baba5c8d30084bd92735a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 00:40:12 +0900 Subject: [PATCH 05/14] Validate WEB_ORIGIN URL syntax in env If WEB_ORIGIN is set to a string that isn't a parseable URL, for example the operator forgets the scheme and writes WEB_ORIGIN=example.com, Hollo currently fails with a generic TypeError thrown from new URL(WEB_ORIGIN) at the top of src/instance-host.ts when that module loads. That's a poor signal for a misconfiguration. Add a URL.canParse(rawWebOrigin) check after the "both set together" guard in src/env.ts, and throw a friendly "WEB_ORIGIN must be a valid URL (e.g. https://ap.example.com)." when the value doesn't parse. A short comment notes that this is a syntax check only; Fedify still enforces the stricter shape (http/https scheme, no path/query/fragment) when the origin is wired into createFederation. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242472066 Assisted-by: Claude Code:claude-opus-4-7 --- src/env.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/env.ts b/src/env.ts index 7b642461..9bef975c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -29,6 +29,18 @@ if (handleHostSet !== webOriginSet) { ); } +// Syntax check only; Fedify enforces the stricter shape (http/https scheme, +// no path/query/fragment) when the origin is wired into createFederation. +if ( + rawWebOrigin != null && + rawWebOrigin !== "" && + !URL.canParse(rawWebOrigin) +) { + throw new Error( + "WEB_ORIGIN must be a valid URL (e.g. https://ap.example.com).", + ); +} + export const HANDLE_HOST = handleHostSet ? rawHandleHost : undefined; export const WEB_ORIGIN = webOriginSet ? rawWebOrigin : undefined; export const FEDIFY_ORIGIN = From 16b2d6db9c8fb4574d9bcfd3352e26808bc6e740 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 00:56:48 +0900 Subject: [PATCH 06/14] Reuse handle canonicalization in /search PR #484 already canonicalized WEB_ORIGIN-host aliases on /api/v1/accounts/lookup, but the same logic wasn't applied to /api/v1/accounts/search. Searching for @alice@ap.example.com misses the local account stored as @alice@example.com and skips the resolve-side refresh path entirely. Extract normalizeHandleForLookup(handle, requestUrl) into src/instance-host.ts. It composes the existing helpers (normalizeHandle to strip a leading @, isLocalHost to recognize the request host / HANDLE_HOST / WEB_ORIGIN host as local, and getInstanceHost for the canonical handle host) into one canonicalizer that yields the @user@host form local accounts are stored under, or returns a remote handle with a leading @ otherwise. /lookup in src/api/v1/accounts.ts reduces to a single call to that helper. /search builds handleLookup once and threads it through both the resolve-side exact-match query and the orderBy clauses, so searching by WEB_ORIGIN-host alias prioritizes the matching local account. The remote-WebFinger fallback in /lookup keeps working: the dropped acct local becomes an inline normalizeHandle(query.acct) at the lookupObject call site. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242540524 https://github.com/fedify-dev/hollo/pull/484#discussion_r3242552501 https://github.com/fedify-dev/hollo/pull/484#discussion_r3242552516 https://github.com/fedify-dev/hollo/pull/484#discussion_r3242552525 https://github.com/fedify-dev/hollo/pull/484#discussion_r3242552542 https://github.com/fedify-dev/hollo/pull/484#discussion_r3242552547 Assisted-by: Claude Code:claude-opus-4-7 --- src/api/v1/accounts.ts | 32 +++++++++++++------------------- src/instance-host.ts | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts index 522aefb2..40cc9f7f 100644 --- a/src/api/v1/accounts.ts +++ b/src/api/v1/accounts.ts @@ -39,7 +39,7 @@ import { REMOTE_ACTOR_FETCH_POSTS, unfollowAccount, } from "../../federation/account"; -import { getInstanceHost, isLocalHost } from "../../instance-host"; +import { normalizeHandleForLookup } from "../../instance-host"; import { scopeRequired, tokenRequired, @@ -336,19 +336,10 @@ app.get( ), async (c) => { const query = c.req.valid("query"); - const acct = normalizeHandle(query.acct); - const requestUrl = new URL(c.req.url); - const at = acct.lastIndexOf("@"); - let handleLookup: string; - if (at < 0) { - handleLookup = `@${acct}@${getInstanceHost(requestUrl)}`; - } else if (isLocalHost(acct.slice(at + 1), requestUrl)) { - // Normalize WEB_ORIGIN-host aliases to the canonical HANDLE_HOST form - // that local accounts are stored under. - handleLookup = `@${acct.slice(0, at)}@${getInstanceHost(requestUrl)}`; - } else { - handleLookup = `@${acct}`; - } + const handleLookup = normalizeHandleForLookup( + query.acct, + new URL(c.req.url), + ); let account: | (Account & { owner: AccountOwner | null; @@ -365,7 +356,7 @@ app.get( } const fedCtx = federation.createContext(c.req.raw, undefined); const options = fedCtx; - const actor = await lookupObject(acct, options); + const actor = await lookupObject(normalizeHandle(query.acct), options); if (!isActor(actor)) return c.json({ error: "Record not found" }, 404); const loaded = await persistAccount(db, actor, c.req.url, options); if (loaded != null) { @@ -421,10 +412,13 @@ app.get( ), async (c) => { const query = c.req.valid("query"); - const normalizedQ = normalizeHandle(query.q); + const requestUrl = new URL(c.req.url); + const handleLookup = HANDLE_PATTERN.test(query.q) + ? normalizeHandleForLookup(query.q, requestUrl) + : `@${normalizeHandle(query.q)}`; if (query.resolve && HANDLE_PATTERN.test(query.q) && query.offset < 1) { const exactMatch = await db.query.accounts.findFirst({ - where: ilike(accounts.handle, `@${normalizedQ}`), + where: ilike(accounts.handle, handleLookup), }); if (exactMatch != null) { const fedCtx = federation.createContext(c.req.raw, undefined); @@ -445,9 +439,9 @@ app.get( ), with: { owner: true, successor: true }, orderBy: [ - desc(ilike(accounts.handle, `@${normalizedQ}`)), + desc(ilike(accounts.handle, handleLookup)), desc(ilike(accounts.name, query.q)), - desc(ilike(accounts.handle, `@${normalizedQ}%`)), + desc(ilike(accounts.handle, `${handleLookup}%`)), desc(ilike(accounts.name, `${query.q}%`)), ], offset: query.offset, diff --git a/src/instance-host.ts b/src/instance-host.ts index 232c1ba4..9a3a78ef 100644 --- a/src/instance-host.ts +++ b/src/instance-host.ts @@ -1,4 +1,5 @@ import { HANDLE_HOST, WEB_ORIGIN } from "./env"; +import { normalizeHandle } from "./patterns"; const WEB_ORIGIN_HOST = WEB_ORIGIN != null ? new URL(WEB_ORIGIN).host.toLowerCase() : undefined; @@ -15,3 +16,26 @@ export function isLocalHost(host: string, requestUrl: URL): boolean { if (WEB_ORIGIN_HOST != null && lower === WEB_ORIGIN_HOST) return true; return false; } + +/** + * Canonicalize a user-supplied handle-like string for an `accounts.handle` + * lookup. Strips a leading `@`, fills in the configured handle host for + * bare usernames, and rewrites local-host aliases (the request host, the + * configured `WEB_ORIGIN` host) to the canonical `HANDLE_HOST` form that + * local accounts are stored under. Remote handles are returned with a + * leading `@` but otherwise untouched. + */ +export function normalizeHandleForLookup( + handle: string, + requestUrl: URL, +): string { + const acct = normalizeHandle(handle); + const at = acct.lastIndexOf("@"); + if (at < 0) { + return `@${acct}@${getInstanceHost(requestUrl)}`; + } + if (isLocalHost(acct.slice(at + 1), requestUrl)) { + return `@${acct.slice(0, at)}@${getInstanceHost(requestUrl)}`; + } + return `@${acct}`; +} From d1fc888dc5af0a74fc863cd6a07007fa90474d5c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 01:07:30 +0900 Subject: [PATCH 07/14] Tighten WEB_ORIGIN validation in env The earlier WEB_ORIGIN check in src/env.ts only confirmed that URL.canParse accepted the string, so values like https://ap.example.com/path, ftp://ap.example.com, or https://ap.example.com?x=1 sailed past env.ts and only failed later from a generic TypeError inside src/instance-host.ts or Fedify's createFederation. Keep the URL.canParse check and add two more. The protocol must be http: or https:, otherwise throw "WEB_ORIGIN must use the http or https scheme". The parsed URL's pathname must be empty or /, its search must be empty, and its hash must be empty, otherwise throw "WEB_ORIGIN must be a bare origin (scheme and host only) with no path, query string, or fragment". Rewrite the leading comment to say this is syntax-only (no DNS resolution) and explain why we duplicate Fedify's own validation: catching it at the env-variable layer points the operator at the configuration mistake instead of a downstream TypeError. Smoke-tested with WEB_ORIGIN values "not a url", "ftp://...", "https://example.com/path", and "https://example.com?x=1"; each produces the matching message. The canonical https://ap.example.com still loads. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242625895 Assisted-by: Claude Code:claude-opus-4-7 --- src/env.ts | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/env.ts b/src/env.ts index 9bef975c..7537e3fa 100644 --- a/src/env.ts +++ b/src/env.ts @@ -29,16 +29,31 @@ if (handleHostSet !== webOriginSet) { ); } -// Syntax check only; Fedify enforces the stricter shape (http/https scheme, -// no path/query/fragment) when the origin is wired into createFederation. -if ( - rawWebOrigin != null && - rawWebOrigin !== "" && - !URL.canParse(rawWebOrigin) -) { - throw new Error( - "WEB_ORIGIN must be a valid URL (e.g. https://ap.example.com).", - ); +// Syntax-level checks only; we don't resolve DNS or contact the host. +// Fedify enforces the same shape when the origin is wired into +// createFederation, but checking up front gives the operator a clear +// error pointing at the env variable instead of a downstream TypeError. +if (rawWebOrigin != null && rawWebOrigin !== "") { + if (!URL.canParse(rawWebOrigin)) { + throw new Error( + "WEB_ORIGIN must be a valid URL (e.g. https://ap.example.com).", + ); + } + const webOriginUrl = new URL(rawWebOrigin); + if (webOriginUrl.protocol !== "http:" && webOriginUrl.protocol !== "https:") { + throw new Error( + "WEB_ORIGIN must use the http or https scheme (e.g. https://ap.example.com).", + ); + } + if ( + (webOriginUrl.pathname !== "/" && webOriginUrl.pathname !== "") || + webOriginUrl.search !== "" || + webOriginUrl.hash !== "" + ) { + throw new Error( + "WEB_ORIGIN must be a bare origin (scheme and host only) with no path, query string, or fragment.", + ); + } } export const HANDLE_HOST = handleHostSet ? rawHandleHost : undefined; From 6c029c5294826a85c3cae9068a80e3881d596c74 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 01:13:23 +0900 Subject: [PATCH 08/14] Match canonical handle in /search filter PR #484's earlier "Reuse handle canonicalization in /search" commit threaded handleLookup through the orderBy clauses but left the findMany where clause substring-matching with raw query.q. In split-domain mode, a search for q=@alice@ap.example.com (the WEB_ORIGIN-host alias form) doesn't substring-match the stored canonical @alice@example.com, so the local account is filtered out before ordering runs. The orderBy fix was therefore ineffective for the case it was meant to cover. Add a second ilike(accounts.handle, %${handleLookup}%) term to the OR clause alongside the existing %${query.q}% match. handleLookup is already canonicalized (alias rewritten to HANDLE_HOST, bare username gets the canonical host filled in), so searching by either form lets the local row through the filter. For non-handle queries (bare usernames), handleLookup is just @, so the added term is a slightly wider substring match that doesn't change existing behavior. https://github.com/fedify-dev/hollo/pull/484#pullrequestreview-4291446897 Assisted-by: Claude Code:claude-opus-4-7 --- src/api/v1/accounts.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts index 40cc9f7f..b1d58d26 100644 --- a/src/api/v1/accounts.ts +++ b/src/api/v1/accounts.ts @@ -435,6 +435,7 @@ app.get( const accountList = await db.query.accounts.findMany({ where: or( ilike(accounts.handle, `%${query.q}%`), + ilike(accounts.handle, `%${handleLookup}%`), ilike(accounts.name, `%${query.q}%`), ), with: { owner: true, successor: true }, From 252b4b9bf6f3f2bee62a17a8b1e099e12094bcfe Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 01:39:21 +0900 Subject: [PATCH 09/14] Normalize exported WEB_ORIGIN to its origin form Hostnames are case-insensitive, but if an operator writes WEB_ORIGIN=https://AP.Example.COM the env.ts module previously exported that string verbatim, forcing every downstream consumer to do its own .toLowerCase() if it wanted to compare against another host. After the canParse, scheme, and origin-shape checks pass, store webOriginUrl.origin in a local normalizedWebOrigin and export that as WEB_ORIGIN. URL.origin yields the canonical form (lowercased host, no trailing slash), so consumers can rely on a normalized value. A short comment names what URL.origin guarantees. Smoke-tested with WEB_ORIGIN=https://AP.Example.COM: the exported value comes out as https://ap.example.com. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242733074 Assisted-by: Claude Code:claude-opus-4-7 --- src/env.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/env.ts b/src/env.ts index 7537e3fa..febcf095 100644 --- a/src/env.ts +++ b/src/env.ts @@ -33,6 +33,7 @@ if (handleHostSet !== webOriginSet) { // Fedify enforces the same shape when the origin is wired into // createFederation, but checking up front gives the operator a clear // error pointing at the env variable instead of a downstream TypeError. +let normalizedWebOrigin: string | undefined; if (rawWebOrigin != null && rawWebOrigin !== "") { if (!URL.canParse(rawWebOrigin)) { throw new Error( @@ -54,10 +55,13 @@ if (rawWebOrigin != null && rawWebOrigin !== "") { "WEB_ORIGIN must be a bare origin (scheme and host only) with no path, query string, or fragment.", ); } + // URL.origin yields the canonical form (lowercased host, no trailing + // slash), so consumers can rely on a normalized value. + normalizedWebOrigin = webOriginUrl.origin; } export const HANDLE_HOST = handleHostSet ? rawHandleHost : undefined; -export const WEB_ORIGIN = webOriginSet ? rawWebOrigin : undefined; +export const WEB_ORIGIN = normalizedWebOrigin; export const FEDIFY_ORIGIN = HANDLE_HOST != null && WEB_ORIGIN != null ? { handleHost: HANDLE_HOST, webOrigin: WEB_ORIGIN } From 1cd1494c271014c2a500334b0f4340ae27cfc70b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 01:50:13 +0900 Subject: [PATCH 10/14] Validate HANDLE_HOST is a bare hostname The earlier env.ts validation only trimmed and lowercased HANDLE_HOST without checking its shape, so values like HANDLE_HOST=https://example.com or HANDLE_HOST=example.com:3000 slipped past env.ts and only failed downstream with a generic Fedify TypeError. HANDLE_HOST is the bare hostname used in fediverse handles (the part after the second @), so it must not carry a scheme, port, or path. After the "both set together" guard, reject the value if the trimmed and lowercased rawHandleHost contains either / or :. The error message is "HANDLE_HOST must be a bare hostname (e.g. example.com) with no scheme, port, or path." A short comment notes that this is a syntax-level check only (no DNS resolution) and explains why a scheme, port, or path makes no sense for a WebFinger handle host. The check is slightly stricter than Fedify's own (Fedify rejects slashes but tolerates ports), which is intentional: WebFinger handles never carry ports in practice, and rejecting them in env.ts catches a common footgun where operators conflate WEB_ORIGIN's port with HANDLE_HOST. Smoke-tested HANDLE_HOST=example.com:3000, https://example.com, and example.com/path: each produces the new friendly error; the canonical example.com still loads. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242870441 Assisted-by: Claude Code:claude-opus-4-7 --- src/env.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/env.ts b/src/env.ts index febcf095..bfb29331 100644 --- a/src/env.ts +++ b/src/env.ts @@ -29,6 +29,19 @@ if (handleHostSet !== webOriginSet) { ); } +// Syntax-level check only; we don't resolve DNS or contact the host. +// HANDLE_HOST is the bare hostname used in fediverse handles (the part +// after the second `@`), so it must not carry a scheme, port, or path. +if ( + rawHandleHost != null && + rawHandleHost !== "" && + (rawHandleHost.includes("/") || rawHandleHost.includes(":")) +) { + throw new Error( + "HANDLE_HOST must be a bare hostname (e.g. example.com) with no scheme, port, or path.", + ); +} + // Syntax-level checks only; we don't resolve DNS or contact the host. // Fedify enforces the same shape when the origin is wired into // createFederation, but checking up front gives the operator a clear From 77ed6499250aac26f7ddd98323f5114a80a35e86 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 02:04:54 +0900 Subject: [PATCH 11/14] Use hostname not host for local-handle comparison Both new URL(WEB_ORIGIN).host.toLowerCase() (the cached WEB_ORIGIN_HOST) and requestUrl.host.toLowerCase() (the inline comparison in isLocalHost) used the URL .host property, which includes a non-default port. The value those compare against comes from the host segment of a fediverse handle (the part after the second @), which never carries a port. So when WEB_ORIGIN is http://localhost:3000, WEB_ORIGIN_HOST was set to localhost:3000 and requestUrl.host was localhost:3000, and a handle like @alice@localhost wasn't recognized as local because localhost !== localhost:3000. Switch both .host accesses to .hostname, which strips the port, and add a short comment at each site naming why fediverse handles can't carry ports. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242936914 https://github.com/fedify-dev/hollo/pull/484#discussion_r3242936935 Assisted-by: Claude Code:claude-opus-4-7 --- src/instance-host.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/instance-host.ts b/src/instance-host.ts index 9a3a78ef..34f84913 100644 --- a/src/instance-host.ts +++ b/src/instance-host.ts @@ -1,8 +1,10 @@ import { HANDLE_HOST, WEB_ORIGIN } from "./env"; import { normalizeHandle } from "./patterns"; +// Use hostname (not host) so a non-default port on WEB_ORIGIN doesn't end +// up in the comparison value; fediverse handles never carry ports. const WEB_ORIGIN_HOST = - WEB_ORIGIN != null ? new URL(WEB_ORIGIN).host.toLowerCase() : undefined; + WEB_ORIGIN != null ? new URL(WEB_ORIGIN).hostname.toLowerCase() : undefined; export function getInstanceHost(fallback: URL | string): string { if (HANDLE_HOST != null) return HANDLE_HOST; @@ -11,7 +13,9 @@ export function getInstanceHost(fallback: URL | string): string { export function isLocalHost(host: string, requestUrl: URL): boolean { const lower = host.toLowerCase(); - if (lower === requestUrl.host.toLowerCase()) return true; + // Compare against hostname (not host) so a non-default port on the + // request URL doesn't make a local handle look remote. + if (lower === requestUrl.hostname.toLowerCase()) return true; if (HANDLE_HOST != null && lower === HANDLE_HOST) return true; if (WEB_ORIGIN_HOST != null && lower === WEB_ORIGIN_HOST) return true; return false; From a9cea99fa8c5d76b1857cb9fb703428663eaa6bb Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 02:06:20 +0900 Subject: [PATCH 12/14] Reject malformed hostnames in HANDLE_HOST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier HANDLE_HOST shape check rejected only / and :, which catches full URLs and host-with-port but lets other malformed values through: HANDLE_HOST="exa mple.com" (with whitespace) for example sneaks past env.ts and only fails downstream with a generic Fedify TypeError. After the slash/colon check, also run URL.canParse on the synthesized URL https://${rawHandleHost}/. Anything that URL.canParse can't accept as a hostname (whitespace, control characters, empty labels) throws "HANDLE_HOST must be a valid hostname (e.g. example.com)." This mirrors how Fedify itself validates handleHost downstream, but produces the friendlier error at the env-variable layer. The Unicode-aware URL parser leaves IDN domains alone. Smoke-tested: "exa mple.com" produces the new error, while "한국.kr", "café.fr", and "example.com" all load cleanly. https://github.com/fedify-dev/hollo/pull/484#discussion_r3242936953 Assisted-by: Claude Code:claude-opus-4-7 --- src/env.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/env.ts b/src/env.ts index bfb29331..bb06b19d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -32,14 +32,18 @@ if (handleHostSet !== webOriginSet) { // Syntax-level check only; we don't resolve DNS or contact the host. // HANDLE_HOST is the bare hostname used in fediverse handles (the part // after the second `@`), so it must not carry a scheme, port, or path. -if ( - rawHandleHost != null && - rawHandleHost !== "" && - (rawHandleHost.includes("/") || rawHandleHost.includes(":")) -) { - throw new Error( - "HANDLE_HOST must be a bare hostname (e.g. example.com) with no scheme, port, or path.", - ); +if (rawHandleHost != null && rawHandleHost !== "") { + if (rawHandleHost.includes("/") || rawHandleHost.includes(":")) { + throw new Error( + "HANDLE_HOST must be a bare hostname (e.g. example.com) with no scheme, port, or path.", + ); + } + // Use URL.canParse on a synthesized URL to catch other malformed + // hostnames (whitespace, control characters, empty labels, etc.). + // canParse is Unicode-aware, so IDN domains pass through. + if (!URL.canParse(`https://${rawHandleHost}/`)) { + throw new Error("HANDLE_HOST must be a valid hostname (e.g. example.com)."); + } } // Syntax-level checks only; we don't resolve DNS or contact the host. From 1ddd00208dc532c6c711d73ae55b791b8d5d90d2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 02:19:17 +0900 Subject: [PATCH 13/14] Accept both host and hostname in isLocalHost The previous commit (77ed649, "Use hostname not host for local-handle comparison") fixed Gemini's port-in-WEB_ORIGIN concern but introduced a regression for non-split-domain deployments. Without HANDLE_HOST/WEB_ORIGIN set, getInstanceHost falls back to requestUrl.host (including any non-default port), so stored handles for a local dev instance at localhost:3000 are written as @alice@localhost:3000. Stripping the port out of the comparison in isLocalHost then made those handles fail to match: the comparison input is localhost:3000 (the stored host segment) but the check value was localhost (the hostname). Emoji reactions sent as myemoji@localhost:3000 got misclassified as remote and returned 404 when no prior remote reaction existed. Keep the .hostname check (which still addresses the port-in- WEB_ORIGIN concern) and add the .host comparison back as a second clause. Both forms are accepted, so split-domain deployments (where stored handles use HANDLE_HOST without a port) and non-split-domain dev (where stored handles include the port from request.host) both work. The comment is rewritten to spell out why both forms are checked. https://github.com/fedify-dev/hollo/pull/484#discussion_r3243032404 Assisted-by: Claude Code:claude-opus-4-7 --- src/instance-host.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/instance-host.ts b/src/instance-host.ts index 34f84913..34e2ea67 100644 --- a/src/instance-host.ts +++ b/src/instance-host.ts @@ -13,8 +13,11 @@ export function getInstanceHost(fallback: URL | string): string { export function isLocalHost(host: string, requestUrl: URL): boolean { const lower = host.toLowerCase(); - // Compare against hostname (not host) so a non-default port on the - // request URL doesn't make a local handle look remote. + // Accept both request URL forms: .host (with port) covers + // non-split-domain deployments whose stored handles include the + // port (e.g. local dev at localhost:3000), and .hostname (no port) + // covers split-domain setups where HANDLE_HOST never carries one. + if (lower === requestUrl.host.toLowerCase()) return true; if (lower === requestUrl.hostname.toLowerCase()) return true; if (HANDLE_HOST != null && lower === HANDLE_HOST) return true; if (WEB_ORIGIN_HOST != null && lower === WEB_ORIGIN_HOST) return true; From 1dc2068ab038820ba3a29cc4bf34598c0a3cd544 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 02:35:46 +0900 Subject: [PATCH 14/14] Document the scope of isLocalHost Add a short block comment above isLocalHost spelling out what the function does and does not do: it's a string-equality check between hostnames, with no DNS resolution and no authority over the request itself. This mirrors the AGENTS.md convention of documenting the limits of security-adjacent checks so a future reader doesn't mistake it for a gatekeeper. No behavior change. https://github.com/fedify-dev/hollo/pull/484#discussion_r3243112215 Assisted-by: Claude Code:claude-opus-4-7 --- src/instance-host.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/instance-host.ts b/src/instance-host.ts index 34e2ea67..0577385f 100644 --- a/src/instance-host.ts +++ b/src/instance-host.ts @@ -11,6 +11,9 @@ export function getInstanceHost(fallback: URL | string): string { return typeof fallback === "string" ? fallback : fallback.host; } +// String-equality check between hostnames; no DNS resolution and no +// authority over the request itself. Used by lookup paths to decide +// whether a handle's host segment refers to this instance. export function isLocalHost(host: string, requestUrl: URL): boolean { const lower = host.toLowerCase(); // Accept both request URL forms: .host (with port) covers