mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
Re-check SSRF on every redirect hop when resolving short links
Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { decrypt_api_key } from './apiKeyCrypto';
|
import { decrypt_api_key } from './apiKeyCrypto';
|
||||||
import { checkSsrf } from '../utils/ssrfGuard';
|
import { safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard';
|
||||||
import { getAppUrl } from './notifications';
|
import { getAppUrl } from './notifications';
|
||||||
|
|
||||||
// ── Google API call counter ───────────────────────────────────────────────────
|
// ── Google API call counter ───────────────────────────────────────────────────
|
||||||
@@ -634,10 +634,10 @@ export async function getPlacePhoto(
|
|||||||
try {
|
try {
|
||||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||||
if (wiki) {
|
if (wiki) {
|
||||||
// Wikimedia photos: fetch bytes and cache to disk
|
// Wikimedia photos: fetch bytes and cache to disk. Follow redirects
|
||||||
const ssrf = await checkSsrf(wiki.photoUrl, true);
|
// manually so each hop (the image URL can 3xx to a CDN host) is
|
||||||
if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 });
|
// re-validated against the SSRF guard, not just the first URL.
|
||||||
const imgRes = await fetch(wiki.photoUrl);
|
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
|
||||||
if (imgRes.ok) {
|
if (imgRes.ok) {
|
||||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||||
@@ -746,13 +746,25 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
|
|||||||
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
|
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
|
||||||
let resolvedUrl = url;
|
let resolvedUrl = url;
|
||||||
|
|
||||||
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) with SSRF protection
|
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) with SSRF protection.
|
||||||
|
// Redirects are followed manually so every hop is re-checked — a short link
|
||||||
|
// that 302s to an internal IP is blocked, while a legitimate cross-host
|
||||||
|
// redirect (goo.gl → maps.google.com) still resolves.
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname)) {
|
if (['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname)) {
|
||||||
const ssrf = await checkSsrf(url, true);
|
try {
|
||||||
if (!ssrf.allowed) throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
|
const redirectRes = await safeFetchFollow(
|
||||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
url,
|
||||||
resolvedUrl = redirectRes.url;
|
{ signal: AbortSignal.timeout(10000) },
|
||||||
|
{ bypassInternalIpAllowed: true },
|
||||||
|
);
|
||||||
|
resolvedUrl = redirectRes.url;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SsrfBlockedError) {
|
||||||
|
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract coordinates from Google Maps URL patterns:
|
// Extract coordinates from Google Maps URL patterns:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|||||||
import unzipper from 'unzipper';
|
import unzipper from 'unzipper';
|
||||||
import { db, getPlaceWithTags } from '../db/database';
|
import { db, getPlaceWithTags } from '../db/database';
|
||||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||||
import { checkSsrf } from '../utils/ssrfGuard';
|
import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard';
|
||||||
import { Place } from '../types';
|
import { Place } from '../types';
|
||||||
import {
|
import {
|
||||||
buildCategoryNameLookup,
|
buildCategoryNameLookup,
|
||||||
@@ -587,10 +587,18 @@ export async function importGoogleList(tripId: string, url: string) {
|
|||||||
const ssrf = await checkSsrf(url);
|
const ssrf = await checkSsrf(url);
|
||||||
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
||||||
|
|
||||||
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl)
|
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl). Redirects are
|
||||||
|
// followed manually so every hop is re-checked against the SSRF guard — a
|
||||||
|
// short link that 302s to an internal IP is blocked even though the initial
|
||||||
|
// host is public.
|
||||||
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
||||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
try {
|
||||||
resolvedUrl = redirectRes.url;
|
const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) });
|
||||||
|
resolvedUrl = redirectRes.url;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pattern: /placelists/list/{ID}
|
// Pattern: /placelists/list/{ID}
|
||||||
@@ -692,11 +700,18 @@ export async function importNaverList(
|
|||||||
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
||||||
|
|
||||||
// Resolve naver.me short links to the canonical map.naver.com folder URL.
|
// Resolve naver.me short links to the canonical map.naver.com folder URL.
|
||||||
|
// Redirects are followed manually so each hop is re-validated against the
|
||||||
|
// SSRF guard (a short link could otherwise 302 to an internal address).
|
||||||
let parsedUrl: URL;
|
let parsedUrl: URL;
|
||||||
try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; }
|
try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; }
|
||||||
if (parsedUrl.hostname === 'naver.me') {
|
if (parsedUrl.hostname === 'naver.me') {
|
||||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
try {
|
||||||
resolvedUrl = redirectRes.url;
|
const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) });
|
||||||
|
resolvedUrl = redirectRes.url;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderMatch = resolvedUrl.match(/favorite\/myPlace\/folder\/([A-Za-z0-9_-]+)/i);
|
const folderMatch = resolvedUrl.match(/favorite\/myPlace\/folder\/([A-Za-z0-9_-]+)/i);
|
||||||
|
|||||||
@@ -131,6 +131,85 @@ export async function safeFetch(url: string, init?: RequestInit, options?: SafeF
|
|||||||
return fetch(url, { ...init, dispatcher } as any);
|
return fetch(url, { ...init, dispatcher } as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SafeFetchFollowOptions extends SafeFetchOptions {
|
||||||
|
/** Maximum number of redirects to follow before giving up. Defaults to 5. */
|
||||||
|
maxRedirects?: number;
|
||||||
|
/**
|
||||||
|
* When true, private/internal IPs that ALLOW_INTERNAL_NETWORK would normally
|
||||||
|
* permit are still blocked (matches `checkSsrf(url, true)`). Loopback and
|
||||||
|
* link-local are always blocked regardless. Defaults to false.
|
||||||
|
*/
|
||||||
|
bypassInternalIpAllowed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF-safe fetch that follows redirects MANUALLY, re-validating every hop.
|
||||||
|
*
|
||||||
|
* `safeFetch()` (and a one-shot `checkSsrf()` + `fetch(redirect:'follow')`) only
|
||||||
|
* guards the INITIAL URL: a validated public URL can 302-redirect to an internal
|
||||||
|
* IP that the platform fetch would then follow unchecked (redirect TOCTOU). This
|
||||||
|
* helper instead requests with `redirect: 'manual'`, and on every 3xx it resolves
|
||||||
|
* the `Location` header against the current URL, runs `checkSsrf()` on the new
|
||||||
|
* target, and only then fetches the next hop through a dispatcher pinned to THAT
|
||||||
|
* hop's resolved IP. Each hop is therefore SSRF-checked + DNS-pinned, while
|
||||||
|
* legitimate cross-host redirects (e.g. goo.gl → maps.google.com) still resolve
|
||||||
|
* because the dispatcher is re-pinned per hop rather than locked to the first IP.
|
||||||
|
*
|
||||||
|
* The returned Response is the first non-redirect response (or the last redirect
|
||||||
|
* if the hop limit is reached). `response.url` reflects the final hop so callers
|
||||||
|
* relying on the resolved URL keep working.
|
||||||
|
*/
|
||||||
|
export async function safeFetchFollow(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
options?: SafeFetchFollowOptions,
|
||||||
|
): Promise<Response> {
|
||||||
|
const maxRedirects = options?.maxRedirects ?? 5;
|
||||||
|
const rejectUnauthorized = options?.rejectUnauthorized ?? true;
|
||||||
|
const bypassInternalIpAllowed = options?.bypassInternalIpAllowed ?? false;
|
||||||
|
|
||||||
|
let currentUrl = url;
|
||||||
|
|
||||||
|
for (let hop = 0; ; hop++) {
|
||||||
|
const ssrf = await checkSsrf(currentUrl, bypassInternalIpAllowed);
|
||||||
|
if (!ssrf.allowed) {
|
||||||
|
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, rejectUnauthorized);
|
||||||
|
const response = await fetch(currentUrl, {
|
||||||
|
...init,
|
||||||
|
redirect: 'manual',
|
||||||
|
dispatcher,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Only a 3xx WITH a Location header is a redirect we follow; anything else
|
||||||
|
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
|
||||||
|
const status = typeof response.status === 'number' ? response.status : 0;
|
||||||
|
const isRedirectStatus = status >= 300 && status < 400;
|
||||||
|
const location = isRedirectStatus ? response.headers?.get('location') ?? null : null;
|
||||||
|
if (!location) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hop >= maxRedirects) {
|
||||||
|
throw new SsrfBlockedError('Too many redirects');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve relative redirects against the current URL, then loop to
|
||||||
|
// re-check + re-pin on the next iteration. Drain the body so the
|
||||||
|
// connection can be reused/closed.
|
||||||
|
let nextUrl: string;
|
||||||
|
try {
|
||||||
|
nextUrl = new URL(location, currentUrl).toString();
|
||||||
|
} catch {
|
||||||
|
throw new SsrfBlockedError('Invalid redirect location');
|
||||||
|
}
|
||||||
|
void response.body?.cancel().catch(() => {});
|
||||||
|
currentUrl = nextUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an undici Agent whose connect.lookup is pinned to the already-validated
|
* Returns an undici Agent whose connect.lookup is pinned to the already-validated
|
||||||
* IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection
|
* IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection
|
||||||
|
|||||||
@@ -29,9 +29,26 @@ vi.mock('../../../src/db/database', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
vi.mock('../../../src/utils/ssrfGuard', () => {
|
||||||
checkSsrf: mockCheckSsrf,
|
class SsrfBlockedError extends Error {
|
||||||
}));
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SsrfBlockedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
checkSsrf: mockCheckSsrf,
|
||||||
|
SsrfBlockedError,
|
||||||
|
// Mirror the real per-hop helper closely enough for unit tests: run the
|
||||||
|
// (mocked) SSRF check, then fetch through the (stubbed) global fetch. The
|
||||||
|
// fetch stubs in these tests already return the final resolved response.
|
||||||
|
safeFetchFollow: vi.fn(async (url: string, init?: any) => {
|
||||||
|
const ssrf = await mockCheckSsrf(url);
|
||||||
|
if (!ssrf.allowed) throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
|
||||||
|
return (globalThis.fetch as any)(url, init);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||||
decrypt_api_key: (v: string | null) => v,
|
decrypt_api_key: (v: string | null) => v,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ vi.mock('undici', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import dns from 'dns/promises';
|
import dns from 'dns/promises';
|
||||||
import { checkSsrf, SsrfBlockedError, safeFetch, createPinnedDispatcher } from '../../../src/utils/ssrfGuard';
|
import { checkSsrf, SsrfBlockedError, safeFetch, safeFetchFollow, createPinnedDispatcher } from '../../../src/utils/ssrfGuard';
|
||||||
|
|
||||||
const mockLookup = vi.mocked(dns.lookup);
|
const mockLookup = vi.mocked(dns.lookup);
|
||||||
|
|
||||||
@@ -215,6 +215,117 @@ describe('safeFetch', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('safeFetchFollow (manual per-hop redirect SSRF)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
mockLookup.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Build a minimal Response-like object for a given hop. */
|
||||||
|
function fakeResponse(opts: { status: number; location?: string; url: string; ok?: boolean }) {
|
||||||
|
return {
|
||||||
|
status: opts.status,
|
||||||
|
ok: opts.ok ?? (opts.status >= 200 && opts.status < 300),
|
||||||
|
url: opts.url,
|
||||||
|
headers: { get: (h: string) => (h.toLowerCase() === 'location' ? opts.location ?? null : null) },
|
||||||
|
body: { cancel: () => Promise.resolve() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('follows a legitimate cross-host redirect (goo.gl -> maps.google.com) to the final response', async () => {
|
||||||
|
// Both hops resolve to public IPs.
|
||||||
|
mockLookup.mockResolvedValue({ address: '142.250.0.0', family: 4 });
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(fakeResponse({ status: 302, location: 'https://maps.google.com/maps/place/Foo', url: 'https://goo.gl/abc' }))
|
||||||
|
.mockResolvedValueOnce(fakeResponse({ status: 200, url: 'https://maps.google.com/maps/place/Foo' }));
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const res = await safeFetchFollow('https://goo.gl/abc');
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.url).toBe('https://maps.google.com/maps/place/Foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a redirect whose target resolves to an internal IP', async () => {
|
||||||
|
vi.stubEnv('ALLOW_INTERNAL_NETWORK', 'false');
|
||||||
|
// First hop (public) is allowed; the redirect target resolves to a private IP.
|
||||||
|
mockLookup
|
||||||
|
.mockResolvedValueOnce({ address: '142.250.0.0', family: 4 }) // goo.gl
|
||||||
|
.mockResolvedValue({ address: '169.254.169.254', family: 4 }); // redirect → metadata
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(fakeResponse({ status: 302, location: 'http://169.254.169.254/latest/meta-data/', url: 'https://goo.gl/evil' }));
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
await expect(safeFetchFollow('https://goo.gl/evil')).rejects.toThrow(SsrfBlockedError);
|
||||||
|
// Only the first hop should have been fetched; the internal hop is blocked BEFORE fetch.
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a redirect to a loopback address even with ALLOW_INTERNAL_NETWORK=true', async () => {
|
||||||
|
mockLookup
|
||||||
|
.mockResolvedValueOnce({ address: '142.250.0.0', family: 4 })
|
||||||
|
.mockResolvedValue({ address: '127.0.0.1', family: 4 });
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(fakeResponse({ status: 301, location: 'http://internal/', url: 'https://goo.gl/x' }));
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
await expect(safeFetchFollow('https://goo.gl/x', undefined, { bypassInternalIpAllowed: true }))
|
||||||
|
.rejects.toThrow(SsrfBlockedError);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the initial URL if it is already internal', async () => {
|
||||||
|
mockLookup.mockResolvedValue({ address: '10.0.0.5', family: 4 });
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
await expect(safeFetchFollow('http://intranet.example')).rejects.toThrow(SsrfBlockedError);
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the response immediately when not a redirect', async () => {
|
||||||
|
mockLookup.mockResolvedValue({ address: '8.8.8.8', family: 4 });
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue(fakeResponse({ status: 200, url: 'https://example.com' }));
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
const res = await safeFetchFollow('https://example.com');
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a 3xx with no Location header as-is (nothing to follow)', async () => {
|
||||||
|
mockLookup.mockResolvedValue({ address: '8.8.8.8', family: 4 });
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue(fakeResponse({ status: 304, url: 'https://example.com' }));
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
const res = await safeFetchFollow('https://example.com');
|
||||||
|
expect(res.status).toBe(304);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws after exceeding the max redirect hops', async () => {
|
||||||
|
mockLookup.mockResolvedValue({ address: '8.8.8.8', family: 4 });
|
||||||
|
// Always 302 to a new public host → loops until the hop cap.
|
||||||
|
let n = 0;
|
||||||
|
const mockFetch = vi.fn().mockImplementation(() =>
|
||||||
|
Promise.resolve(fakeResponse({ status: 302, location: `https://h${++n}.example.com/`, url: `https://h${n}.example.com/` })),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
await expect(safeFetchFollow('https://start.example.com', undefined, { maxRedirects: 2 }))
|
||||||
|
.rejects.toThrow(SsrfBlockedError);
|
||||||
|
// initial + 2 allowed redirects = 3 fetches, then the 4th hop is rejected before fetch
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves relative redirect Location against the current URL', async () => {
|
||||||
|
mockLookup.mockResolvedValue({ address: '8.8.8.8', family: 4 });
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(fakeResponse({ status: 302, location: '/resolved/path', url: 'https://example.com/start' }))
|
||||||
|
.mockResolvedValueOnce(fakeResponse({ status: 200, url: 'https://example.com/resolved/path' }));
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
await safeFetchFollow('https://example.com/start');
|
||||||
|
// Second fetch must target the absolute resolution of the relative Location.
|
||||||
|
expect(mockFetch.mock.calls[1][0]).toBe('https://example.com/resolved/path');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('createPinnedDispatcher', () => {
|
describe('createPinnedDispatcher', () => {
|
||||||
it('returns an object (Agent instance)', () => {
|
it('returns an object (Agent instance)', () => {
|
||||||
const dispatcher = createPinnedDispatcher('93.184.216.34');
|
const dispatcher = createPinnedDispatcher('93.184.216.34');
|
||||||
|
|||||||
Reference in New Issue
Block a user