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..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 @@ -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; @@ -238,11 +239,28 @@ 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; + } } - const { body: stream, headers } = await fetch(url); + // 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: isFirstPartyLocal ? undefined : 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; +}