Skip to content

Fixed actor documents mixing http and https URI schemes#1976

Open
sagzy wants to merge 6 commits into
mainfrom
activitypub-issue-1178-bc2b71
Open

Fixed actor documents mixing http and https URI schemes#1976
sagzy wants to merge 6 commits into
mainfrom
activitypub-issue-1178-bc2b71

Conversation

@sagzy

@sagzy sagzy commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

closes #1178

Problem

An actor document could be served with mismatched URI schemes:

{
  "id": "https://ghost.example/.ghost/activitypub/users/index",
  "publicKey": {
    "id": "http://ghost.example/.ghost/activitypub/users/index#main-key",
    "owner": "http://ghost.example/.ghost/activitypub/users/index",
    ...
  }
}

The actor id comes from the stored ap_id, which is always created with an https scheme (AccountService.createInternalAccount). The publicKey.id/owner URIs, however, are generated by Fedify from the incoming request URL. That URL is only https if the reverse proxy in front of the service sends X-Forwarded-Proto: https — when the header is missing (or arrives as http, e.g. Caddy behind another TLS-terminating proxy, as reported in the issue), the served document mixes http key URIs with an https actor id, and remote servers fail HTTP signature verification against the actor.

Solution

The service only ever serves https canonical URLs, so the request scheme shouldn't be trusted from the proxy at all. X-Forwarded-Proto is pinned to https at the serve boundary, before the existing behindProxy decorator applies it to the request URL — so every generated URI matches the stored https actor IDs regardless of proxy configuration.

Because the mismatch is environment-agnostic (stored actor IDs are https in every environment), the scheme is forced in every environment except the known plain-http ones (development, testing) — rather than only when NODE_ENV is exactly staging/production — so deployments with an unset or non-standard NODE_ENV are covered too. Local environments must keep the request scheme for things like the JWKS lookup in the role middleware; that classification now lives in a single shared isLocalEnvironment helper used by both the serve boundary and the role middleware, so the two can't drift apart.

The whole serve() fetch composition (accept-header forcing, https forcing, behindProxy) moved out of app.ts into a createServeFetch(environment, fetch) factory in src/http/serve-fetch.tsapp.ts boots the server at import time, so the env gate and decorator ordering were untestable there.

Verification

  • Unit tests cover createServeFetch through the real production composition: https forcing in staging/production and for unset/unrecognised NODE_ENV, scheme preservation (with X-Forwarded-Proto still honoured) in development/testing, X-Forwarded-Host handling, and the forced accept header.
  • Reproduced the issue scenario against the e2e stack: a plain-http request to /.ghost/activitypub/users/index with no X-Forwarded-Proto header now returns publicKey.id and owner with the https scheme, matching the actor id.
  • pnpm lint, pnpm test:types and the full unit suite (75 files, 957 tests) pass.

closes #1178

Canonical URLs are always https (internal account ap_ids are created
with a hardcoded https scheme), but Fedify derives generated URIs -
notably the actor's publicKey.id and owner - from the incoming request
URL. When a reverse proxy in front of the service did not set the
X-Forwarded-Proto header (or set it to http), the request URL stayed
http and the served actor document contained http key URIs alongside
the https actor id, breaking HTTP signature verification on remote
servers.

Since the service only ever serves https canonical URLs, the request
scheme is now pinned to https at the serve boundary by forcing the
X-Forwarded-Proto header before the behindProxy decorator applies it
to the request URL, rather than trusting the proxy to send it.
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0980a181-2449-451a-9f70-224e064dcbf7

📥 Commits

Reviewing files that changed from the base of the PR and between aa14992 and bc1f820.

📒 Files selected for processing (3)
  • src/app.ts
  • src/configuration/registrations.ts
  • src/lookup-helpers.ts

Walkthrough

