From b49065d30e875e5220df59e099033a001d2c16ed Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:25:11 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]=20Fi?= =?UTF-8?q?x=20SSRF=20vulnerability=20in=20website=20analysis=20and=20docu?= =?UTF-8?q?ment=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: HIGH 💡 Vulnerability: Server-Side Request Forgery (SSRF) bypass via redirects. The `validateWebUrl` function checked the initial URL, but `axios` followed redirects automatically, potentially exposing private internal services. 🎯 Impact: Attackers could access internal services or read metadata by supplying a malicious URL that redirects to internal IPs. 🔧 Fix: Implemented safe HTTP/HTTPS agents with custom DNS lookup validation to prevent SSRF and DNS rebinding. Applied to `WebsiteAnalysisService` and `DocumentSourceProcessorService`. ✅ Verification: Added unit tests for IP blocking logic. Co-authored-by: criptogus <128640021+criptogus@users.noreply.github.com> --- .../document-source-processor.service.ts | 4 +- .../src/services/websiteAnalysisService.ts | 6 +- backend/src/utils/ssrf-filter.test.ts | 89 +++++++++++++++++++ backend/src/utils/ssrf-filter.ts | 44 +++++++-- 4 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 backend/src/utils/ssrf-filter.test.ts diff --git a/backend/src/services/document-source-processor.service.ts b/backend/src/services/document-source-processor.service.ts index d69b8fc2b..59d0d3ce9 100644 --- a/backend/src/services/document-source-processor.service.ts +++ b/backend/src/services/document-source-processor.service.ts @@ -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 { @@ -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'; diff --git a/backend/src/services/websiteAnalysisService.ts b/backend/src/services/websiteAnalysisService.ts index eac6f5f7c..39970b0a4 100644 --- a/backend/src/services/websiteAnalysisService.ts +++ b/backend/src/services/websiteAnalysisService.ts @@ -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; @@ -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) { diff --git a/backend/src/utils/ssrf-filter.test.ts b/backend/src/utils/ssrf-filter.test.ts new file mode 100644 index 000000000..5b2fd99f1 --- /dev/null +++ b/backend/src/utils/ssrf-filter.test.ts @@ -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'); + }); + }); +}); diff --git a/backend/src/utils/ssrf-filter.ts b/backend/src/utils/ssrf-filter.ts index 8c423cda9..d64b00955 100644 --- a/backend/src/utils/ssrf-filter.ts +++ b/backend/src/utils/ssrf-filter.ts @@ -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 { - 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 @@ -25,3 +22,38 @@ export async function validateWebUrl(urlStr: string): Promise { throw new Error(`SSRF Blocked: Access to private IP ${address} is forbidden`); } } + +export async function validateWebUrl(urlStr: string): Promise { + 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 });