From 8be690375bf4779c20880fc91376d2d1d98c3fae Mon Sep 17 00:00:00 2001 From: ChristianTeroerde Date: Sat, 11 Jul 2026 17:51:32 +0200 Subject: [PATCH 1/2] fix(import): apply SSRF request-filtering agent to import fetch Importer.getFile() fetched a user-supplied attachmentUrl (via GET /api/import/analyze) with raw node-fetch and no SSRF protection, while the attachments path already routes through getSsrfSafeAgents() (request-filtering-agent). Add getSsrfSafeFetchAgent() for node-fetch's (parsedUrl) => Agent factory and use it in the import fetch so attacker-supplied import URLs cannot reach private/link-local hosts (including via redirects). Honors TEABLE_SSRF_PROTECTION_DISABLED. Co-Authored-By: Claude Opus 4.8 --- .../features/import/open-api/import.class.ts | 8 +++++++- apps/nestjs-backend/src/utils/ssrf-guard.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/nestjs-backend/src/features/import/open-api/import.class.ts b/apps/nestjs-backend/src/features/import/open-api/import.class.ts index 2bf518c470..97f57d0f95 100644 --- a/apps/nestjs-backend/src/features/import/open-api/import.class.ts +++ b/apps/nestjs-backend/src/features/import/open-api/import.class.ts @@ -14,6 +14,7 @@ import { z } from 'zod'; import type { ZodType } from 'zod'; import { CustomHttpException } from '../../../custom.exception'; import { exceptionParse } from '../../../utils/exception-parse'; +import { getSsrfSafeFetchAgent } from '../../../utils/ssrf-guard'; import { toLineDelimitedStream } from './delimiter-stream'; export const DEFAULT_IMPORT_CPU_USAGE = 0.5; @@ -242,7 +243,12 @@ export abstract class Importer { url = `http://localhost:${process.env.PORT}${url}`; } - const { body: stream, headers } = await fetch(url); + // Guard against SSRF: route the fetch through the request-filtering agent so + // attacker-supplied import URLs cannot reach private/link-local hosts (incl. via + // redirects). Mirrors the protection already applied on the attachments path. + const { body: stream, headers } = await fetch(url, { + agent: getSsrfSafeFetchAgent(), + }); const supportType = importTypeMap[type].accept.split(','); diff --git a/apps/nestjs-backend/src/utils/ssrf-guard.ts b/apps/nestjs-backend/src/utils/ssrf-guard.ts index cf6e002163..d4262da307 100644 --- a/apps/nestjs-backend/src/utils/ssrf-guard.ts +++ b/apps/nestjs-backend/src/utils/ssrf-guard.ts @@ -28,3 +28,22 @@ export function getSsrfSafeAgents(): { } return SAFE_AGENTS; } + +/** + * Returns an SSRF-safe agent selector for use with node-fetch: + * `fetch(url, { agent: getSsrfSafeFetchAgent() })` + * node-fetch's `agent` option accepts a `(parsedUrl) => Agent` factory (unlike + * axios' `httpAgent`/`httpsAgent`), so we pick the http or https request-filtering + * agent per URL. This also blocks redirect-based bypasses + * (e.g. http://evil.com -> https://169.254.169.254). Returns undefined when SSRF + * protection is disabled via env var, so node-fetch uses its default agents. + */ +export function getSsrfSafeFetchAgent(): + | ((parsedUrl: URL) => RequestFilteringHttpAgent | RequestFilteringHttpsAgent) + | undefined { + if (isSsrfProtectionDisabled()) { + return undefined; + } + return (parsedUrl: URL) => + parsedUrl.protocol === 'https:' ? globalHttpsAgent : globalHttpAgent; +} From 05bf4aa3434ee10f75fe323900cf49a6f2918ddd Mon Sep 17 00:00:00 2001 From: ChristianTeroerde Date: Sat, 11 Jul 2026 18:09:21 +0200 Subject: [PATCH 2/2] fix(import): keep first-party local-storage imports working under the SSRF guard Review feedback: the request-filtering agent rejected the loopback address, so default local-storage imports (relative /api/attachments/read/... URLs that getFile() rewrites to http://localhost:PORT) would fail unless SSRF protection is disabled. Only apply the SSRF filter to externally-supplied absolute URLs. A relative reference is trusted as first-party only when its rewrite resolves to a genuine localhost host, so the loopback fetch bypasses the filter and default imports keep working. A crafted value such as "@169.254.169.254" resolves to a non-localhost host and stays filtered, so the guard is not bypassable through the rewrite branch. Co-Authored-By: Claude Opus 4.8 --- .../features/import/open-api/import.class.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/nestjs-backend/src/features/import/open-api/import.class.ts b/apps/nestjs-backend/src/features/import/open-api/import.class.ts index 97f57d0f95..8de117bb5d 100644 --- a/apps/nestjs-backend/src/features/import/open-api/import.class.ts +++ b/apps/nestjs-backend/src/features/import/open-api/import.class.ts @@ -239,15 +239,27 @@ export abstract class Importer { async getFile() { const { url: _url, type } = this.config; let url = _url.trim(); + // Externally-supplied absolute URLs are subject to SSRF filtering; only a relative + // reference rewritten to a genuine loopback host (the default local-storage + // attachment path, e.g. "/api/attachments/read/...") is trusted as first-party. + let isFirstPartyLocal = false; if (!z.string().url().safeParse(url).success) { url = `http://localhost:${process.env.PORT}${url}`; + try { + isFirstPartyLocal = new URL(url).hostname === 'localhost'; + } catch { + isFirstPartyLocal = false; + } } - // Guard against SSRF: route the fetch through the request-filtering agent so - // attacker-supplied import URLs cannot reach private/link-local hosts (incl. via - // redirects). Mirrors the protection already applied on the attachments path. + // Guard against SSRF: attacker-supplied import URLs are routed through the + // request-filtering agent so they cannot reach private/link-local hosts (incl. via + // redirects), mirroring the attachments path. The trusted first-party loopback + // fetch bypasses the filter so default local-storage imports keep working; a + // crafted value such as "@169.254.169.254" resolves to a non-localhost host and + // stays filtered. const { body: stream, headers } = await fetch(url, { - agent: getSsrfSafeFetchAgent(), + agent: isFirstPartyLocal ? undefined : getSsrfSafeFetchAgent(), }); const supportType = importTypeMap[type].accept.split(',');