The change adds shared environment classification for local HTTP behavior, updates JWKS URL protocol selection, and introduces an environment-aware fetch wrapper. The wrapper applies proxy URL handling, forces the ActivityPub Accept header, and enforces HTTPS outside development and testing. Server shutdown, Fedify configuration, and WebFinger lookup checks now use the shared classification, with unit tests covering the updated behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: johnonolan

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the core fix: preventing mixed http/https actor document URIs.
Description check ✅ Passed The description is clearly related to the URI-scheme mismatch fix and its server/proxy handling.
Linked Issues check ✅ Passed The changes address #1178 by forcing https for generated actor URIs in non-local environments and keeping local HTTP behavior intact.
Out of Scope Changes check ✅ Passed All code changes support the URI-scheme fix or shared environment handling; no unrelated work is evident.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch activitypub-issue-1178-bc2b71

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b57f815f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/http/force-https.ts Outdated
In local setups the service runs over plain http and the role
middleware's JWKS lookup follows the request scheme, so forcing
https there would break bearer-token authentication. Gate the
override on the same environments in which the role middleware
already requires https.
The forced https scheme was gated on NODE_ENV being exactly staging or
production, but the mismatch it fixes is environment-agnostic — stored
actor IDs are always https — so a deployment with NODE_ENV unset or
non-standard behind a proxy that omits X-Forwarded-Proto kept serving
mixed-scheme actor documents. The gate is now inverted: https is forced
everywhere except the known plain-http environments (development,
testing), which must keep the request scheme for things like the local
JWKS lookup.

The environment classification now lives in a single isLocalEnvironment
helper shared with the role middleware, so the two sites the previous
comment tied together by prose can no longer drift apart.

The serve() fetch composition also moved out of app.ts into a
createServeFetch factory: app.ts executes serve() at import time, so
the env gate and decorator ordering were untestable there — the factory
is unit tested through the real production composition.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/http/serve-fetch.ts (1)

24-51: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Preserve TypeScript return types for fetch wrappers.

Using unknown as the return type loses the Response | Promise<Response> signature expected by Hono's serve. Defining a strict type alias prevents future type mismatches and improves maintainability.

