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
4 changes: 3 additions & 1 deletion backend/src/services/document-source-processor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { YoutubeTranscript } from 'youtube-transcript';
import { query } from './database.js';
import { DocumentSourceService } from './document-source.service.js';
import { generateEmbedding } from './embeddings.js';
import { validateWebUrl } from '../utils/ssrf-filter.js';
import { validateWebUrl, getSafeHttpAgent, getSafeHttpsAgent } from '../utils/ssrf-filter.js';

// Tipos
interface ProcessedContent {
Expand Down Expand Up @@ -125,6 +125,8 @@ export class DocumentSourceProcessorService {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
timeout: 30000,
httpAgent: getSafeHttpAgent(),
httpsAgent: getSafeHttpsAgent()
});
text = this.extractTextFromHtml(response.data);
metadata.source = 'axios';
Expand Down
6 changes: 4 additions & 2 deletions backend/src/services/websiteAnalysisService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { GoogleGenAI } from '@google/genai';
import axios from 'axios';
import * as cheerio from 'cheerio';
import { getSystemGoogleKey } from './api-keys-helper.service.js';
import { validateWebUrl } from '../utils/ssrf-filter.js';
import { validateWebUrl, getSafeHttpAgent, getSafeHttpsAgent } from '../utils/ssrf-filter.js';

interface WebsiteAnalysis {
url: string;
Expand Down Expand Up @@ -92,7 +92,9 @@ class WebsiteAnalysisService {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
maxRedirects: 5
maxRedirects: 5,
httpAgent: getSafeHttpAgent(),
httpsAgent: getSafeHttpsAgent()
});
return response.data;
} catch (error: any) {
Expand Down
89 changes: 89 additions & 0 deletions backend/src/utils/ssrf-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { jest, describe, it, expect } from '@jest/globals';

// We need to mock dns before importing ssrf-filter because it promisifies dns.lookup at top level
jest.unstable_mockModule('dns', async () => {
const util = await import('util');
const promisify = util.promisify;

const lookup = (hostname: string, options: any, callback: any) => {
if (typeof options === 'function') {
callback = options;
}

// Mock responses
if (hostname === 'localhost') return callback(null, '127.0.0.1', 4);
if (hostname === 'private.local') return callback(null, '192.168.1.1', 4);
if (hostname === 'public.com') return callback(null, '8.8.8.8', 4);
if (hostname === 'google.com') return callback(null, '8.8.8.8', 4);

callback(new Error('ENOTFOUND'));
};

// Add custom promisify implementation to match Node's dns.lookup behavior
(lookup as any)[promisify.custom] = (hostname: string, options: any) => {
return new Promise((resolve, reject) => {
lookup(hostname, options, (err: any, address: string, family: number) => {
if (err) reject(err);
else resolve({ address, family });
});
});
};

return {
default: { lookup },
lookup
};
});

// Dynamic import to ensure mock is applied
const { checkIp, validateWebUrl } = await import('./ssrf-filter.js');

describe('SSRF Filter', () => {
describe('checkIp', () => {
it('should throw for 127.0.0.1', () => {
expect(() => checkIp('127.0.0.1')).toThrow(/SSRF Blocked/);
});

it('should throw for 10.0.0.1', () => {
expect(() => checkIp('10.0.0.1')).toThrow(/SSRF Blocked/);
});

it('should throw for 192.168.1.1', () => {
expect(() => checkIp('192.168.1.1')).toThrow(/SSRF Blocked/);
});

it('should throw for 169.254.1.1', () => {
expect(() => checkIp('169.254.1.1')).toThrow(/SSRF Blocked/);
});

it('should throw for 0.0.0.0', () => {
expect(() => checkIp('0.0.0.0')).toThrow(/SSRF Blocked/);
});

it('should throw for IPv6 loopback', () => {
expect(() => checkIp('::1')).toThrow(/SSRF Blocked/);
});

it('should allow public IP', () => {
expect(() => checkIp('8.8.8.8')).not.toThrow();
});
});

describe('validateWebUrl', () => {
it('should block localhost', async () => {
await expect(validateWebUrl('http://localhost')).rejects.toThrow(/SSRF Blocked/);
});

it('should block private domains', async () => {
await expect(validateWebUrl('http://private.local')).rejects.toThrow(/SSRF Blocked/);
});

it('should allow public domains', async () => {
await expect(validateWebUrl('http://public.com')).resolves.not.toThrow();
});

it('should validate protocol', async () => {
await expect(validateWebUrl('ftp://public.com')).rejects.toThrow('Invalid protocol');
});
});
});
44 changes: 38 additions & 6 deletions backend/src/utils/ssrf-filter.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import dns from 'dns';
import { promisify } from 'util';
import { URL } from 'url';
import http from 'http';
import https from 'https';

const lookup = promisify(dns.lookup);

export async function validateWebUrl(urlStr: string): Promise<void> {
const url = new URL(urlStr);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid protocol');

const { address } = await lookup(url.hostname);

export function checkIp(address: string): void {
// Check against private IP ranges (IPv4 & IPv6)
if (
/^127\./.test(address) || // Loopback
Expand All @@ -25,3 +22,38 @@ export async function validateWebUrl(urlStr: string): Promise<void> {
throw new Error(`SSRF Blocked: Access to private IP ${address} is forbidden`);
}
}

export async function validateWebUrl(urlStr: string): Promise<void> {
const url = new URL(urlStr);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid protocol');

const { address } = await lookup(url.hostname);
checkIp(address);
}

// Safe lookup for Agents
const safeLookup = (hostname: string, options: any, callback: (err: NodeJS.ErrnoException | null, address: string | dns.LookupAddress[] | string[], family: number) => void) => {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return callback(err, address as any, family);

try {
if (typeof address === 'string') {
checkIp(address);
} else if (Array.isArray(address)) {
// Handle if address is array of strings or objects (depending on node version/options)
address.forEach((addr: any) => {
const ip = typeof addr === 'string' ? addr : addr.address;
checkIp(ip);
});
}
callback(null, address as any, family);
} catch (error: any) {
const e = new Error(error.message);
(e as any).code = 'ECONNREFUSED'; // Simulate connection refusal
callback(e as NodeJS.ErrnoException, address as any, family);
}
});
};

export const getSafeHttpAgent = () => new http.Agent({ lookup: safeLookup as any });
export const getSafeHttpsAgent = () => new https.Agent({ lookup: safeLookup as any });
Loading