const express = require('express'); const rateLimit = require('express-rate-limit'); const path = require('path'); const { validateUrl } = require('../lib/ssrf'); const router = express.Router(); const UA_STRINGS = { desktop: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', mobile: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', }; const redirectLimiter = rateLimit({ keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip, windowMs: 60_000, max: 20 }); router.post('/api/redirect-analyze', redirectLimiter, express.json(), async (req, res) => { const { url: rawUrl, userAgent = 'desktop', method = 'GET' } = req.body || {}; const check = await validateUrl(rawUrl); if (!check.safe) return res.status(400).json({ error: check.error }); const uaString = UA_STRINGS[userAgent] || UA_STRINGS.desktop; const MAX_STEPS = 15; const STEP_TIMEOUT = 10_000; const chain = []; const visitedUrls = new Set(); let currentUrl = check.url; let loopDetected = false; const totalStart = Date.now(); for (let step = 0; step < MAX_STEPS; step++) { if (visitedUrls.has(currentUrl)) { loopDetected = true; break; } visitedUrls.add(currentUrl); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), STEP_TIMEOUT); const stepStart = Date.now(); try { const response = await fetch(currentUrl, { method: method.toUpperCase(), redirect: 'manual', signal: controller.signal, headers: { 'User-Agent': uaString, 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7', }, }); clearTimeout(timer); const stepTime = Date.now() - stepStart; const responseHeaders = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); const location = response.headers.get('location') || null; chain.push({ url: currentUrl, status: response.status, statusText: response.statusText, location, time: stepTime, headers: responseHeaders }); if (response.status < 300 || response.status >= 400) break; if (!location) break; let nextUrl; try { nextUrl = new URL(location, currentUrl).href; } catch { break; } // Re-validate every hop before the next request (SSRF via redirect) const rcheck = await validateUrl(nextUrl); if (!rcheck.safe) { chain.push({ url: nextUrl, status: 0, statusText: 'Заблокировано: ' + rcheck.error, location: null, time: 0, headers: {} }); break; } currentUrl = rcheck.url; } catch (err) { clearTimeout(timer); chain.push({ url: currentUrl, status: 0, statusText: err.name === 'AbortError' ? 'Timeout' : err.message, location: null, time: Date.now() - stepStart, headers: {} }); break; } } const totalTime = Date.now() - totalStart; const finalUrl = chain.length > 0 ? chain[chain.length - 1].url : check.url; const issues = []; const redirectSteps = chain.filter(s => s.status >= 300 && s.status < 400); if (redirectSteps.length > 3) issues.push('long_chain'); for (let i = 0; i < chain.length - 1; i++) { if (chain[i].url.startsWith('https://') && chain[i + 1].url.startsWith('http://')) { issues.push('mixed_protocol'); break; } } if (chain.some(s => s.status === 302)) issues.push('302_not_301'); res.json({ chain, final_url: finalUrl, total_time: totalTime, loop_detected: loopDetected, issues }); }); // Page router.get('/redirects', (req, res) => { res.sendFile(path.join(__dirname, '..', 'public', 'redirects.html')); }); module.exports = router;