From 8b57f815f4f0edc34eb628eb783d812ef9b16ed7 Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 8 Jul 2026 08:59:09 +0200 Subject: [PATCH 1/6] Fixed actor documents mixing http and https URI schemes closes https://github.com/TryGhost/ActivityPub/issues/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. --- src/app.ts | 3 +- src/http/force-https.ts | 23 ++++++++++++++ src/http/force-https.unit.test.ts | 52 +++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/http/force-https.ts create mode 100644 src/http/force-https.unit.test.ts diff --git a/src/app.ts b/src/app.ts index abd39fec..05cf3147 100644 --- a/src/app.ts +++ b/src/app.ts @@ -123,6 +123,7 @@ import type { SiteController } from '@/http/api/site.controller'; import { TopicController } from '@/http/api/topic.controller'; import type { WebFingerController } from '@/http/api/webfinger.controller'; import type { WebhookController } from '@/http/api/webhook.controller'; +import { forceHttps } from '@/http/force-https'; import type { HostDataContextLoader } from '@/http/host-data-context-loader'; import { createDeploymentHeadersMiddleware } from '@/http/middleware/deployment-headers'; import { createHostDataContextMiddleware } from '@/http/middleware/host-data-context'; @@ -1046,7 +1047,7 @@ function forceAcceptHeader(fn: (req: Request) => unknown) { serve( { - fetch: forceAcceptHeader(behindProxy(app.fetch)), + fetch: forceAcceptHeader(forceHttps(behindProxy(app.fetch))), port: Number.parseInt(process.env.PORT || '8080', 10), }, (info) => { diff --git a/src/http/force-https.ts b/src/http/force-https.ts new file mode 100644 index 00000000..cace9018 --- /dev/null +++ b/src/http/force-https.ts @@ -0,0 +1,23 @@ +/** + * Decorate a `fetch()` function so that the request is always treated as + * having been made over https. + * + * Canonical URLs are always https (see `AccountEntity.draft` / + * `AccountService.createInternalAccount`), but Fedify derives generated URIs + * (e.g. the actor's `publicKey.id`) from the incoming request URL. A reverse + * proxy that omits the `X-Forwarded-Proto` header would leave the request URL + * as http, producing http URIs that contradict the stored https actor IDs and + * breaking HTTP signature verification on remote servers. + * + * This must wrap a `fetch()` function already decorated with `behindProxy` + * (i.e. run before it), as `behindProxy` is what applies the header to the + * request URL. + * + * @param fetch A `fetch()` function to be decorated + */ +export function forceHttps(fetch: (req: Request) => unknown) { + return (request: Request) => { + request.headers.set('x-forwarded-proto', 'https'); + return fetch(request); + }; +} diff --git a/src/http/force-https.unit.test.ts b/src/http/force-https.unit.test.ts new file mode 100644 index 00000000..be834fc7 --- /dev/null +++ b/src/http/force-https.unit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { behindProxy } from 'x-forwarded-fetch'; + +import { forceHttps } from './force-https'; + +describe('forceHttps', () => { + it('should set the x-forwarded-proto header to https when it is missing', async () => { + let receivedRequest: Request | undefined; + + const fetch = forceHttps((request: Request) => { + receivedRequest = request; + }); + + await fetch(new Request('http://example.com/foo')); + + expect(receivedRequest?.headers.get('x-forwarded-proto')).toBe('https'); + }); + + it('should override an x-forwarded-proto header of http', async () => { + let receivedRequest: Request | undefined; + + const fetch = forceHttps((request: Request) => { + receivedRequest = request; + }); + + await fetch( + new Request('http://example.com/foo', { + headers: { + 'x-forwarded-proto': 'http', + }, + }), + ); + + expect(receivedRequest?.headers.get('x-forwarded-proto')).toBe('https'); + }); + + it('should result in an https request URL when composed with behindProxy', async () => { + let receivedRequest: Request | undefined; + + const fetch = forceHttps( + behindProxy((request: Request) => { + receivedRequest = request; + return new Response(); + }), + ); + + await fetch(new Request('http://example.com/foo')); + + expect(receivedRequest?.url).toBe('https://example.com/foo'); + }); +}); From e9cecb55a31bd3e01ba289e951f7a6d3a4a6f692 Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 8 Jul 2026 11:52:06 +0200 Subject: [PATCH 2/6] Limited forced https scheme to staging and production 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. --- src/app.ts | 8 +++++++- src/http/force-https.ts | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 05cf3147..b6035640 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1045,9 +1045,15 @@ function forceAcceptHeader(fn: (req: Request) => unknown) { }; } +// Same environments in which the role middleware requires https for the JWKS +// lookup — local setups serve plain http and must keep the request scheme +const appFetch = ['staging', 'production'].includes(process.env.NODE_ENV || '') + ? forceHttps(behindProxy(app.fetch)) + : behindProxy(app.fetch); + serve( { - fetch: forceAcceptHeader(forceHttps(behindProxy(app.fetch))), + fetch: forceAcceptHeader(appFetch), port: Number.parseInt(process.env.PORT || '8080', 10), }, (info) => { diff --git a/src/http/force-https.ts b/src/http/force-https.ts index cace9018..2c8f7781 100644 --- a/src/http/force-https.ts +++ b/src/http/force-https.ts @@ -13,6 +13,11 @@ * (i.e. run before it), as `behindProxy` is what applies the header to the * request URL. * + * Only apply this in environments where all traffic is served over https: + * local setups run over plain http, and code that follows the request scheme + * (e.g. the JWKS lookup in the role middleware) relies on it staying http + * there. + * * @param fetch A `fetch()` function to be decorated */ export function forceHttps(fetch: (req: Request) => unknown) { From 22515dc495a47071057c8cf778e34fe64db86204 Mon Sep 17 00:00:00 2001 From: Sag Date: Thu, 16 Jul 2026 14:42:19 +0200 Subject: [PATCH 3/6] Forced the https scheme in every non-local environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app.ts | 18 +---- src/helpers/environment.ts | 17 ++++ src/helpers/environment.unit.test.ts | 21 +++++ src/http/force-https.ts | 28 ------- src/http/force-https.unit.test.ts | 52 ------------- src/http/middleware/role-guard.ts | 6 +- src/http/serve-fetch.ts | 59 ++++++++++++++ src/http/serve-fetch.unit.test.ts | 111 +++++++++++++++++++++++++++ 8 files changed, 214 insertions(+), 98 deletions(-) create mode 100644 src/helpers/environment.ts create mode 100644 src/helpers/environment.unit.test.ts delete mode 100644 src/http/force-https.ts delete mode 100644 src/http/force-https.unit.test.ts create mode 100644 src/http/serve-fetch.ts create mode 100644 src/http/serve-fetch.unit.test.ts diff --git a/src/app.ts b/src/app.ts index b6035640..86018b88 100644 --- a/src/app.ts +++ b/src/app.ts @@ -43,7 +43,6 @@ import * as Sentry from '@sentry/node'; import { get } from 'es-toolkit/compat'; import { Hono, type Context as HonoContext, type Next } from 'hono'; import { cors } from 'hono/cors'; -import { behindProxy } from 'x-forwarded-fetch'; import type { Account } from '@/account/account.entity'; import { AccountBlockedEvent } from '@/account/events/account-blocked.event'; @@ -123,7 +122,6 @@ import type { SiteController } from '@/http/api/site.controller'; import { TopicController } from '@/http/api/topic.controller'; import type { WebFingerController } from '@/http/api/webfinger.controller'; import type { WebhookController } from '@/http/api/webhook.controller'; -import { forceHttps } from '@/http/force-https'; import type { HostDataContextLoader } from '@/http/host-data-context-loader'; import { createDeploymentHeadersMiddleware } from '@/http/middleware/deployment-headers'; import { createHostDataContextMiddleware } from '@/http/middleware/host-data-context'; @@ -133,6 +131,7 @@ import { requireRole, } from '@/http/middleware/role-guard'; import { RouteRegistry } from '@/http/routing/route-registry'; +import { createServeFetch } from '@/http/serve-fetch'; import { setupInstrumentation, spanWrapper } from '@/instrumentation'; import { createPushMessageHandler, @@ -1038,22 +1037,9 @@ app.onError((err, c) => { return c.text('Internal Server Error', 500); }); -function forceAcceptHeader(fn: (req: Request) => unknown) { - return (request: Request) => { - request.headers.set('accept', 'application/activity+json'); - return fn(request); - }; -} - -// Same environments in which the role middleware requires https for the JWKS -// lookup — local setups serve plain http and must keep the request scheme -const appFetch = ['staging', 'production'].includes(process.env.NODE_ENV || '') - ? forceHttps(behindProxy(app.fetch)) - : behindProxy(app.fetch); - serve( { - fetch: forceAcceptHeader(appFetch), + fetch: createServeFetch(process.env.NODE_ENV, app.fetch), port: Number.parseInt(process.env.PORT || '8080', 10), }, (info) => { diff --git a/src/helpers/environment.ts b/src/helpers/environment.ts new file mode 100644 index 00000000..2bcb684e --- /dev/null +++ b/src/helpers/environment.ts @@ -0,0 +1,17 @@ +/** + * Environments that run over plain http (local development and CI). Code that + * follows the request scheme (e.g. the JWKS lookup in the role middleware) + * relies on requests staying http in these environments, and the serve + * boundary must not force the scheme to https for them (see + * `createServeFetch`). + * + * Every other environment — including an unset or unrecognised `NODE_ENV` — + * is treated as being served over https, matching the canonical account URLs + * which are always created with an https scheme + * (`AccountService.createInternalAccount`). + */ +const LOCAL_ENVIRONMENTS = ['development', 'testing']; + +export function isLocalEnvironment(environment: string | undefined): boolean { + return LOCAL_ENVIRONMENTS.includes(environment || ''); +} diff --git a/src/helpers/environment.unit.test.ts b/src/helpers/environment.unit.test.ts new file mode 100644 index 00000000..a98d9dd0 --- /dev/null +++ b/src/helpers/environment.unit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { isLocalEnvironment } from './environment'; + +describe('isLocalEnvironment', () => { + it('should return true for local environments', () => { + expect(isLocalEnvironment('development')).toBe(true); + expect(isLocalEnvironment('testing')).toBe(true); + }); + + it('should return false for deployed environments', () => { + expect(isLocalEnvironment('staging')).toBe(false); + expect(isLocalEnvironment('production')).toBe(false); + }); + + it('should return false when the environment is unset or unrecognised', () => { + expect(isLocalEnvironment(undefined)).toBe(false); + expect(isLocalEnvironment('')).toBe(false); + expect(isLocalEnvironment('prod')).toBe(false); + }); +}); diff --git a/src/http/force-https.ts b/src/http/force-https.ts deleted file mode 100644 index 2c8f7781..00000000 --- a/src/http/force-https.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Decorate a `fetch()` function so that the request is always treated as - * having been made over https. - * - * Canonical URLs are always https (see `AccountEntity.draft` / - * `AccountService.createInternalAccount`), but Fedify derives generated URIs - * (e.g. the actor's `publicKey.id`) from the incoming request URL. A reverse - * proxy that omits the `X-Forwarded-Proto` header would leave the request URL - * as http, producing http URIs that contradict the stored https actor IDs and - * breaking HTTP signature verification on remote servers. - * - * This must wrap a `fetch()` function already decorated with `behindProxy` - * (i.e. run before it), as `behindProxy` is what applies the header to the - * request URL. - * - * Only apply this in environments where all traffic is served over https: - * local setups run over plain http, and code that follows the request scheme - * (e.g. the JWKS lookup in the role middleware) relies on it staying http - * there. - * - * @param fetch A `fetch()` function to be decorated - */ -export function forceHttps(fetch: (req: Request) => unknown) { - return (request: Request) => { - request.headers.set('x-forwarded-proto', 'https'); - return fetch(request); - }; -} diff --git a/src/http/force-https.unit.test.ts b/src/http/force-https.unit.test.ts deleted file mode 100644 index be834fc7..00000000 --- a/src/http/force-https.unit.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { behindProxy } from 'x-forwarded-fetch'; - -import { forceHttps } from './force-https'; - -describe('forceHttps', () => { - it('should set the x-forwarded-proto header to https when it is missing', async () => { - let receivedRequest: Request | undefined; - - const fetch = forceHttps((request: Request) => { - receivedRequest = request; - }); - - await fetch(new Request('http://example.com/foo')); - - expect(receivedRequest?.headers.get('x-forwarded-proto')).toBe('https'); - }); - - it('should override an x-forwarded-proto header of http', async () => { - let receivedRequest: Request | undefined; - - const fetch = forceHttps((request: Request) => { - receivedRequest = request; - }); - - await fetch( - new Request('http://example.com/foo', { - headers: { - 'x-forwarded-proto': 'http', - }, - }), - ); - - expect(receivedRequest?.headers.get('x-forwarded-proto')).toBe('https'); - }); - - it('should result in an https request URL when composed with behindProxy', async () => { - let receivedRequest: Request | undefined; - - const fetch = forceHttps( - behindProxy((request: Request) => { - receivedRequest = request; - return new Response(); - }), - ); - - await fetch(new Request('http://example.com/foo')); - - expect(receivedRequest?.url).toBe('https://example.com/foo'); - }); -}); diff --git a/src/http/middleware/role-guard.ts b/src/http/middleware/role-guard.ts index c338ce40..8037651b 100644 --- a/src/http/middleware/role-guard.ts +++ b/src/http/middleware/role-guard.ts @@ -4,6 +4,8 @@ import type { Context as HonoContext, Next } from 'hono'; import jwt from 'jsonwebtoken'; import jose from 'node-jose'; +import { isLocalEnvironment } from '@/helpers/environment'; + export enum GhostRole { Anonymous = 'Anonymous', Owner = 'Owner', @@ -21,9 +23,9 @@ function getJwksURL(host: string, ctx: HonoContext) { const GHOST_JWKS_ENDPOINT = '/ghost/.well-known/jwks.json'; let protocol = 'https'; - // We allow insecure requests when not in production for things like testing + // We allow insecure requests in local environments for things like testing if ( - !['staging', 'production'].includes(process.env.NODE_ENV || '') && + isLocalEnvironment(process.env.NODE_ENV) && !ctx.req.raw.url.startsWith('https') ) { protocol = 'http'; diff --git a/src/http/serve-fetch.ts b/src/http/serve-fetch.ts new file mode 100644 index 00000000..e75b8384 --- /dev/null +++ b/src/http/serve-fetch.ts @@ -0,0 +1,59 @@ +import { behindProxy } from 'x-forwarded-fetch'; + +import { isLocalEnvironment } from '@/helpers/environment'; + +/** + * Decorate a `fetch()` function so that the request is always treated as + * having been made over https. + * + * Canonical URLs are always https (see `AccountEntity.draft` / + * `AccountService.createInternalAccount`), but Fedify derives generated URIs + * (e.g. the actor's `publicKey.id`) from the incoming request URL. A reverse + * proxy that omits the `X-Forwarded-Proto` header would leave the request URL + * as http, producing http URIs that contradict the stored https actor IDs and + * breaking HTTP signature verification on remote servers. + * + * Fedify's `origin` option on `createFederation` would be the first-class way + * to pin generated URIs, but it takes a single static origin — unusable here, + * where the host varies per tenant. + * + * This must wrap a `fetch()` function already decorated with `behindProxy` + * (i.e. run before it), as `behindProxy` is what applies the header to the + * request URL — `createServeFetch` owns that composition. + */ +function forceHttps(fetch: (req: Request) => unknown) { + return (request: Request) => { + request.headers.set('x-forwarded-proto', 'https'); + return fetch(request); + }; +} + +function forceAcceptHeader(fetch: (req: Request) => unknown) { + 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, +) { + const proxiedFetch = behindProxy(fetch); + + return forceAcceptHeader( + isLocalEnvironment(environment) + ? proxiedFetch + : forceHttps(proxiedFetch), + ); +} diff --git a/src/http/serve-fetch.unit.test.ts b/src/http/serve-fetch.unit.test.ts new file mode 100644 index 00000000..0b67b0e1 --- /dev/null +++ b/src/http/serve-fetch.unit.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import { createServeFetch } from './serve-fetch'; + +async function dispatch(environment: string | undefined, request: Request) { + let receivedRequest: Request | undefined; + + const fetch = createServeFetch(environment, (request: Request) => { + receivedRequest = request; + return new Response(); + }); + + await fetch(request); + + if (!receivedRequest) { + throw new Error('Expected the wrapped fetch to be called'); + } + + return receivedRequest; +} + +describe('createServeFetch', () => { + for (const environment of ['staging', 'production']) { + it(`should force an https request URL in ${environment} when x-forwarded-proto is missing`, async () => { + const request = await dispatch( + environment, + new Request('http://example.com/foo'), + ); + + expect(request.url).toBe('https://example.com/foo'); + }); + + it(`should force an https request URL in ${environment} when x-forwarded-proto is http`, async () => { + const request = await dispatch( + environment, + new Request('http://example.com/foo', { + headers: { + 'x-forwarded-proto': 'http', + }, + }), + ); + + expect(request.url).toBe('https://example.com/foo'); + }); + } + + it('should force an https request URL when NODE_ENV is unset or unrecognised', async () => { + for (const environment of [undefined, '', 'prod']) { + const request = await dispatch( + environment, + new Request('http://example.com/foo'), + ); + + expect(request.url).toBe('https://example.com/foo'); + } + }); + + for (const environment of ['development', 'testing']) { + it(`should keep an http request URL in ${environment}`, async () => { + const request = await dispatch( + environment, + new Request('http://example.com/foo'), + ); + + expect(request.url).toBe('http://example.com/foo'); + }); + + it(`should still honour x-forwarded-proto in ${environment}`, async () => { + const request = await dispatch( + environment, + new Request('http://example.com/foo', { + headers: { + 'x-forwarded-proto': 'https', + }, + }), + ); + + expect(request.url).toBe('https://example.com/foo'); + }); + } + + it('should apply x-forwarded-host to the request URL', async () => { + const request = await dispatch( + 'production', + new Request('http://internal.host/foo', { + headers: { + 'x-forwarded-host': 'example.com', + }, + }), + ); + + expect(request.url).toBe('https://example.com/foo'); + }); + + it('should force the accept header in every environment', async () => { + for (const environment of ['development', 'production']) { + const request = await dispatch( + environment, + new Request('http://example.com/foo', { + headers: { + accept: 'text/html', + }, + }), + ); + + expect(request.headers.get('accept')).toBe( + 'application/activity+json', + ); + } + }); +}); From 7211f3a2e44a25df4d45f7f35ae23d6966853b80 Mon Sep 17 00:00:00 2001 From: Sag Date: Thu, 16 Jul 2026 14:50:34 +0200 Subject: [PATCH 4/6] Tightened fetch decorator types in serve-fetch A shared FetchHandler alias preserves the Response contract through the decorator composition instead of widening to unknown. --- src/http/serve-fetch.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/http/serve-fetch.ts b/src/http/serve-fetch.ts index e75b8384..98b1aff5 100644 --- a/src/http/serve-fetch.ts +++ b/src/http/serve-fetch.ts @@ -2,6 +2,8 @@ import { behindProxy } from 'x-forwarded-fetch'; import { isLocalEnvironment } from '@/helpers/environment'; +type FetchHandler = (request: Request) => Response | Promise; + /** * Decorate a `fetch()` function so that the request is always treated as * having been made over https. @@ -21,14 +23,14 @@ import { isLocalEnvironment } from '@/helpers/environment'; * (i.e. run before it), as `behindProxy` is what applies the header to the * request URL — `createServeFetch` owns that composition. */ -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); @@ -47,8 +49,8 @@ function forceAcceptHeader(fetch: (req: Request) => unknown) { */ export function createServeFetch( environment: string | undefined, - fetch: (request: Request) => Response | Promise, -) { + fetch: FetchHandler, +): FetchHandler { const proxiedFetch = behindProxy(fetch); return forceAcceptHeader( From aa1499203b7791f931b05a24b6771518cdd02f79 Mon Sep 17 00:00:00 2001 From: Sag Date: Mon, 20 Jul 2026 10:03:39 +0200 Subject: [PATCH 5/6] Renamed createServeFetch to createFetchHandler 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. --- src/app.ts | 4 ++-- src/helpers/environment.ts | 2 +- src/http/{serve-fetch.ts => fetch-handler.ts} | 4 ++-- ...{serve-fetch.unit.test.ts => fetch-handler.unit.test.ts} | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/http/{serve-fetch.ts => fetch-handler.ts} (95%) rename src/http/{serve-fetch.unit.test.ts => fetch-handler.unit.test.ts} (95%) diff --git a/src/app.ts b/src/app.ts index 86018b88..75ea8aa1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -122,6 +122,7 @@ import type { SiteController } from '@/http/api/site.controller'; import { TopicController } from '@/http/api/topic.controller'; import type { WebFingerController } from '@/http/api/webfinger.controller'; import type { WebhookController } from '@/http/api/webhook.controller'; +import { createFetchHandler } from '@/http/fetch-handler'; import type { HostDataContextLoader } from '@/http/host-data-context-loader'; import { createDeploymentHeadersMiddleware } from '@/http/middleware/deployment-headers'; import { createHostDataContextMiddleware } from '@/http/middleware/host-data-context'; @@ -131,7 +132,6 @@ import { requireRole, } from '@/http/middleware/role-guard'; import { RouteRegistry } from '@/http/routing/route-registry'; -import { createServeFetch } from '@/http/serve-fetch'; import { setupInstrumentation, spanWrapper } from '@/instrumentation'; import { createPushMessageHandler, @@ -1039,7 +1039,7 @@ app.onError((err, c) => { serve( { - fetch: createServeFetch(process.env.NODE_ENV, app.fetch), + fetch: createFetchHandler(process.env.NODE_ENV, app.fetch), port: Number.parseInt(process.env.PORT || '8080', 10), }, (info) => { diff --git a/src/helpers/environment.ts b/src/helpers/environment.ts index 2bcb684e..f7ae9062 100644 --- a/src/helpers/environment.ts +++ b/src/helpers/environment.ts @@ -3,7 +3,7 @@ * follows the request scheme (e.g. the JWKS lookup in the role middleware) * relies on requests staying http in these environments, and the serve * boundary must not force the scheme to https for them (see - * `createServeFetch`). + * `createFetchHandler`). * * Every other environment — including an unset or unrecognised `NODE_ENV` — * is treated as being served over https, matching the canonical account URLs diff --git a/src/http/serve-fetch.ts b/src/http/fetch-handler.ts similarity index 95% rename from src/http/serve-fetch.ts rename to src/http/fetch-handler.ts index 98b1aff5..3820f13e 100644 --- a/src/http/serve-fetch.ts +++ b/src/http/fetch-handler.ts @@ -21,7 +21,7 @@ type FetchHandler = (request: Request) => Response | Promise; * * This must wrap a `fetch()` function already decorated with `behindProxy` * (i.e. run before it), as `behindProxy` is what applies the header to the - * request URL — `createServeFetch` owns that composition. + * request URL — `createFetchHandler` owns that composition. */ function forceHttps(fetch: FetchHandler): FetchHandler { return (request: Request) => { @@ -47,7 +47,7 @@ function forceAcceptHeader(fetch: FetchHandler): FetchHandler { * @param environment `process.env.NODE_ENV` * @param fetch The app's `fetch()` function */ -export function createServeFetch( +export function createFetchHandler( environment: string | undefined, fetch: FetchHandler, ): FetchHandler { diff --git a/src/http/serve-fetch.unit.test.ts b/src/http/fetch-handler.unit.test.ts similarity index 95% rename from src/http/serve-fetch.unit.test.ts rename to src/http/fetch-handler.unit.test.ts index 0b67b0e1..78e4914f 100644 --- a/src/http/serve-fetch.unit.test.ts +++ b/src/http/fetch-handler.unit.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { createServeFetch } from './serve-fetch'; +import { createFetchHandler } from './fetch-handler'; async function dispatch(environment: string | undefined, request: Request) { let receivedRequest: Request | undefined; - const fetch = createServeFetch(environment, (request: Request) => { + const fetch = createFetchHandler(environment, (request: Request) => { receivedRequest = request; return new Response(); }); @@ -19,7 +19,7 @@ async function dispatch(environment: string | undefined, request: Request) { return receivedRequest; } -describe('createServeFetch', () => { +describe('createFetchHandler', () => { for (const environment of ['staging', 'production']) { it(`should force an https request URL in ${environment} when x-forwarded-proto is missing`, async () => { const request = await dispatch( From bc1f820f5ae5bf922d742e5117b0cf260d0bdb52 Mon Sep 17 00:00:00 2001 From: Sag Date: Tue, 21 Jul 2026 11:27:52 +0200 Subject: [PATCH 6/6] Routed remaining local-environment checks through isLocalEnvironment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app.ts | 5 +++-- src/configuration/registrations.ts | 9 +++------ src/lookup-helpers.ts | 3 ++- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/app.ts b/src/app.ts index 75ea8aa1..fa1a7cd9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -102,6 +102,7 @@ import type { FeedUpdateService } from '@/feed/feed-update.service'; import type { FlagService } from '@/flag/flag.service'; import type { GhostPostService } from '@/ghost/ghost-post.service'; import { getTraceContext } from '@/helpers/context-header'; +import { isLocalEnvironment } from '@/helpers/environment'; import { AccountController } from '@/http/api/account.controller'; import { BlockController } from '@/http/api/block.controller'; import { BlueskyController } from '@/http/api/bluesky.controller'; @@ -1077,13 +1078,13 @@ async function gracefulShutdown(signal: 'SIGINT' | 'SIGTERM') { } process.on('SIGINT', () => { - if (['development', 'testing'].includes(process.env.NODE_ENV || '')) { + if (isLocalEnvironment(process.env.NODE_ENV)) { process.exit(0); } void gracefulShutdown('SIGINT'); }); process.on('SIGTERM', () => { - if (['development', 'testing'].includes(process.env.NODE_ENV || '')) { + if (isLocalEnvironment(process.env.NODE_ENV)) { process.exit(0); } void gracefulShutdown('SIGTERM'); diff --git a/src/configuration/registrations.ts b/src/configuration/registrations.ts index 8db234c7..c53b1ed3 100644 --- a/src/configuration/registrations.ts +++ b/src/configuration/registrations.ts @@ -48,6 +48,7 @@ import { FeedService } from '@/feed/feed.service'; import { FeedUpdateService } from '@/feed/feed-update.service'; import { FlagService } from '@/flag/flag.service'; import { GhostPostService } from '@/ghost/ghost-post.service'; +import { isLocalEnvironment } from '@/helpers/environment'; import { getSiteSettings } from '@/helpers/ghost'; import { AccountController } from '@/http/api/account.controller'; import { BlockController } from '@/http/api/block.controller'; @@ -256,14 +257,10 @@ export function registerDependencies( circuitBreaker: false, skipSignatureVerification: process.env.SKIP_SIGNATURE_VERIFICATION === 'true' && - ['development', 'testing'].includes( - process.env.NODE_ENV || '', - ), + isLocalEnvironment(process.env.NODE_ENV), allowPrivateAddress: process.env.ALLOW_PRIVATE_ADDRESS === 'true' && - ['development', 'testing'].includes( - process.env.NODE_ENV || '', - ), + isLocalEnvironment(process.env.NODE_ENV), firstKnock: 'draft-cavage-http-signatures-12', }); }).singleton(), diff --git a/src/lookup-helpers.ts b/src/lookup-helpers.ts index 582e4260..8a776a0e 100644 --- a/src/lookup-helpers.ts +++ b/src/lookup-helpers.ts @@ -10,6 +10,7 @@ import { lookupWebFinger } from '@fedify/webfinger'; import type { FedifyContext } from '@/app'; import { error, ok, type Result } from '@/core/result'; +import { isLocalEnvironment } from '@/helpers/environment'; export type LookupError = 'no-links-found' | 'no-self-link' | 'lookup-error'; @@ -82,7 +83,7 @@ export async function lookupActorProfile( const webfingerData = await lookupWebFinger(resource, { allowPrivateAddress: process.env.ALLOW_PRIVATE_ADDRESS === 'true' && - ['development', 'testing'].includes(process.env.NODE_ENV || ''), + isLocalEnvironment(process.env.NODE_ENV), }); if (!webfingerData?.links) {