diff --git a/backend/src/services/websiteAnalysisService.ts b/backend/src/services/websiteAnalysisService.ts index eac6f5f7c..ded51e433 100644 --- a/backend/src/services/websiteAnalysisService.ts +++ b/backend/src/services/websiteAnalysisService.ts @@ -87,14 +87,40 @@ class WebsiteAnalysisService { private async fetchWebsite(url: string): Promise { try { - const response = await axios.get(url, { - timeout: 10000, - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - }, - maxRedirects: 5 - }); - return response.data; + let currentUrl = url; + let response; + const MAX_REDIRECTS = 5; + + for (let i = 0; i <= MAX_REDIRECTS; i++) { + // 🛡️ Security: Validate against SSRF before every request (including redirects) + await validateWebUrl(currentUrl); + + response = await axios.get(currentUrl, { + timeout: 10000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }, + maxRedirects: 0, // 🛡️ Security: Disable automatic redirects to prevent SSRF + validateStatus: (status) => status >= 200 && status < 400 + }); + + // Check for redirect + if (response.status >= 300 && response.status < 400 && response.headers.location) { + const location = response.headers.location; + // Handle relative URLs + currentUrl = new URL(location, currentUrl).toString(); + + if (i === MAX_REDIRECTS) { + throw new Error('Too many redirects'); + } + continue; + } + + // Success + return response.data; + } + + throw new Error('Too many redirects'); } catch (error: any) { if (error.code === 'ENOTFOUND') { throw new Error('Website not found'); @@ -102,6 +128,9 @@ class WebsiteAnalysisService { if (error.code === 'ETIMEDOUT') { throw new Error('Website timeout'); } + if (error.message.includes('SSRF')) { + throw error; + } throw new Error(`Failed to fetch website: ${error.message}`); } } diff --git a/backend/tests/websiteAnalysisService.security.test.ts b/backend/tests/websiteAnalysisService.security.test.ts new file mode 100644 index 000000000..8b333c570 --- /dev/null +++ b/backend/tests/websiteAnalysisService.security.test.ts @@ -0,0 +1,109 @@ + +import { jest } from '@jest/globals'; +import axios from 'axios'; + +// Set dummy API key for the service constructor +process.env.GOOGLE_AI_API_KEY = 'dummy_key'; + +// Mock dependencies +jest.mock('axios'); +jest.mock('../src/utils/ssrf-filter.js', () => ({ + validateWebUrl: jest.fn().mockImplementation(async (url: string) => { + // Simulate SSRF check logic locally for the test + if (url.includes('127.0.0.1') || url.includes('localhost') || url.includes('private')) { + throw new Error('SSRF Blocked'); + } + return Promise.resolve(); + }), +})); + +jest.mock('@google/genai', () => ({ + GoogleGenAI: jest.fn().mockImplementation(() => ({ + models: { + generateContent: jest.fn().mockResolvedValue({ + text: JSON.stringify({}) + }) + } + })) +})); + +// Import the service after mocks are set up +// Note: We need to use dynamic import or require because of hoisting if we were using purely ESM, +// but Jest mocks usually hoist. However, since the service is a singleton instance exported, +// we rely on the modules being evaluated after mocks. +import { websiteAnalysisService } from '../src/services/websiteAnalysisService'; +import { validateWebUrl } from '../src/utils/ssrf-filter'; + +describe('WebsiteAnalysisService Security', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should throw SSRF error when redirecting to a private IP', async () => { + const initialUrl = 'http://example.com/safe'; + const redirectUrl = 'http://127.0.0.1/unsafe'; + + // Mock axios to return a 302 Redirect to a private IP + // With the FIX, the service should handle this manually, see the location, and call validateWebUrl + // Without the FIX, axios (mocked) would just return the 302 response (since we aren't simulating axios redirect logic here), + // and the service would proceed to extract metadata from the "redirect body", ignoring the security risk of the location. + // However, to truly test that validation IS called on the redirect, we check the calls to validateWebUrl. + + // We want to verify that logic flows: + // 1. fetch(initial) -> 302 + // 2. check(redirectUrl) -> THROWS + + // If the service relies on axios following redirects, it would call: + // fetch(initial) -> and expect axios to return final content. + // Since we mock axios.get, we can't easily simulate "axios follows redirect and then we validate". + // BUT, we are implementing "Manual Redirect Handling". + // So we expect the service to see the 302, and explicitly verify the new URL. + + (axios.get as jest.Mock).mockImplementation(async (url: string, config: any) => { + if (url === initialUrl) { + // Verify that we are disabling redirects in axios + // Use a small delay or check strictly if needed, but for now just return the redirect + return { + status: 302, + headers: { location: redirectUrl }, + data: 'Redirecting...', + config + }; + } + if (url === redirectUrl) { + return { + status: 200, + data: 'Secret', + config + }; + } + return { status: 404 }; + }); + + // We expect the operation to fail because validateWebUrl throws on '127.0.0.1' + await expect(websiteAnalysisService.analyze(initialUrl)) + .rejects + .toThrow('SSRF Blocked'); + + // Verify validateWebUrl was called with the redirect URL + expect(validateWebUrl).toHaveBeenCalledWith(redirectUrl); + }); + + it('should configure axios to not follow redirects automatically', async () => { + const initialUrl = 'http://example.com/redirect'; + + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: '', + }); + + try { + await websiteAnalysisService.analyze(initialUrl); + } catch (e) {} + + const calls = (axios.get as jest.Mock).mock.calls; + expect(calls.length).toBeGreaterThan(0); + const config = calls[0][1]; + expect(config).toHaveProperty('maxRedirects', 0); + }); +});