124 lines
4.5 KiB
JavaScript
124 lines
4.5 KiB
JavaScript
const express = require('express');
|
|
const rateLimit = require('express-rate-limit');
|
|
const path = require('path');
|
|
const { validateUrl } = require('../lib/ssrf');
|
|
|
|
const router = express.Router();
|
|
|
|
// History (per session)
|
|
router.get('/api/history', (req, res) => {
|
|
res.json(req.session.httpHistory || []);
|
|
});
|
|
|
|
router.post('/api/history/add', express.json(), (req, res) => {
|
|
if (!req.session.httpHistory) req.session.httpHistory = [];
|
|
req.session.httpHistory.unshift(req.body);
|
|
if (req.session.httpHistory.length > 30) req.session.httpHistory.length = 30;
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.delete('/api/history', (req, res) => {
|
|
req.session.httpHistory = [];
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Proxy
|
|
const proxyLimiter = rateLimit({
|
|
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip,
|
|
windowMs: 60 * 1000,
|
|
max: parseInt(process.env.PROXY_RATE_LIMIT_MAX) || 60,
|
|
message: { error: 'Слишком много запросов. Попробуйте через минуту.' },
|
|
});
|
|
|
|
router.post('/api/proxy', proxyLimiter, express.json({ limit: '10mb' }), async (req, res) => {
|
|
const { url, method = 'GET', headers = {}, body, timeout = 30000 } = req.body || {};
|
|
|
|
const check = await validateUrl(url);
|
|
if (!check.safe) return res.status(400).json({ error: check.error });
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), Math.min(Number(timeout) || 30000, 60000));
|
|
|
|
let curMethod = method.toUpperCase();
|
|
const fetchOptions = {
|
|
method: curMethod,
|
|
headers: Object.fromEntries(
|
|
Object.entries(headers).filter(([k]) => !['host', 'connection', 'transfer-encoding'].includes(k.toLowerCase()))
|
|
),
|
|
signal: controller.signal,
|
|
redirect: 'manual',
|
|
};
|
|
|
|
if (body != null && !['GET', 'HEAD'].includes(curMethod)) {
|
|
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
}
|
|
|
|
// Follow redirects manually, re-validating each hop against SSRF rules.
|
|
const MAX_REDIRECTS = 5;
|
|
let currentUrl = check.url;
|
|
let response;
|
|
for (let i = 0; ; i++) {
|
|
response = await fetch(currentUrl, fetchOptions);
|
|
if (![301, 302, 303, 307, 308].includes(response.status)) break;
|
|
if (i >= MAX_REDIRECTS) { clearTimeout(timer); return res.status(400).json({ error: 'Слишком много редиректов' }); }
|
|
const loc = response.headers.get('location');
|
|
if (!loc) break;
|
|
let nextUrl;
|
|
try { nextUrl = new URL(loc, currentUrl).href; } catch { clearTimeout(timer); return res.status(400).json({ error: 'Некорректный редирект' }); }
|
|
const rcheck = await validateUrl(nextUrl);
|
|
if (!rcheck.safe) { clearTimeout(timer); return res.status(400).json({ error: 'Редирект на запрещённый адрес: ' + rcheck.error }); }
|
|
currentUrl = rcheck.url;
|
|
// 303 (and 301/302 for non-GET/HEAD) → switch to GET without body, per HTTP semantics
|
|
if (response.status === 303 || (response.status < 307 && !['GET', 'HEAD'].includes(curMethod))) {
|
|
curMethod = 'GET';
|
|
fetchOptions.method = 'GET';
|
|
delete fetchOptions.body;
|
|
}
|
|
}
|
|
clearTimeout(timer);
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
const responseHeaders = {};
|
|
response.headers.forEach((value, key) => { responseHeaders[key] = value; });
|
|
|
|
const MAX_SIZE = 10 * 1024 * 1024;
|
|
const reader = response.body?.getReader();
|
|
const chunks = [];
|
|
let totalSize = 0;
|
|
let truncated = false;
|
|
|
|
if (reader) {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
totalSize += value.length;
|
|
if (totalSize > MAX_SIZE) { truncated = true; break; }
|
|
chunks.push(value);
|
|
}
|
|
}
|
|
|
|
const buffer = Buffer.concat(chunks.map(c => Buffer.from(c)));
|
|
const responseBody = buffer.toString('utf-8');
|
|
|
|
res.json({
|
|
status: response.status, statusText: response.statusText,
|
|
headers: responseHeaders, body: responseBody,
|
|
time: elapsed, size: totalSize, truncated, url: response.url,
|
|
});
|
|
} catch (err) {
|
|
const elapsed = Date.now() - startTime;
|
|
if (err.name === 'AbortError') return res.status(408).json({ error: 'Таймаут запроса', time: elapsed });
|
|
res.status(500).json({ error: err.message, time: elapsed });
|
|
}
|
|
});
|
|
|
|
// Page
|
|
router.get('/httpclient', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '..', 'public', 'httpclient.html'));
|
|
});
|
|
|
|
module.exports = router;
|