♻️ Proposed refactor to preserve types
+type FetchHandler = (req: Request) => Response | Promise<Response>;
+
-function forceHttps(fetch: (req: Request) => unknown) {
+function forceHttps(fetch: FetchHandler): FetchHandler {
     return (request: Request) => {
         request.headers.set('x-forwarded-proto', 'https');
         return fetch(request);
     };
 }
 
-function forceAcceptHeader(fetch: (req: Request) => unknown) {
+function forceAcceptHeader(fetch: FetchHandler): FetchHandler {
     return (request: Request) => {
         request.headers.set('accept', 'application/activity+json');
         return fetch(request);
     };
 }
 
 /**
  * Build the `fetch()` function passed to `serve()`: applies `X-Forwarded-*`
  * headers to the request URL and forces the accept header, and — outside
  * local environments, which serve plain http — forces the https scheme so
  * generated URIs match the stored https canonical URLs regardless of proxy
  * configuration.
  *
  * `@param` environment `process.env.NODE_ENV`
  * `@param` fetch The app's `fetch()` function
  */
 export function createServeFetch(
     environment: string | undefined,
-    fetch: (request: Request) => Response | Promise<Response>,
-) {
+    fetch: FetchHandler,
+): FetchHandler {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http/serve-fetch.ts` around lines 24 - 51, Update the fetch parameter and
return types in forceHttps and forceAcceptHeader to preserve the Response |
Promise<Response> contract used by createServeFetch and Hono’s serve; replace
the unknown wrapper return typing with a shared strict fetch-handler type, and
apply it consistently to both wrappers without changing their header behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/http/middleware/role-guard.ts`:
- Around line 26-28: Update the host extraction in the role middleware around
the JWKS URL construction to derive the host from the proxy-resolved request URL
via the request object, rather than reading the raw Host header. Use the
existing ctx.req/request flow so tenant context and JWKS validation share the
same resolved host.

---

Nitpick comments:
In `@src/http/serve-fetch.ts`:
- Around line 24-51: Update the fetch parameter and return types in forceHttps
and forceAcceptHeader to preserve the Response | Promise<Response> contract used
by createServeFetch and Hono’s serve; replace the unknown wrapper return typing
with a shared strict fetch-handler type, and apply it consistently to both
wrappers without changing their header behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fbb62cdf-9785-48c3-a829-c3284fe078ad

📥 Commits

Reviewing files that changed from the base of the PR and between e9cecb5 and 22515dc.

📒 Files selected for processing (6)
  • src/app.ts
  • src/helpers/environment.ts
  • src/helpers/environment.unit.test.ts
  • src/http/middleware/role-guard.ts
  • src/http/serve-fetch.ts
  • src/http/serve-fetch.unit.test.ts

Comment thread src/http/middleware/role-guard.ts
A shared FetchHandler alias preserves the Response contract through
the decorator composition instead of widening to unknown.
@sagzy
sagzy requested a review from mike182uk July 16, 2026 13:31
Comment thread src/http/serve-fetch.ts Outdated
@mike182uk

Copy link
Copy Markdown
Member

@sagzy So this means X-Forwarded-Proto is no longer needed from the proxy right?

The factory builds the fetch handler passed to serve(); naming it
after what it produces reads better than naming it after the call
site. The module moves to fetch-handler.ts to match.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/http/fetch-handler.unit.test.ts (1)

5-20: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Test environment Request differs from production Request regarding header mutability.

Because this test manually instantiates new Request(...), the headers object has a none guard according to the Fetch API spec, allowing request.headers.set(...) to succeed. However, an incoming Request provided by the server in production environments will have an immutable guard, which will cause request.headers.set(...) in forceHttps and forceAcceptHeader to throw a TypeError.

You should consider updating the implementation to create a new Request instance (or a clone) to ensure it does not crash on immutable incoming requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http/fetch-handler.unit.test.ts` around lines 5 - 20, Update the request
handling used by forceHttps and forceAcceptHeader to avoid mutating the incoming
Request headers directly; construct a new Request or clone the request with the
required header changes, while preserving the existing request data and behavior
for mutable test Requests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/http/fetch-handler.ts`:
- Around line 26-31: Clone the incoming Request before modifying headers in
forceHttps and forceAcceptHeader. For both sites in src/http/fetch-handler.ts
(lines 26-31 and 33-38), create a new request with cloned Headers, update the
clone, and pass it to fetch instead of mutating the original request.

---

Outside diff comments:
In `@src/http/fetch-handler.unit.test.ts`:
- Around line 5-20: Update the request handling used by forceHttps and
forceAcceptHeader to avoid mutating the incoming Request headers directly;
construct a new Request or clone the request with the required header changes,
while preserving the existing request data and behavior for mutable test
Requests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cf7c4199-5895-48f4-a072-e921b7b83270

📥 Commits

Reviewing files that changed from the base of the PR and between 22515dc and aa14992.

📒 Files selected for processing (4)
  • src/app.ts
  • src/helpers/environment.ts
  • src/http/fetch-handler.ts
  • src/http/fetch-handler.unit.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/helpers/environment.ts

Comment thread src/http/fetch-handler.ts
@sagzy

sagzy commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@sagzy So this means X-Forwarded-Proto is no longer needed from the proxy right?

Correct, with the fix, self-hosters don't need to send the X-Forwarded-Proto header anymore. Caddy already sets the header automatically, so the default setup with https://github.com/tryghost/ghost-docker was already working without additional configuration. This fixes self-hosted setups with e.g. an additional outer proxy that forwards http to Caddy (example in #1178)

The isLocalEnvironment helper was introduced to give the serve boundary
and the role middleware a single definition of "local", so the two
couldn't drift. But five other sites still open-coded the same
['development', 'testing'] list — the SIGINT/SIGTERM fast-exit, the
Fedify skipSignatureVerification and allowPrivateAddress guards, and the
WebFinger allowPrivateAddress guard. Left as-is, adding an environment to
the helper's list would change the request scheme and JWKS behavior while
silently leaving signature verification and private-address allowance on
the old definition — reproducing exactly the scheme/verification mismatch
this work set out to prevent.

Each replacement is behaviour-identical, so this is a pure refactor that
makes the helper the sole source of the classification.
@sagzy
sagzy deployed to staging July 21, 2026 09:38 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Actor ID and public key ID have different URI schemes

2 participants