v1.11.9: manual redirect handling — re-run URL guard on each hop
This commit is contained in:
@@ -343,3 +343,113 @@ describe('executeWebFetch — size + truncation', () => {
|
||||
expect('content' in result && result.truncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// v1.11.9: manual redirect handling — re-run URL guard on each hop
|
||||
// ============================================================================
|
||||
|
||||
// Helper: build a 30x redirect Response. status 302 by default; tests
|
||||
// pass other codes (or omit the Location header) when they need to.
|
||||
function redirect(loc: string | null, status = 302): Response {
|
||||
const headers: Record<string, string> = {};
|
||||
if (loc !== null) headers['location'] = loc;
|
||||
return new Response('', { status, headers });
|
||||
}
|
||||
|
||||
describe('executeWebFetch — redirect handling', () => {
|
||||
it('blocks a redirect target that resolves to a private IP (AWS IMDS)', async () => {
|
||||
// Public-IP origin 302s into 169.254.169.254 (link-local). Pre-v1.11.9
|
||||
// `redirect: 'follow'` would silently follow this; the new manual
|
||||
// loop re-runs isPublicUrl on the resolved target and blocks.
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect('http://169.254.169.254/latest/meta-data/'));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/redirect' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('error' in result).toBe(true);
|
||||
if ('error' in result) {
|
||||
expect(result.error).toBe('blocked_by_url_guard');
|
||||
// Reason should make it clear this was a REDIRECT hop, not the
|
||||
// initial URL — so logs can distinguish the two failure modes.
|
||||
expect(result.reason).toMatch(/redirect target/);
|
||||
}
|
||||
// Critical: the second fetch (the private target) must NOT happen.
|
||||
expect(fakeFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('follows a public-to-public redirect and returns the final body', async () => {
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect('https://example.org/final'))
|
||||
.mockResolvedValueOnce(mockResponse('ok body', { contentType: 'text/plain' }));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/start' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('content' in result).toBe(true);
|
||||
if ('content' in result) {
|
||||
expect(result.content).toBe('ok body');
|
||||
// Final URL is reported back so the model knows where the body came from.
|
||||
expect(result.url).toBe('https://example.org/final');
|
||||
}
|
||||
expect(fakeFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('bails after MAX_REDIRECTS hops with a Too many redirects error', async () => {
|
||||
// Chain 6 redirects — one more than the loop allows. Each Location
|
||||
// points at a distinct public host so the URL guard stays happy and
|
||||
// we exercise the redirectCount > MAX_REDIRECTS branch specifically.
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect('https://a.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://b.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://c.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://d.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://e.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://f.example/'));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://start.example/' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('error' in result).toBe(true);
|
||||
if ('error' in result) {
|
||||
expect(result.error).toBe('too_many_redirects');
|
||||
expect(result.reason).toMatch(/Too many redirects/);
|
||||
}
|
||||
});
|
||||
|
||||
it('errors when a 30x response omits the Location header', async () => {
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect(null, 302));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('error' in result).toBe(true);
|
||||
if ('error' in result) {
|
||||
expect(result.error).toBe('redirect_missing_location');
|
||||
expect(result.reason).toMatch(/no Location/);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a relative Location against the current URL', async () => {
|
||||
// Server sends `Location: /foo` (relative) on a request to
|
||||
// https://example.com/path. RFC 9110 says resolve against the
|
||||
// request URL, so the next hop is https://example.com/foo. Assert
|
||||
// the second fetch was called with the absolute resolved URL.
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect('/foo'))
|
||||
.mockResolvedValueOnce(mockResponse('final', { contentType: 'text/plain' }));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/path' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('content' in result && result.content).toBe('final');
|
||||
expect(fakeFetch).toHaveBeenCalledTimes(2);
|
||||
expect(fakeFetch.mock.calls[1]![0]).toBe('https://example.com/foo');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user