feat(stalker): add validated redirect session hooks

This commit is contained in:
4gray committed 2026-07-27 09:35:43 +02:00
1 parent 8009233220
commit 9166d0f12c
2 files changed
+538 -24

No files matched your search

@@ -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({
@@ -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> | void;
collectResponse?(
response: ValidatedAxiosResponseContext
): Promise<void> | void;
prepareHeaders?(
hop: ValidatedAxiosPrepareHeadersContext
):
| AxiosRequestConfig['headers']
| Promise<AxiosRequestConfig['headers']>;
}
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<string>,
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<ValidatedAxiosRequestConfig> {
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<unknown>,
validatedUrl: URL,
method: string
): Promise<void> {
await hooks?.collectResponse?.({
headers: copyResponseHeaders(response.headers),
method,
status: response.status,
url: validatedUrl.toString(),
});
}
function rejectedAxiosResponse(
error: unknown
): AxiosResponse<unknown> | 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<unknown>;
}
/**
* 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<T = unknown>(
let requestConfig = { ...config };
let initialOrigin: string | undefined;
let initialAddresses: readonly string[] | undefined;
let pendingValidatedTarget: ValidatedRemoteUrl | undefined;
const visitedTargets = new Set<string>();
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<T = unknown>(
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<T>({
...pinnedConfig,
maxRedirects: 0,
url: validatedUrl.toString(),
validateStatus: (status) =>
REDIRECT_STATUSES.has(status) || originalValidateStatus(status),
});
let response: AxiosResponse<T>;
try {
response = await axios<T>({
...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<T = unknown>(
}
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<T = unknown>(
};
}
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;
}
}