-
-
Notifications
You must be signed in to change notification settings - Fork 35
Fixed actor documents mixing http and https URI schemes #1976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8b57f81
Fixed actor documents mixing http and https URI schemes
sagzy e9cecb5
Limited forced https scheme to staging and production
sagzy 22515dc
Forced the https scheme in every non-local environment
sagzy 7211f3a
Tightened fetch decorator types in serve-fetch
sagzy aa14992
Renamed createServeFetch to createFetchHandler
sagzy bc1f820
Routed remaining local-environment checks through isLocalEnvironment
sagzy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 || ''); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }; | ||
| } | ||
|
|
||
| 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), | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| ); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.