From 45a83b3d7d0d0889e12bfc85f04be9da96e7e138 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sun, 26 Apr 2026 15:09:18 -0600 Subject: [PATCH] fix: prevent SSRF by using redirect: manual and validating Location header Replace default redirect-following fetch with manual redirect handling. Validates the Location header hostname against the alltrails.com allowlist before issuing a second fetch, preventing server-side request forgery via malicious redirect chains. --- packages/api/src/routes/alltrails.ts | 42 ++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/api/src/routes/alltrails.ts b/packages/api/src/routes/alltrails.ts index a71ef5f4d8..8fe7e4537f 100644 --- a/packages/api/src/routes/alltrails.ts +++ b/packages/api/src/routes/alltrails.ts @@ -35,6 +35,7 @@ export const alltrailsRoutes = new Elysia({ prefix: '/alltrails' }).post( try { response = await fetch(url, { headers: { 'User-Agent': UA }, + redirect: 'manual', signal: AbortSignal.timeout(8000), }); } catch (e) { @@ -44,18 +45,37 @@ export const alltrailsRoutes = new Elysia({ prefix: '/alltrails' }).post( return status(502, { error: 'Failed to fetch AllTrails URL' }); } - if (!response.ok) { - return status(502, { error: `AllTrails returned status ${response.status}` }); - } - - const finalUrl = response.url || url; - try { - const finalHostname = new URL(finalUrl).hostname; - if (!ALLTRAILS_HOSTNAME_RE.test(finalHostname)) { + // Validate any redirect before following it (SSRF guard) + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) { + return status(502, { error: 'AllTrails redirected without a Location header' }); + } + let redirectUrl: URL; + try { + redirectUrl = new URL(location, url); + } catch { + return status(502, { error: 'Invalid redirect URL from AllTrails' }); + } + if (redirectUrl.protocol !== 'https:' || !ALLTRAILS_HOSTNAME_RE.test(redirectUrl.hostname)) { return status(400, { error: 'URL redirected outside alltrails.com' }); } - } catch { - return status(502, { error: 'Could not parse redirect URL' }); + try { + response = await fetch(redirectUrl.toString(), { + headers: { 'User-Agent': UA }, + redirect: 'error', + signal: AbortSignal.timeout(8000), + }); + } catch (e) { + if (e instanceof DOMException && e.name === 'TimeoutError') { + return status(504, { error: 'Request to AllTrails timed out' }); + } + return status(502, { error: 'Failed to fetch AllTrails URL' }); + } + } + + if (!response.ok) { + return status(502, { error: `AllTrails returned status ${response.status}` }); } const html = await response.text(); @@ -68,7 +88,7 @@ export const alltrailsRoutes = new Elysia({ prefix: '/alltrails' }).post( const description = extractOgTag(html, 'og:description'); const image = extractOgTag(html, 'og:image'); - return { title, description, image, url: finalUrl }; + return { title, description, image, url: response.url || url }; }, { body: z.object({