mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
refactor(core): Use the common SSRF service on instance AI and harden web fetch (#29674)
This commit is contained in:
+2
@@ -46,6 +46,7 @@ import type { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'
|
||||
import type { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { RoleService } from '@/services/role.service';
|
||||
import type { SsrfProtectionService } from '@/services/ssrf/ssrf-protection.service';
|
||||
import type { Telemetry } from '@/telemetry';
|
||||
|
||||
jest.mock('@/permissions.ee/check-access');
|
||||
@@ -127,6 +128,7 @@ const service = new InstanceAiAdapterService(
|
||||
roleService,
|
||||
telemetry,
|
||||
aiBuilderTemporaryWorkflowRepository,
|
||||
mock<SsrfProtectionService>(),
|
||||
);
|
||||
|
||||
const user = mock<User>({
|
||||
|
||||
@@ -996,6 +996,7 @@ function createNodeAdapterForTests(nodes: Array<Record<string, unknown>>) {
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[28],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[30],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
|
||||
);
|
||||
|
||||
(
|
||||
@@ -1126,6 +1127,7 @@ function createDataTableAdapterForTests(overrides?: {
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[28],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[30],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
|
||||
);
|
||||
|
||||
const adapter = service.createContext(mockUser).dataTableService;
|
||||
@@ -1403,6 +1405,7 @@ function createWorkflowAdapterForTests(overrides?: {
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[28],
|
||||
{ track: jest.fn() } as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
|
||||
mockAiBuilderTemporaryWorkflowRepository as unknown as AiBuilderTemporaryWorkflowRepository,
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
|
||||
);
|
||||
|
||||
const context = service.createContext(mockUser, { threadId: 'thread-1' });
|
||||
@@ -1731,6 +1734,7 @@ function createExecutionAdapterForTests(overrides?: { sharingEnabled?: boolean }
|
||||
mockRoleService as unknown as RoleService,
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[30],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
|
||||
);
|
||||
|
||||
const adapter = service.createContext(mockUser).executionService;
|
||||
@@ -1985,6 +1989,7 @@ function createRunAdapterForTests(workflow: Record<string, unknown>) {
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[28],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[30],
|
||||
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
|
||||
);
|
||||
|
||||
const adapter = service.createContext(mockUser).executionService;
|
||||
|
||||
@@ -115,6 +115,7 @@ import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters
|
||||
import { FolderService } from '@/services/folder.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
import { SsrfProtectionService } from '@/services/ssrf/ssrf-protection.service';
|
||||
import { TagService } from '@/services/tag.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||
@@ -190,6 +191,7 @@ export class InstanceAiAdapterService {
|
||||
private readonly roleService: RoleService,
|
||||
private readonly telemetry: Telemetry,
|
||||
private readonly aiBuilderTemporaryWorkflowRepository: AiBuilderTemporaryWorkflowRepository,
|
||||
private readonly ssrfProtectionService: SsrfProtectionService,
|
||||
) {
|
||||
this.logger = logger.scoped('instance-ai');
|
||||
this.allowSendingParameterValues = globalConfig.ai.allowSendingParameterValues;
|
||||
@@ -1451,6 +1453,7 @@ export class InstanceAiAdapterService {
|
||||
const fetchCache = this.webResearchCache;
|
||||
const searchCacheRef = this.searchCache;
|
||||
const settingsService = this.settingsService;
|
||||
const ssrf = this.ssrfProtectionService;
|
||||
const userId = user.id;
|
||||
|
||||
// Lazy search method that resolves credentials on first call
|
||||
@@ -1509,6 +1512,7 @@ export class InstanceAiAdapterService {
|
||||
maxResponseBytes: options?.maxResponseBytes,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
authorizeUrl: options?.authorizeUrl,
|
||||
ssrf,
|
||||
});
|
||||
|
||||
// Attempt summarization (truncation fallback — no model injection yet)
|
||||
|
||||
+153
-39
@@ -1,18 +1,38 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { SsrfProtectionConfig } from '@n8n/config';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { SsrfBridge } from 'n8n-core';
|
||||
import { createResultOk } from 'n8n-workflow';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
|
||||
import type { DnsResolver } from '@/services/ssrf/dns-resolver';
|
||||
import { SsrfProtectionService } from '@/services/ssrf/ssrf-protection.service';
|
||||
|
||||
import { fetchAndExtract } from '../fetch-and-extract';
|
||||
import * as ssrfGuard from '../ssrf-guard';
|
||||
|
||||
jest.mock('../ssrf-guard');
|
||||
|
||||
const mockAssertPublicUrl = ssrfGuard.assertPublicUrl as jest.MockedFunction<
|
||||
typeof ssrfGuard.assertPublicUrl
|
||||
>;
|
||||
function createSsrfMock(): jest.Mocked<SsrfBridge> {
|
||||
const ssrf = mock<SsrfBridge>();
|
||||
ssrf.validateUrl.mockResolvedValue(createResultOk(undefined));
|
||||
ssrf.validateRedirectSync.mockReturnValue(undefined);
|
||||
// Lookup is invoked by undici when fetching real hosts. Our tests stub fetch
|
||||
// itself so the lookup is never called — return a noop.
|
||||
ssrf.createSecureLookup.mockReturnValue((_h, _o, cb) =>
|
||||
cb(null, '127.0.0.1', 4),
|
||||
) as unknown as jest.Mocked<SsrfBridge>['createSecureLookup'];
|
||||
return ssrf;
|
||||
}
|
||||
|
||||
// Helper to create a mock Response
|
||||
function createMockResponse(
|
||||
body: string,
|
||||
options: { contentType?: string; status?: number; url?: string } = {},
|
||||
options: {
|
||||
contentType?: string;
|
||||
status?: number;
|
||||
url?: string;
|
||||
location?: string;
|
||||
} = {},
|
||||
): Response {
|
||||
const { contentType = 'text/html', status = 200, url: responseUrl } = options;
|
||||
const { contentType = 'text/html', status = 200, url: responseUrl, location } = options;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -21,22 +41,26 @@ function createMockResponse(
|
||||
},
|
||||
});
|
||||
|
||||
const headers = new Headers({ 'content-type': contentType });
|
||||
if (location) headers.set('location', location);
|
||||
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Not Found',
|
||||
statusText: status === 200 ? 'OK' : status === 301 ? 'Moved' : 'Not Found',
|
||||
url: responseUrl ?? 'https://example.com',
|
||||
headers: new Headers({ 'content-type': contentType }),
|
||||
headers,
|
||||
body: stream,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('fetchAndExtract', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let ssrf: jest.Mocked<SsrfBridge>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAssertPublicUrl.mockResolvedValue(undefined);
|
||||
ssrf = createSsrfMock();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -58,7 +82,7 @@ describe('fetchAndExtract', () => {
|
||||
|
||||
globalThis.fetch = jest.fn().mockResolvedValue(createMockResponse(html));
|
||||
|
||||
const result = await fetchAndExtract('https://example.com/docs');
|
||||
const result = await fetchAndExtract('https://example.com/docs', { ssrf });
|
||||
|
||||
expect(result.title).toBeTruthy();
|
||||
expect(result.content).toContain('API Reference');
|
||||
@@ -82,7 +106,7 @@ describe('fetchAndExtract', () => {
|
||||
|
||||
globalThis.fetch = jest.fn().mockResolvedValue(createMockResponse(html));
|
||||
|
||||
const result = await fetchAndExtract('https://example.com/table');
|
||||
const result = await fetchAndExtract('https://example.com/table', { ssrf });
|
||||
|
||||
expect(result.content).toContain('Code');
|
||||
expect(result.content).toContain('200');
|
||||
@@ -94,7 +118,7 @@ describe('fetchAndExtract', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(createMockResponse(text, { contentType: 'text/plain' }));
|
||||
|
||||
const result = await fetchAndExtract('https://example.com/file.txt');
|
||||
const result = await fetchAndExtract('https://example.com/file.txt', { ssrf });
|
||||
|
||||
expect(result.content).toBe(text);
|
||||
expect(result.truncated).toBe(false);
|
||||
@@ -107,6 +131,7 @@ describe('fetchAndExtract', () => {
|
||||
.mockResolvedValue(createMockResponse(longText, { contentType: 'text/plain' }));
|
||||
|
||||
const result = await fetchAndExtract('https://example.com/long', {
|
||||
ssrf,
|
||||
maxContentLength: 1000,
|
||||
});
|
||||
|
||||
@@ -128,7 +153,7 @@ describe('fetchAndExtract', () => {
|
||||
|
||||
globalThis.fetch = jest.fn().mockResolvedValue(createMockResponse(html));
|
||||
|
||||
const result = await fetchAndExtract('https://example.com/spa');
|
||||
const result = await fetchAndExtract('https://example.com/spa', { ssrf });
|
||||
|
||||
expect(result.safetyFlags?.jsRenderingSuspected).toBe(true);
|
||||
});
|
||||
@@ -147,45 +172,134 @@ describe('fetchAndExtract', () => {
|
||||
|
||||
globalThis.fetch = jest.fn().mockResolvedValue(createMockResponse(html));
|
||||
|
||||
const result = await fetchAndExtract('https://example.com/login');
|
||||
const result = await fetchAndExtract('https://example.com/login', { ssrf });
|
||||
|
||||
expect(result.safetyFlags?.loginRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('performs post-redirect SSRF check', async () => {
|
||||
const html = '<html><body><p>Hello</p></body></html>';
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(createMockResponse(html, { url: 'https://redirected.example.com' }));
|
||||
|
||||
await fetchAndExtract('https://example.com');
|
||||
|
||||
// Should be called twice: once for original URL, once for redirect URL
|
||||
expect(mockAssertPublicUrl).toHaveBeenCalledTimes(2);
|
||||
expect(mockAssertPublicUrl).toHaveBeenCalledWith('https://example.com');
|
||||
expect(mockAssertPublicUrl).toHaveBeenCalledWith('https://redirected.example.com');
|
||||
});
|
||||
|
||||
it('handles HTTP errors gracefully', async () => {
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(createMockResponse('Not Found', { status: 404 }));
|
||||
|
||||
const result = await fetchAndExtract('https://example.com/missing');
|
||||
const result = await fetchAndExtract('https://example.com/missing', { ssrf });
|
||||
|
||||
expect(result.content).toContain('HTTP 404');
|
||||
expect(result.contentLength).toBe(0);
|
||||
});
|
||||
|
||||
it('skips post-redirect SSRF check when URL did not change', async () => {
|
||||
const html = '<html><body><p>Hello</p></body></html>';
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(createMockResponse(html, { url: 'https://example.com' }));
|
||||
describe('SSRF integration', () => {
|
||||
it('validates the initial URL via SsrfBridge', async () => {
|
||||
const html = '<html><body><p>Hi</p></body></html>';
|
||||
globalThis.fetch = jest.fn().mockResolvedValue(createMockResponse(html));
|
||||
|
||||
await fetchAndExtract('https://example.com');
|
||||
await fetchAndExtract('https://example.com', { ssrf });
|
||||
|
||||
// Only called once for the original URL
|
||||
expect(mockAssertPublicUrl).toHaveBeenCalledTimes(1);
|
||||
expect(ssrf.validateUrl).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
|
||||
it('aborts the fetch if validateUrl rejects', async () => {
|
||||
const blockedError = new Error('Blocked: 10.0.0.1');
|
||||
ssrf.validateUrl.mockResolvedValueOnce({ ok: false, error: blockedError });
|
||||
globalThis.fetch = jest.fn();
|
||||
|
||||
await expect(fetchAndExtract('https://blocked.example', { ssrf })).rejects.toThrow(
|
||||
'Blocked: 10.0.0.1',
|
||||
);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes a dispatcher with the secure lookup to fetch', async () => {
|
||||
const html = '<html><body><p>Hi</p></body></html>';
|
||||
const fetchSpy = jest.fn().mockResolvedValue(createMockResponse(html));
|
||||
globalThis.fetch = fetchSpy;
|
||||
|
||||
await fetchAndExtract('https://example.com', { ssrf });
|
||||
|
||||
expect(ssrf.createSecureLookup).toHaveBeenCalled();
|
||||
const fetchOptions = fetchSpy.mock.calls[0][1] as { dispatcher?: unknown };
|
||||
expect(fetchOptions.dispatcher).toBeDefined();
|
||||
});
|
||||
|
||||
it('validates each redirect target via validateRedirectSync and validateUrl', async () => {
|
||||
const html = '<html><body><p>Hi</p></body></html>';
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createMockResponse('', {
|
||||
status: 301,
|
||||
location: 'https://final.example.com/page',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(createMockResponse(html));
|
||||
|
||||
await fetchAndExtract('https://example.com', { ssrf });
|
||||
|
||||
expect(ssrf.validateUrl).toHaveBeenCalledWith('https://example.com');
|
||||
expect(ssrf.validateUrl).toHaveBeenCalledWith('https://final.example.com/page');
|
||||
expect(ssrf.validateRedirectSync).toHaveBeenCalledWith('https://final.example.com/page');
|
||||
});
|
||||
|
||||
it('blocks redirects whose direct-IP target is private', async () => {
|
||||
globalThis.fetch = jest.fn().mockResolvedValueOnce(
|
||||
createMockResponse('', {
|
||||
status: 301,
|
||||
location: 'http://10.0.0.1/secret',
|
||||
}),
|
||||
);
|
||||
ssrf.validateRedirectSync.mockImplementationOnce(() => {
|
||||
throw new Error('Blocked: 10.0.0.1');
|
||||
});
|
||||
|
||||
await expect(fetchAndExtract('https://example.com', { ssrf })).rejects.toThrow(
|
||||
'Blocked: 10.0.0.1',
|
||||
);
|
||||
});
|
||||
|
||||
it('defeats DNS rebinding: secure lookup rejects a private IP returned at connect time', async () => {
|
||||
// End-to-end TOCTOU regression guard. Wires fetchAndExtract to a real
|
||||
// SsrfProtectionService backed by a mocked DnsResolver that returns a
|
||||
// public IP on the validation lookup and a private IP on the connect
|
||||
// lookup. The secure lookup must reject the private IP rather than
|
||||
// hand it to undici, even though validation passed.
|
||||
const dnsResolver = mock<DnsResolver>();
|
||||
dnsResolver.lookup
|
||||
.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
|
||||
.mockResolvedValueOnce([{ address: '10.0.0.1', family: 4 }]);
|
||||
|
||||
const realSsrf = new SsrfProtectionService(
|
||||
new SsrfProtectionConfig(),
|
||||
dnsResolver,
|
||||
mock<Logger>({ scoped: jest.fn().mockReturnThis() }),
|
||||
);
|
||||
|
||||
// Capture the lookup function that fetchAndExtract installs on the dispatcher.
|
||||
let dispatcherLookup: LookupFunction | undefined;
|
||||
const realCreate = realSsrf.createSecureLookup.bind(realSsrf);
|
||||
jest.spyOn(realSsrf, 'createSecureLookup').mockImplementation(() => {
|
||||
dispatcherLookup = realCreate();
|
||||
return dispatcherLookup;
|
||||
});
|
||||
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(createMockResponse('<html><body>x</body></html>'));
|
||||
|
||||
// Validation lookup returns the public IP, so fetchAndExtract proceeds.
|
||||
await fetchAndExtract('https://evil.example/', { ssrf: realSsrf });
|
||||
expect(dispatcherLookup).toBeDefined();
|
||||
|
||||
// Simulate undici invoking the dispatcher's lookup at connect time.
|
||||
// DnsResolver now returns the private IP — the secure lookup must error
|
||||
// out instead of returning the rebound address.
|
||||
await expect(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
dispatcherLookup!('evil.example', { family: 0 }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(/restricted IP/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import * as dns from 'node:dns/promises';
|
||||
|
||||
import { assertPublicUrl } from '../ssrf-guard';
|
||||
|
||||
jest.mock('node:dns/promises');
|
||||
|
||||
const mockLookup = dns.lookup as jest.MockedFunction<typeof dns.lookup>;
|
||||
|
||||
describe('assertPublicUrl', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('allows public IP addresses', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never);
|
||||
await expect(assertPublicUrl('https://example.com')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks non-HTTP(S) schemes', async () => {
|
||||
await expect(assertPublicUrl('ftp://example.com')).rejects.toThrow(
|
||||
'scheme "ftp:" is not allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks file:// scheme', async () => {
|
||||
await expect(assertPublicUrl('file:///etc/passwd')).rejects.toThrow(
|
||||
'scheme "file:" is not allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks 127.x.x.x loopback', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }] as never);
|
||||
await expect(assertPublicUrl('https://localhost')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks 10.x.x.x private range', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }] as never);
|
||||
await expect(assertPublicUrl('https://internal.corp')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks 172.16.x.x private range', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '172.16.0.1', family: 4 }] as never);
|
||||
await expect(assertPublicUrl('https://internal.corp')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks 192.168.x.x private range', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '192.168.1.1', family: 4 }] as never);
|
||||
await expect(assertPublicUrl('https://home.local')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks 169.254.x.x link-local', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }] as never);
|
||||
await expect(assertPublicUrl('https://metadata.cloud')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks IPv6 loopback ::1', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '::1', family: 6 }] as never);
|
||||
await expect(assertPublicUrl('https://ipv6-local')).rejects.toThrow('private IPv6');
|
||||
});
|
||||
|
||||
it('blocks IPv4 literal private IP', async () => {
|
||||
await expect(assertPublicUrl('https://192.168.1.1/path')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('allows IPv4 literal public IP', async () => {
|
||||
await expect(assertPublicUrl('https://8.8.8.8/path')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles DNS resolution failure', async () => {
|
||||
mockLookup.mockRejectedValue(new Error('ENOTFOUND'));
|
||||
await expect(assertPublicUrl('https://nonexistent.invalid')).rejects.toThrow(
|
||||
'DNS resolution failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks when any resolved address is private', async () => {
|
||||
mockLookup.mockResolvedValue([
|
||||
{ address: '93.184.216.34', family: 4 },
|
||||
{ address: '10.0.0.1', family: 4 },
|
||||
] as never);
|
||||
await expect(assertPublicUrl('https://dual-stack.example')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks 100.64.x.x carrier-grade NAT (RFC 6598)', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '100.64.0.1', family: 4 }] as never);
|
||||
await expect(assertPublicUrl('https://cgnat.internal')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks 198.18.x.x benchmarking range', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '198.18.0.1', family: 4 }] as never);
|
||||
await expect(assertPublicUrl('https://bench.internal')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks 240.x.x.x reserved range', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '240.0.0.1', family: 4 }] as never);
|
||||
await expect(assertPublicUrl('https://reserved.internal')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
describe('IPv4-mapped IPv6 addresses', () => {
|
||||
it('blocks ::ffff:127.0.0.1 (loopback)', async () => {
|
||||
await expect(assertPublicUrl('http://[::ffff:127.0.0.1]:8080/x')).rejects.toThrow('private');
|
||||
});
|
||||
|
||||
it('blocks ::ffff:10.0.0.1 (RFC-1918)', async () => {
|
||||
await expect(assertPublicUrl('http://[::ffff:10.0.0.1]/x')).rejects.toThrow('private');
|
||||
});
|
||||
|
||||
it('blocks ::ffff:192.168.1.1 (RFC-1918)', async () => {
|
||||
await expect(assertPublicUrl('http://[::ffff:192.168.1.1]/x')).rejects.toThrow('private');
|
||||
});
|
||||
|
||||
it('blocks ::ffff:169.254.169.254 (link-local / cloud metadata)', async () => {
|
||||
await expect(assertPublicUrl('http://[::ffff:169.254.169.254]/x')).rejects.toThrow('private');
|
||||
});
|
||||
|
||||
it('blocks DNS-resolved IPv4-mapped IPv6 loopback', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '::ffff:127.0.0.1', family: 6 }] as never);
|
||||
await expect(assertPublicUrl('https://sneaky.example')).rejects.toThrow('private');
|
||||
});
|
||||
|
||||
it('blocks DNS-resolved IPv4-mapped IPv6 private range', async () => {
|
||||
mockLookup.mockResolvedValue([{ address: '::ffff:10.0.0.1', family: 6 }] as never);
|
||||
await expect(assertPublicUrl('https://sneaky.example')).rejects.toThrow('private');
|
||||
});
|
||||
|
||||
it('allows ::ffff: mapped public IP', async () => {
|
||||
await expect(assertPublicUrl('http://[::ffff:8.8.8.8]/x')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks hex-pair form loopback (::ffff:7f00:1)', async () => {
|
||||
await expect(assertPublicUrl('http://[::ffff:7f00:1]/')).rejects.toThrow('private');
|
||||
});
|
||||
|
||||
it('blocks hex-pair form private address (::ffff:a00:1)', async () => {
|
||||
await expect(assertPublicUrl('http://[::ffff:a00:1]/')).rejects.toThrow('private');
|
||||
});
|
||||
|
||||
it('blocks hex-pair form link-local (::ffff:a9fe:a9fe)', async () => {
|
||||
await expect(assertPublicUrl('http://[::ffff:a9fe:a9fe]/')).rejects.toThrow('private');
|
||||
});
|
||||
|
||||
it('allows hex-pair form public address (::ffff:808:808)', async () => {
|
||||
await expect(assertPublicUrl('http://[::ffff:808:808]/')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks decimal IP for loopback (2130706433 = 127.0.0.1)', async () => {
|
||||
await expect(assertPublicUrl('http://2130706433/')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks hex IP for loopback (0x7f000001 = 127.0.0.1)', async () => {
|
||||
await expect(assertPublicUrl('http://0x7f000001/')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks decimal IP for private range (167772161 = 10.0.0.1)', async () => {
|
||||
await expect(assertPublicUrl('http://167772161/')).rejects.toThrow('private IP');
|
||||
});
|
||||
|
||||
it('blocks hex IP for metadata endpoint (0xa9fea9fe = 169.254.169.254)', async () => {
|
||||
await expect(assertPublicUrl('http://0xa9fea9fe/')).rejects.toThrow('private IP');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import { parseHTML } from 'linkedom';
|
||||
import TurndownService from 'turndown';
|
||||
import { gfm } from '@joplin/turndown-plugin-gfm';
|
||||
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import type { FetchedPage } from '@n8n/instance-ai';
|
||||
import { assertPublicUrl } from './ssrf-guard';
|
||||
import { parseHTML } from 'linkedom';
|
||||
import type { SsrfBridge } from 'n8n-core';
|
||||
import TurndownService from 'turndown';
|
||||
import { Agent } from 'undici';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const MAX_TIMEOUT_MS = 120_000;
|
||||
@@ -21,6 +21,11 @@ export interface FetchAndExtractOptions {
|
||||
* Throw to abort the fetch (e.g. for HITL domain approval).
|
||||
*/
|
||||
authorizeUrl?: (url: string) => Promise<void>;
|
||||
/**
|
||||
* SSRF guard. The same secure lookup function pins DNS for the actual
|
||||
* connect, so the IP that passes validation is the one fetch connects to.
|
||||
*/
|
||||
ssrf: SsrfBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,13 +37,13 @@ export interface FetchAndExtractOptions {
|
||||
*/
|
||||
export async function fetchAndExtract(
|
||||
url: string,
|
||||
options?: FetchAndExtractOptions,
|
||||
options: FetchAndExtractOptions,
|
||||
): Promise<FetchedPage> {
|
||||
const maxContentLength = options?.maxContentLength ?? DEFAULT_MAX_CONTENT_LENGTH;
|
||||
const maxResponseBytes = options?.maxResponseBytes ?? MAX_RESPONSE_BYTES;
|
||||
const timeoutMs = Math.min(options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
||||
const maxContentLength = options.maxContentLength ?? DEFAULT_MAX_CONTENT_LENGTH;
|
||||
const maxResponseBytes = options.maxResponseBytes ?? MAX_RESPONSE_BYTES;
|
||||
const timeoutMs = Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
||||
|
||||
const authorizeUrl = options?.authorizeUrl;
|
||||
const { authorizeUrl, ssrf } = options;
|
||||
|
||||
// Manual redirect handling — validate every hop against SSRF guard
|
||||
let currentUrl = url;
|
||||
@@ -46,8 +51,10 @@ export async function fetchAndExtract(
|
||||
let redirectCount = 0;
|
||||
|
||||
while (redirectCount <= MAX_REDIRECTS) {
|
||||
await assertPublicUrl(currentUrl);
|
||||
const validation = await ssrf.validateUrl(currentUrl);
|
||||
if (!validation.ok) throw validation.error;
|
||||
|
||||
const dispatcher = new Agent({ connect: { lookup: ssrf.createSecureLookup() } });
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
@@ -60,9 +67,12 @@ export async function fetchAndExtract(
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,application/pdf;q=0.7,*/*;q=0.5',
|
||||
},
|
||||
redirect: 'manual',
|
||||
// @ts-expect-error dispatcher is a valid undici option for Node.js fetch
|
||||
dispatcher,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
await dispatcher.close();
|
||||
}
|
||||
|
||||
// Follow redirects manually so each hop is SSRF-checked
|
||||
@@ -78,6 +88,10 @@ export async function fetchAndExtract(
|
||||
// Resolve relative redirect URLs against the current URL
|
||||
currentUrl = new URL(location, currentUrl).href;
|
||||
|
||||
// Direct-IP redirect targets are caught here; hostnames are caught by
|
||||
// validateUrl on the next loop iteration before the dispatcher connects.
|
||||
ssrf.validateRedirectSync(currentUrl);
|
||||
|
||||
// Domain-access authorization for the redirect target
|
||||
if (authorizeUrl) {
|
||||
await authorizeUrl(currentUrl);
|
||||
@@ -86,13 +100,6 @@ export async function fetchAndExtract(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Defense-in-depth: if the runtime followed a redirect despite manual mode,
|
||||
// validate the actual response URL against the SSRF guard.
|
||||
if (response.url && response.url !== currentUrl) {
|
||||
await assertPublicUrl(response.url);
|
||||
currentUrl = response.url;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { assertPublicUrl } from './ssrf-guard';
|
||||
export { fetchAndExtract } from './fetch-and-extract';
|
||||
export type { FetchAndExtractOptions } from './fetch-and-extract';
|
||||
export { maybeSummarize } from './summarize-content';
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
|
||||
/** RFC-1918 / loopback / link-local / IETF reserved ranges. */
|
||||
const PRIVATE_RANGES = [
|
||||
// 10.0.0.0/8
|
||||
{ start: ip4ToNum('10.0.0.0'), end: ip4ToNum('10.255.255.255') },
|
||||
// 172.16.0.0/12
|
||||
{ start: ip4ToNum('172.16.0.0'), end: ip4ToNum('172.31.255.255') },
|
||||
// 192.168.0.0/16
|
||||
{ start: ip4ToNum('192.168.0.0'), end: ip4ToNum('192.168.255.255') },
|
||||
// 127.0.0.0/8 (loopback)
|
||||
{ start: ip4ToNum('127.0.0.0'), end: ip4ToNum('127.255.255.255') },
|
||||
// 169.254.0.0/16 (link-local)
|
||||
{ start: ip4ToNum('169.254.0.0'), end: ip4ToNum('169.254.255.255') },
|
||||
// 0.0.0.0/8
|
||||
{ start: ip4ToNum('0.0.0.0'), end: ip4ToNum('0.255.255.255') },
|
||||
// 100.64.0.0/10 (Carrier-grade NAT, RFC 6598 — common in cloud VPCs)
|
||||
{ start: ip4ToNum('100.64.0.0'), end: ip4ToNum('100.127.255.255') },
|
||||
// 192.0.0.0/24 (IETF protocol assignments, RFC 6890)
|
||||
{ start: ip4ToNum('192.0.0.0'), end: ip4ToNum('192.0.0.255') },
|
||||
// 198.18.0.0/15 (Benchmarking, RFC 2544)
|
||||
{ start: ip4ToNum('198.18.0.0'), end: ip4ToNum('198.19.255.255') },
|
||||
// 240.0.0.0/4 (Reserved, class E)
|
||||
{ start: ip4ToNum('240.0.0.0'), end: ip4ToNum('255.255.255.255') },
|
||||
];
|
||||
|
||||
/** IPv6 loopback and link-local prefixes. */
|
||||
const PRIVATE_IPV6_PREFIXES = ['::1', 'fe80:', 'fd', 'fc'];
|
||||
|
||||
/** Regex for IPv4-mapped IPv6 addresses like ::ffff:127.0.0.1 or ::ffff:7f00:1 */
|
||||
const IPV4_MAPPED_IPV6_DOTTED = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i;
|
||||
const IPV4_MAPPED_IPV6_HEX = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;
|
||||
|
||||
function ip4ToNum(ip: string): number {
|
||||
const parts = ip.split('.').map(Number);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
||||
}
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const num = ip4ToNum(ip);
|
||||
return PRIVATE_RANGES.some((r) => num >= r.start && num <= r.end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the embedded IPv4 address from an IPv4-mapped IPv6 address.
|
||||
* Handles both dotted notation (::ffff:127.0.0.1) and hex notation (::ffff:7f00:1).
|
||||
* Returns null if the address is not an IPv4-mapped IPv6 address.
|
||||
*/
|
||||
function extractMappedIPv4(ip: string): string | null {
|
||||
const dottedMatch = IPV4_MAPPED_IPV6_DOTTED.exec(ip);
|
||||
if (dottedMatch) return dottedMatch[1];
|
||||
|
||||
const hexMatch = IPV4_MAPPED_IPV6_HEX.exec(ip);
|
||||
if (hexMatch) {
|
||||
const high = parseInt(hexMatch[1], 16);
|
||||
const low = parseInt(hexMatch[2], 16);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPrivateIPv6(ip: string): boolean {
|
||||
const lower = ip.toLowerCase();
|
||||
|
||||
// Check IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1, ::ffff:7f00:1)
|
||||
const mappedIPv4 = extractMappedIPv4(lower);
|
||||
if (mappedIPv4 !== null) {
|
||||
return isPrivateIPv4(mappedIPv4);
|
||||
}
|
||||
|
||||
return PRIVATE_IPV6_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL points to a public internet host.
|
||||
* Blocks non-HTTP(S) schemes, private/loopback IPs, and IPv6 link-local.
|
||||
*
|
||||
* @throws Error if the URL fails any SSRF check
|
||||
*/
|
||||
export async function assertPublicUrl(url: string): Promise<void> {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only allow http and https
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(
|
||||
`Blocked: scheme "${parsed.protocol}" is not allowed. Only HTTP(S) is supported.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve hostname to IP and check against private ranges
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
// Check if hostname is already an IP literal
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
||||
if (isPrivateIPv4(hostname)) {
|
||||
throw new Error(`Blocked: ${hostname} resolves to a private IP address.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hostname.startsWith('[') || hostname.includes(':')) {
|
||||
const bare = hostname.replace(/^\[|\]$/g, '');
|
||||
if (isPrivateIPv6(bare)) {
|
||||
throw new Error(`Blocked: ${hostname} resolves to a private IPv6 address.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// DNS resolve and check all returned addresses
|
||||
try {
|
||||
const result = await lookup(hostname, { all: true });
|
||||
for (const { address, family } of result) {
|
||||
if (family === 4 && isPrivateIPv4(address)) {
|
||||
throw new Error(`Blocked: ${hostname} resolves to a private IP address (${address}).`);
|
||||
}
|
||||
if (family === 6 && isPrivateIPv6(address)) {
|
||||
throw new Error(`Blocked: ${hostname} resolves to a private IPv6 address (${address}).`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Blocked:')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`DNS resolution failed for ${hostname}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -467,33 +467,72 @@ describe('SsrfProtectionService', () => {
|
||||
});
|
||||
|
||||
describe('decimal/octal/hex IP representations', () => {
|
||||
it('should block decimal IP representation via DNS resolution', async () => {
|
||||
// 2130706433 = 127.0.0.1 in decimal — URL constructor treats it as hostname
|
||||
const dnsResolver = createMockDnsResolver();
|
||||
dnsResolver.lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||
it('should block decimal IP representation for loopback', async () => {
|
||||
// new URL('http://2130706433/').hostname === '127.0.0.1' — normalized before lookup
|
||||
const { service } = createService();
|
||||
expectBlocked(await service.validateUrl('http://2130706433/'));
|
||||
});
|
||||
|
||||
const { service } = createService({}, dnsResolver);
|
||||
const result = await service.validateUrl('http://2130706433/');
|
||||
it('should block decimal IP representation for RFC-1918', async () => {
|
||||
// 167772161 === 10.0.0.1
|
||||
const { service } = createService();
|
||||
expectBlocked(await service.validateUrl('http://167772161/'));
|
||||
});
|
||||
|
||||
expectBlocked(result);
|
||||
it('should block hex IP representation for loopback', async () => {
|
||||
// 0x7f000001 === 127.0.0.1
|
||||
const { service } = createService();
|
||||
expectBlocked(await service.validateUrl('http://0x7f000001/'));
|
||||
});
|
||||
|
||||
it('should block hex IP representation for cloud metadata', async () => {
|
||||
// 0xa9fea9fe === 169.254.169.254
|
||||
const { service } = createService();
|
||||
expectBlocked(await service.validateUrl('http://0xa9fea9fe/'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv6-mapped IPv4 addresses', () => {
|
||||
it('should block ::ffff:127.0.0.1', () => {
|
||||
it('should block ::ffff:127.0.0.1 (dotted form)', () => {
|
||||
const { service } = createService();
|
||||
expectBlocked(service.validateIp('::ffff:127.0.0.1'));
|
||||
});
|
||||
|
||||
it('should block ::ffff:10.0.0.1', () => {
|
||||
it('should block ::ffff:10.0.0.1 (dotted form)', () => {
|
||||
const { service } = createService();
|
||||
expectBlocked(service.validateIp('::ffff:10.0.0.1'));
|
||||
});
|
||||
|
||||
it('should block ::ffff:7f00:1 (hex-compressed form for 127.0.0.1)', () => {
|
||||
// new URL auto-compresses ::ffff:127.0.0.1 to this form
|
||||
const { service } = createService();
|
||||
expectBlocked(service.validateIp('::ffff:7f00:1'));
|
||||
});
|
||||
|
||||
it('should block ::ffff:a9fe:a9fe (hex-compressed form for 169.254.169.254)', () => {
|
||||
const { service } = createService();
|
||||
expectBlocked(service.validateIp('::ffff:a9fe:a9fe'));
|
||||
});
|
||||
|
||||
it('should block validateUrl for bracketed IPv4-mapped IPv6 loopback', async () => {
|
||||
const { service } = createService();
|
||||
expectBlocked(await service.validateUrl('http://[::ffff:127.0.0.1]/'));
|
||||
});
|
||||
|
||||
it('should block validateUrl for bracketed IPv4-mapped IPv6 metadata', async () => {
|
||||
const { service } = createService();
|
||||
expectBlocked(await service.validateUrl('http://[::ffff:169.254.169.254]/'));
|
||||
});
|
||||
|
||||
it('should allow ::ffff: with public IP', () => {
|
||||
const { service } = createService();
|
||||
expectAllowed(service.validateIp('::ffff:8.8.8.8'));
|
||||
});
|
||||
|
||||
it('should allow ::ffff:808:808 (hex-compressed public IP)', () => {
|
||||
const { service } = createService();
|
||||
expectAllowed(service.validateIp('::ffff:808:808'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS rebinding prevention (TOCTOU)', () => {
|
||||
|
||||
Reference in New Issue
Block a user