Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -103,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';
Expand All @@ -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 { 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';
Expand Down Expand Up @@ -1037,16 +1038,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);
};
}

serve(
{
fetch: forceAcceptHeader(behindProxy(app.fetch)),
fetch: createFetchHandler(process.env.NODE_ENV, app.fetch),
port: Number.parseInt(process.env.PORT || '8080', 10),
},
(info) => {
Expand Down Expand Up @@ -1084,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');
Expand Down
9 changes: 3 additions & 6 deletions src/configuration/registrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
Expand Down
17 changes: 17 additions & 0 deletions src/helpers/environment.ts
Original file line number Diff line number Diff line change
@@ -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
* `createFetchHandler`).
*
* 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 || '');
}
21 changes: 21 additions & 0 deletions src/helpers/environment.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
61 changes: 61 additions & 0 deletions src/http/fetch-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { behindProxy } from 'x-forwarded-fetch';

import { isLocalEnvironment } from '@/helpers/environment';

type FetchHandler = (request: Request) => Response | Promise<Response>;

/**
* 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 — `createFetchHandler` owns that composition.
*/
function forceHttps(fetch: FetchHandler): FetchHandler {
return (request: Request) => {
request.headers.set('x-forwarded-proto', 'https');
return fetch(request);
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 createFetchHandler(
environment: string | undefined,
fetch: FetchHandler,
): FetchHandler {
const proxiedFetch = behindProxy(fetch);

return forceAcceptHeader(
isLocalEnvironment(environment)
? proxiedFetch
: forceHttps(proxiedFetch),
);
}
111 changes: 111 additions & 0 deletions src/http/fetch-handler.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';

import { createFetchHandler } from './fetch-handler';

async function dispatch(environment: string | undefined, request: Request) {
let receivedRequest: Request | undefined;

const fetch = createFetchHandler(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('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(
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',
);
}
});
});
6 changes: 4 additions & 2 deletions src/http/middleware/role-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) &&
Comment thread
sagzy marked this conversation as resolved.
!ctx.req.raw.url.startsWith('https')
) {
protocol = 'http';
Expand Down
3 changes: 2 additions & 1 deletion src/lookup-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down
Loading