mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
test(ssrf): pin what a redirect hop may carry across an origin
The credential strip and the 303/302 body downgrade are new behaviour in a security helper, and src/utils sits on a 96% line ratchet that the untested branches took under.
This commit is contained in:
@@ -366,6 +366,99 @@ describe('safeFetchFollow (manual per-hop redirect SSRF)', () => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Following redirects by hand opts out of the platform's own rules, so they
|
||||
// have to be restated. undici drops Authorization across origins itself
|
||||
// (Fetch, "HTTP-redirect fetch" step 13) — without this, converting a caller
|
||||
// that sends a bearer token to safeFetchFollow would have been a regression.
|
||||
describe('credentials and body across a hop', () => {
|
||||
const authInit = { method: 'POST', headers: { Authorization: 'Bearer secret', 'X-Api-Key': 'k', 'User-Agent': 'TREK' }, body: 'payload' };
|
||||
const headerOf = (call: unknown[], name: string) =>
|
||||
new Headers((call[1] as { headers?: ConstructorParameters<typeof Headers>[0] }).headers).get(name);
|
||||
|
||||
function twoHops(location: string, status = 307) {
|
||||
mockLookup.mockResolvedValue({ address: '142.250.0.0', family: 4 });
|
||||
const mockFetch = vi.fn()
|
||||
.mockResolvedValueOnce(fakeResponse({ status, location, url: 'https://start.example/a' }))
|
||||
.mockResolvedValueOnce(fakeResponse({ status: 200, url: location }));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
return mockFetch;
|
||||
}
|
||||
|
||||
it('drops credential headers when the hop changes origin', async () => {
|
||||
const mockFetch = twoHops('https://elsewhere.example/b');
|
||||
await safeFetchFollow('https://start.example/a', authInit);
|
||||
|
||||
expect(headerOf(mockFetch.mock.calls[0], 'authorization')).toBe('Bearer secret');
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'authorization')).toBeNull();
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'x-api-key')).toBeNull();
|
||||
// User-Agent is not a credential and must survive: the goo.gl chain needs
|
||||
// it on the last hop or Google serves a different page.
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'user-agent')).toBe('TREK');
|
||||
});
|
||||
|
||||
it('keeps them on a same-origin hop', async () => {
|
||||
const mockFetch = twoHops('https://start.example/b');
|
||||
await safeFetchFollow('https://start.example/a', authInit);
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'authorization')).toBe('Bearer secret');
|
||||
});
|
||||
|
||||
it('treats the same host moving http to https as an upgrade, not a host change', async () => {
|
||||
const mockFetch = twoHops('https://start.example/b');
|
||||
await safeFetchFollow('http://start.example/a', authInit);
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'authorization')).toBe('Bearer secret');
|
||||
});
|
||||
|
||||
it('keeps method and body on a 307, which is what preserves them', async () => {
|
||||
const mockFetch = twoHops('https://start.example/b', 307);
|
||||
await safeFetchFollow('https://start.example/a', authInit);
|
||||
expect(mockFetch.mock.calls[1][1]).toMatchObject({ method: 'POST', body: 'payload' });
|
||||
});
|
||||
|
||||
it('downgrades a 303 to GET without a body', async () => {
|
||||
const mockFetch = twoHops('https://start.example/b', 303);
|
||||
await safeFetchFollow('https://start.example/a', authInit);
|
||||
expect(mockFetch.mock.calls[1][1]).toMatchObject({ method: 'GET', body: undefined });
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'content-type')).toBeNull();
|
||||
});
|
||||
|
||||
it('downgrades a 302 on a POST too, so a client_secret is not re-posted elsewhere', async () => {
|
||||
const mockFetch = twoHops('https://elsewhere.example/b', 302);
|
||||
await safeFetchFollow('https://start.example/a', authInit);
|
||||
expect(mockFetch.mock.calls[1][1]).toMatchObject({ method: 'GET', body: undefined });
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'authorization')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a GET alone on a 301', async () => {
|
||||
const mockFetch = twoHops('https://start.example/b', 301);
|
||||
await safeFetchFollow('https://start.example/a', { headers: { 'User-Agent': 'TREK' } });
|
||||
expect(mockFetch.mock.calls[1][1]).toMatchObject({ method: undefined });
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'user-agent')).toBe('TREK');
|
||||
});
|
||||
|
||||
it('keeps credentials when the caller opts in', async () => {
|
||||
const mockFetch = twoHops('https://elsewhere.example/b');
|
||||
await safeFetchFollow('https://start.example/a', authInit, { keepCredentialsOnRedirect: true });
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'authorization')).toBe('Bearer secret');
|
||||
});
|
||||
|
||||
it('an init without headers survives the strip untouched', async () => {
|
||||
const mockFetch = twoHops('https://elsewhere.example/b');
|
||||
await safeFetchFollow('https://start.example/a', { signal: AbortSignal.timeout(5000) });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('a relative redirect stays on the origin and keeps them', async () => {
|
||||
mockLookup.mockResolvedValue({ address: '142.250.0.0', family: 4 });
|
||||
const mockFetch = vi.fn()
|
||||
.mockResolvedValueOnce(fakeResponse({ status: 307, location: '/moved', url: 'https://start.example/a' }))
|
||||
.mockResolvedValueOnce(fakeResponse({ status: 200, url: 'https://start.example/moved' }));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
await safeFetchFollow('https://start.example/a', authInit);
|
||||
expect(mockFetch.mock.calls[1][0]).toBe('https://start.example/moved');
|
||||
expect(headerOf(mockFetch.mock.calls[1], 'authorization')).toBe('Bearer secret');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks a redirect to a loopback address even with ALLOW_INTERNAL_NETWORK=true', async () => {
|
||||
mockLookup
|
||||
.mockResolvedValueOnce({ address: '142.250.0.0', family: 4 })
|
||||
|
||||
Reference in New Issue
Block a user