security: round 3 hardening (CSRF double-submit, TRX MITM, container hardening)

This commit is contained in:
ZOMBIIIIIII
2026-05-12 01:47:58 +03:00
parent c8bc40af97
commit 8dc0855827
37 changed files with 1852 additions and 318 deletions

View File

@@ -1,9 +1,19 @@
import { NextFunction, Request, Response, Router } from 'express';
import { env } from '../config/env';
import { logger } from '../lib/logger';
const router = Router();
const RELAY_API_URL = 'https://api.relay.link';
const ALLOWED_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
const RELAY_TIMEOUT_MS = 20_000;
// Whitelist: GET-paths + allowed `/execute/<action>` actions.
// Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check.
const ALLOWED_GET_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
const ALLOWED_EXECUTE_ACTIONS = new Set([
'swap',
'bridge',
// добавлять по мере необходимости
]);
router.use(proxyRelayRequest);
@@ -12,7 +22,19 @@ export default router;
async function proxyRelayRequest(req: Request, res: Response, next: NextFunction) {
try {
const relayPath = req.path;
if (!relayPath.startsWith('/execute/') && !ALLOWED_PATHS.has(relayPath)) {
// Whitelist matching — никакого freeform после `/execute/`.
let allowed = false;
if (ALLOWED_GET_PATHS.has(relayPath)) {
allowed = true;
} else if (relayPath.startsWith('/execute/')) {
const action = relayPath.slice('/execute/'.length);
// action: только alphanumeric, никаких слешей/дотов
if (/^[a-z][a-z0-9-]{0,32}$/i.test(action) && ALLOWED_EXECUTE_ACTIONS.has(action)) {
allowed = true;
}
}
if (!allowed) {
res.status(404).json({ success: false, error: 'Relay endpoint not allowed' });
return;
}
@@ -24,29 +46,55 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
value.forEach((item) => relayUrl.searchParams.append(key, String(item)));
return;
}
if (typeof value !== 'undefined') {
relayUrl.searchParams.set(key, String(value));
}
});
const response = await fetch(relayUrl.toString(), {
method: req.method,
headers: {
Accept: 'application/json',
...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}),
},
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
});
// Explicit timeout via AbortController — иначе hang upstream'а держит request indefinitely.
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS);
const contentType = response.headers.get('content-type') ?? 'application/json';
const payload = await response.text();
let upstream: globalThis.Response;
try {
upstream = await fetch(relayUrl.toString(), {
method: req.method,
headers: {
Accept: 'application/json',
...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}),
},
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
signal: controller.signal,
});
} finally {
clearTimeout(t);
}
res.status(response.status);
res.type(contentType);
res.send(payload);
} catch (error) {
next(error);
// Force JSON content-type — иначе compromised upstream может вернуть text/html
// → reflected XSS если frontend рендерит ответ напрямую.
res.status(upstream.status);
res.type('application/json');
const text = await upstream.text();
if (!upstream.ok) {
logger.warn(`Relay upstream ${upstream.status}: ${text.slice(0, 200)}`);
res.json({ success: false, error: 'Relay upstream error' });
return;
}
// Send raw text если это валидный JSON, иначе обернём
try {
res.send(text);
} catch {
res.json({ success: false, error: 'Relay returned non-JSON' });
}
} catch (error: any) {
if (error?.name === 'AbortError') {
res.status(504).json({ success: false, error: 'Relay request timeout' });
return;
}
logger.error(`Relay proxy failed: ${error?.stack || error?.message}`);
res.status(502).json({ success: false, error: 'Relay proxy error' });
}
}