From 9166d0f12c30cddda8c9d8b5c6bef4ea56bf59f9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 09:35:43 +0200 Subject: [PATCH] feat(stalker): add validated redirect session hooks --- .../src/app/util/validated-axios.spec.ts | 307 ++++++++++++++++++ .../src/app/util/validated-axios.ts | 255 +++++++++++++-- 2 files changed, 538 insertions(+), 24 deletions(-) diff --git a/apps/electron-backend/src/app/util/validated-axios.spec.ts b/apps/electron-backend/src/app/util/validated-axios.spec.ts index 28f58b7c7..dbc7cf135 100644 --- a/apps/electron-backend/src/app/util/validated-axios.spec.ts +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -297,6 +297,313 @@ describe('requestWithValidatedRedirects', () => { await expect(resolvePinnedAddress(1)).resolves.toBe('142.250.191.110'); }); + it('runs narrow hooks in exact validated-hop order and collects redirects before preparing the next hop', async () => { + const events: string[] = []; + const resolveHostname = jest.fn(async (hostname: string) => { + events.push(`validate:${hostname}`); + return ['93.184.216.34']; + }); + axiosMock + .mockImplementationOnce(async (config) => { + events.push(`axios:${config.url}:${config.headers['x-hop']}`); + return { + status: 302, + headers: { location: '/next' }, + }; + }) + .mockImplementationOnce(async (config) => { + events.push(`axios:${config.url}:${config.headers['x-hop']}`); + return { + status: 200, + headers: {}, + data: 'ok', + }; + }); + + await requestWithValidatedRedirects( + 'https://example.com/start', + { + method: 'GET', + redirectHooks: { + prepareHeaders: async (hop) => { + events.push(`prepare:${hop.url}:${hop.method}`); + return { + ...hop.headers, + 'x-hop': new URL(hop.url).pathname, + }; + }, + collectResponse: async (response) => { + events.push( + `collect:${response.url}:${response.status}` + ); + }, + beforeValidatedRedirect: async (redirect) => { + events.push( + `before:${redirect.fromUrl}->${redirect.toUrl}:${redirect.method}` + ); + }, + }, + }, + { resolveHostname } + ); + + expect(events).toEqual([ + 'validate:example.com', + 'prepare:https://example.com/start:GET', + 'axios:https://example.com/start:/start', + 'collect:https://example.com/start:302', + 'validate:example.com', + 'before:https://example.com/start->https://example.com/next:GET', + 'prepare:https://example.com/next:GET', + 'axios:https://example.com/next:/next', + 'collect:https://example.com/next:200', + ]); + }); + + it('does not pass redirect hook internals to Axios', async () => { + axiosMock.mockResolvedValueOnce({ + status: 200, + headers: {}, + data: 'ok', + }); + const redirectHooks = { + prepareHeaders: jest.fn(async (hop) => hop.headers), + }; + + await requestWithValidatedRedirects( + 'https://example.com/start', + { method: 'GET', redirectHooks }, + { resolveHostname: publicResolver } + ); + + expect(axiosMock.mock.calls[0][0]).not.toHaveProperty( + 'redirectHooks' + ); + }); + + it('prepares each hop from an isolated header snapshot', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: '/next' }, + }) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: 'ok', + }); + const observedBaseHeaders: string[][] = []; + + await requestWithValidatedRedirects( + 'https://example.com/start', + { + headers: { 'x-values': ['base'] }, + method: 'GET', + redirectHooks: { + prepareHeaders: (hop) => { + const values = hop.headers['x-values'] as string[]; + observedBaseHeaders.push([...values]); + values.push(new URL(hop.url).pathname); + return hop.headers; + }, + }, + }, + { resolveHostname: publicResolver } + ); + + expect(observedBaseHeaders).toEqual([['base'], ['base']]); + expect(axiosMock.mock.calls[0][0].headers['x-values']).toEqual([ + 'base', + '/start', + ]); + expect(axiosMock.mock.calls[1][0].headers['x-values']).toEqual([ + 'base', + '/next', + ]); + }); + + it('does not let response collection rewrite the validated redirect target', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: '/validated' }, + }) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: 'ok', + }); + + await requestWithValidatedRedirects( + 'https://example.com/start', + { + method: 'GET', + redirectHooks: { + collectResponse: (response) => { + response.headers.location = '/tampered'; + }, + }, + }, + { resolveHostname: publicResolver } + ); + + expect(axiosMock.mock.calls[1][0].url).toBe( + 'https://example.com/validated' + ); + }); + + it('collects a rejected HTTP response before propagating the Axios failure', async () => { + const rejected = { + response: { + status: 401, + headers: { 'set-cookie': ['session=synthetic; Path=/'] }, + }, + }; + const collectResponse = jest.fn(); + axiosMock.mockRejectedValueOnce(rejected); + + await expect( + requestWithValidatedRedirects( + 'https://example.com/start', + { + method: 'GET', + redirectHooks: { collectResponse }, + }, + { resolveHostname: publicResolver } + ) + ).rejects.toBe(rejected); + + expect(collectResponse).toHaveBeenCalledWith({ + headers: { + 'set-cookie': ['session=synthetic; Path=/'], + }, + method: 'GET', + status: 401, + url: 'https://example.com/start', + }); + }); + + it('allows a validated cross-origin redirect to pause before target preparation or contact', async () => { + class RedirectPause extends Error {} + + const pause = new RedirectPause('approval-required'); + const resolveHostname = jest.fn(async (hostname: string) => + hostname === 'example.com' + ? ['93.184.216.34'] + : ['142.250.191.110'] + ); + const prepareHeaders = jest.fn(async (hop) => hop.headers); + const collectResponse = jest.fn(async () => undefined); + const beforeValidatedRedirect = jest.fn(async () => { + throw pause; + }); + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: { location: 'https://target.example/landing' }, + }); + + await expect( + requestWithValidatedRedirects( + 'https://example.com/start', + { + method: 'GET', + redirectHooks: { + beforeValidatedRedirect, + collectResponse, + prepareHeaders, + }, + }, + { resolveHostname } + ) + ).rejects.toBe(pause); + + expect(resolveHostname).toHaveBeenNthCalledWith(1, 'example.com'); + expect(resolveHostname).toHaveBeenNthCalledWith( + 2, + 'target.example' + ); + expect(collectResponse).toHaveBeenCalledTimes(1); + expect(beforeValidatedRedirect).toHaveBeenCalledWith({ + crossOrigin: true, + fromUrl: 'https://example.com/start', + method: 'GET', + status: 302, + toUrl: 'https://target.example/landing', + }); + expect(prepareHeaders).toHaveBeenCalledTimes(1); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + location: '/same', + name: 'same target', + startUrl: 'https://example.com/same', + }, + { + location: '#two', + name: 'fragment-only target', + startUrl: 'https://example.com/same#one', + }, + ])( + 'rejects a repeated $name before another contact', + async ({ location, startUrl }) => { + const beforeValidatedRedirect = jest.fn(); + const prepareHeaders = jest.fn(async (hop) => hop.headers); + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: { location }, + }); + + await expect( + requestWithValidatedRedirects( + startUrl, + { + method: 'GET', + redirectHooks: { + beforeValidatedRedirect, + prepareHeaders, + }, + }, + { resolveHostname: publicResolver } + ) + ).rejects.toMatchObject({ + message: expect.stringMatching(/redirect loop/i), + status: 502, + }); + + expect(beforeValidatedRedirect).not.toHaveBeenCalled(); + expect(prepareHeaders).toHaveBeenCalledTimes(1); + expect(axiosMock).toHaveBeenCalledTimes(1); + } + ); + + it('allows a same-target POST redirect when its effective method rewrites to GET', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: '/submit#result' }, + }) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: 'ok', + }); + + await requestWithValidatedRedirects( + 'https://example.com/submit', + { data: { value: 1 }, method: 'POST' }, + { resolveHostname: publicResolver } + ); + + expect(axiosMock).toHaveBeenCalledTimes(2); + expect(axiosMock.mock.calls[1][0]).toMatchObject({ + data: undefined, + method: 'GET', + url: 'https://example.com/submit#result', + }); + }); + it('removes sensitive headers when a redirect changes origin', async () => { axiosMock .mockResolvedValueOnce({ diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts index 6b5e7fa4c..d5be9d90f 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -2,6 +2,7 @@ import axios, { AxiosRequestConfig, AxiosResponse, RawAxiosRequestHeaders, + RawAxiosResponseHeaders, } from 'axios'; import type { LookupAddress } from 'node:dns'; import { Agent as HttpAgent } from 'node:http'; @@ -10,6 +11,7 @@ import { isIP, LookupFunction } from 'node:net'; import { RemoteUrlPolicy, UnsafeUrlError, + ValidatedRemoteUrl, validateRemoteUrl, } from '../events/url-safety'; @@ -25,13 +27,73 @@ export interface ValidatedRequestAgentFactory { createHttpsAgent?(lookup?: LookupFunction, url?: URL): HttpsAgent; } +export interface ValidatedAxiosPrepareHeadersContext { + readonly headers: RawAxiosRequestHeaders; + readonly method: string; + readonly url: string; +} + +export interface ValidatedAxiosResponseContext { + readonly headers: RawAxiosResponseHeaders; + readonly method: string; + readonly status: number; + readonly url: string; +} + +export interface ValidatedAxiosRedirectContext { + readonly crossOrigin: boolean; + readonly fromUrl: string; + readonly method: string; + readonly status: number; + readonly toUrl: string; +} + +export interface ValidatedAxiosRedirectHooks { + beforeValidatedRedirect?( + redirect: ValidatedAxiosRedirectContext + ): Promise | void; + collectResponse?( + response: ValidatedAxiosResponseContext + ): Promise | void; + prepareHeaders?( + hop: ValidatedAxiosPrepareHeadersContext + ): + | AxiosRequestConfig['headers'] + | Promise; +} + export type ValidatedAxiosRequestConfig = Omit< AxiosRequestConfig, 'httpAgent' | 'httpsAgent' > & { agentFactory?: ValidatedRequestAgentFactory; + redirectHooks?: ValidatedAxiosRedirectHooks; }; +function copyRequestHeaders( + headers: AxiosRequestConfig['headers'] +): RawAxiosRequestHeaders { + if (!headers) { + return {}; + } + + const source = + typeof (headers as { toJSON?: () => RawAxiosRequestHeaders }) + .toJSON === 'function' + ? ( + headers as { + toJSON: () => RawAxiosRequestHeaders; + } + ).toJSON() + : headers; + return Object.fromEntries( + Object.entries(source).map(([name, value]) => [ + name, + Array.isArray(value) ? [...value] : value, + ]) + ); +} + function copyHeadersWithoutSensitiveValues( headers: AxiosRequestConfig['headers'] ): AxiosRequestConfig['headers'] { @@ -39,15 +101,7 @@ function copyHeadersWithoutSensitiveValues( return headers; } - const source = - typeof (headers as { toJSON?: () => RawAxiosRequestHeaders }).toJSON === - 'function' - ? ( - headers as { - toJSON: () => RawAxiosRequestHeaders; - } - ).toJSON() - : headers; + const source = copyRequestHeaders(headers); const sanitized: RawAxiosRequestHeaders = {}; for (const [name, value] of Object.entries(source)) { @@ -59,6 +113,19 @@ function copyHeadersWithoutSensitiveValues( return sanitized; } +function copyResponseHeaders( + headers: AxiosResponse['headers'] +): RawAxiosResponseHeaders { + const source = + typeof headers.toJSON === 'function' ? headers.toJSON() : headers; + return Object.fromEntries( + Object.entries(source).map(([name, value]) => [ + name, + Array.isArray(value) ? [...value] : value, + ]) + ); +} + function createPinnedLookup(addresses: readonly string[]): LookupFunction { const records: LookupAddress[] = addresses.map((address) => ({ address, @@ -95,7 +162,11 @@ function pinRequestToValidatedAddresses( url: URL, addresses: readonly string[] | undefined ): AxiosRequestConfig { - const { agentFactory, ...axiosConfig } = config; + const { + agentFactory, + redirectHooks: _redirectHooks, + ...axiosConfig + } = config; if (!addresses) { if (url.protocol === 'https:' && agentFactory?.createHttpsAgent) { return { @@ -159,6 +230,87 @@ function getRedirectValidationPolicy( }; } +function effectiveRequestMethod(method: string | undefined): string { + return method?.toUpperCase() || 'GET'; +} + +function canonicalVisitedTarget(url: URL, method: string): string { + const canonicalUrl = new URL(url); + canonicalUrl.hash = ''; + return `${method} ${canonicalUrl.toString()}`; +} + +function rememberValidatedTarget( + visitedTargets: Set, + target: URL, + method: string +): void { + const key = canonicalVisitedTarget(target, method); + if (visitedTargets.has(key)) { + throw new UnsafeUrlError('Redirect loop detected', 502); + } + visitedTargets.add(key); +} + +async function prepareHopConfig( + config: ValidatedAxiosRequestConfig, + validatedUrl: URL, + method: string +): Promise { + const prepareHeaders = config.redirectHooks?.prepareHeaders; + if (!prepareHeaders) { + return config; + } + + const headers = await prepareHeaders({ + headers: copyRequestHeaders(config.headers), + method, + url: validatedUrl.toString(), + }); + return { + ...config, + headers, + }; +} + +async function collectHopResponse( + hooks: ValidatedAxiosRedirectHooks | undefined, + response: AxiosResponse, + validatedUrl: URL, + method: string +): Promise { + await hooks?.collectResponse?.({ + headers: copyResponseHeaders(response.headers), + method, + status: response.status, + url: validatedUrl.toString(), + }); +} + +function rejectedAxiosResponse( + error: unknown +): AxiosResponse | undefined { + if ( + typeof error !== 'object' || + error === null || + !('response' in error) + ) { + return undefined; + } + + const response = error.response; + if ( + typeof response !== 'object' || + response === null || + typeof Reflect.get(response, 'status') !== 'number' || + typeof Reflect.get(response, 'headers') !== 'object' || + Reflect.get(response, 'headers') === null + ) { + return undefined; + } + return response as AxiosResponse; +} + /** * Runs an Axios request while validating the initial URL and every redirect. * Redirects are followed manually so each target passes through the same @@ -177,17 +329,27 @@ export async function requestWithValidatedRedirects( let requestConfig = { ...config }; let initialOrigin: string | undefined; let initialAddresses: readonly string[] | undefined; + let pendingValidatedTarget: ValidatedRemoteUrl | undefined; + const visitedTargets = new Set(); for (let redirectCount = 0; ; redirectCount += 1) { - const validatedTarget = await validateRemoteUrl( - currentUrl, - getRedirectValidationPolicy(currentUrl, initialOrigin, policy) - ); + const validatedTarget = + pendingValidatedTarget ?? + (await validateRemoteUrl( + currentUrl, + getRedirectValidationPolicy(currentUrl, initialOrigin, policy) + )); + pendingValidatedTarget = undefined; const validatedUrl = validatedTarget.url; const isInitialRequest = !initialOrigin; if (isInitialRequest) { initialOrigin = validatedUrl.origin; initialAddresses = validatedTarget.addresses; + rememberValidatedTarget( + visitedTargets, + validatedUrl, + effectiveRequestMethod(requestConfig.method) + ); } const addresses = !isInitialRequest && @@ -196,18 +358,45 @@ export async function requestWithValidatedRedirects( validatedUrl.origin === initialOrigin ? initialAddresses : validatedTarget.addresses; - const pinnedConfig = pinRequestToValidatedAddresses( + const method = effectiveRequestMethod(requestConfig.method); + const hopConfig = await prepareHopConfig( requestConfig, validatedUrl, + method + ); + const pinnedConfig = pinRequestToValidatedAddresses( + hopConfig, + validatedUrl, addresses ); - const response = await axios({ - ...pinnedConfig, - maxRedirects: 0, - url: validatedUrl.toString(), - validateStatus: (status) => - REDIRECT_STATUSES.has(status) || originalValidateStatus(status), - }); + let response: AxiosResponse; + try { + response = await axios({ + ...pinnedConfig, + maxRedirects: 0, + url: validatedUrl.toString(), + validateStatus: (status) => + REDIRECT_STATUSES.has(status) || + originalValidateStatus(status), + }); + } catch (error) { + const rejectedResponse = rejectedAxiosResponse(error); + if (rejectedResponse) { + await collectHopResponse( + requestConfig.redirectHooks, + rejectedResponse, + validatedUrl, + method + ); + } + throw error; + } + await collectHopResponse( + requestConfig.redirectHooks, + response, + validatedUrl, + method + ); if (!REDIRECT_STATUSES.has(response.status)) { return response; @@ -225,7 +414,6 @@ export async function requestWithValidatedRedirects( } const nextUrl = new URL(location, validatedUrl); - const method = requestConfig.method?.toUpperCase(); const shouldRewriteToGet = (response.status === 303 && method !== 'HEAD') || ((response.status === 301 || response.status === 302) && @@ -254,6 +442,25 @@ export async function requestWithValidatedRedirects( }; } - currentUrl = nextUrl.toString(); + const nextMethod = effectiveRequestMethod(requestConfig.method); + const nextTarget = await validateRemoteUrl( + nextUrl.toString(), + getRedirectValidationPolicy( + nextUrl.toString(), + initialOrigin, + policy + ) + ); + rememberValidatedTarget(visitedTargets, nextTarget.url, nextMethod); + await requestConfig.redirectHooks?.beforeValidatedRedirect?.({ + crossOrigin: nextTarget.url.origin !== validatedUrl.origin, + fromUrl: validatedUrl.toString(), + method: nextMethod, + status: response.status, + toUrl: nextTarget.url.toString(), + }); + + currentUrl = nextTarget.url.toString(); + pendingValidatedTarget = nextTarget; } }