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
45 changes: 37 additions & 8 deletions backend/src/services/websiteAnalysisService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,21 +87,50 @@ class WebsiteAnalysisService {

private async fetchWebsite(url: string): Promise<string> {
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');
}
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}`);
}
}
Expand Down
109 changes: 109 additions & 0 deletions backend/tests/websiteAnalysisService.security.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<html><body>Secret</body></html>',
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: '<html></html>',
});

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);
});
});
Loading