wa-dev-tools/routes/httpclient.js

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;