Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/nestjs-backend/src/features/import/open-api/import.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
});
Comment on lines +261 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep first-party import attachment URLs fetchable

When the UI uploads an import file with the default local storage provider, notify() returns a relative /api/attachments/read/... presigned URL for cookie-authenticated requests; this method then rewrites it to http://localhost:${process.env.PORT}... before fetching it. Adding the request-filtering agent here makes those normal uploaded-file imports resolve to loopback and get rejected as a private address, so /api/import/analyze and the subsequent import flow fail for the default local-storage path unless SSRF protection is disabled. The SSRF guard needs to exempt trusted first-party attachment URLs or read local attachments without going through a blocked localhost fetch.

Useful? React with 👍 / 👎.


const supportType = importTypeMap[type].accept.split(',');

Expand Down
19 changes: 19 additions & 0 deletions apps/nestjs-backend/src/utils/ssrf-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}