Security audit fixes synced from VPS prod: SSRF/IDOR/WS-auth/session/rate-limits/crash-guards + session journal

This commit is contained in:
treamz 2026-07-24 23:34:29 +03:00
parent d997bad179
commit 626a28f750
19 changed files with 367 additions and 107 deletions

17
CLAUDE.md Normal file
View File

@ -0,0 +1,17 @@
# CLAUDE.md — WA Dev Tools (wadevelop.ru)
> 🕒 **Начиная новую сессию**, сначала загляни в [`docs/SESSION_LOG.md`](docs/SESSION_LOG.md) —
> там хронология сессий и инструкции «как продолжить» (последние решения, открытые вопросы,
> незавершённые шаги). Итоги каждой сессии фиксируются туда (skill `/session-handoff`).
## Кратко о проекте
WA Dev Tools — платформа инструментов для веб-разработчика (Node.js/Express + AdminJS, БД MySQL).
- **Боевой прод:** VPS `195.140.146.84` (FirstVDS), Docker-контейнер `wadevelop`, код
`/opt/sites/www/wadevelop.ru/`. Домен `wadevelop.ru` → NPM (openresty) → контейнер.
- **⚠️ rpi4 (192.168.31.200, `/mnt/webdata/www/images.wadevelop.ru`) — ДВОЙНИК/dev**, к домену
НЕ подключён. Туда НЕ деплоить.
- **Деплой:** скилл `/deploy-wadevelop` (SFTP-заливка + `docker restart wadevelop`).
- Эта папка (`K:\Claude\wadevelop.ru`) — локальная копия для разработки (склонирована с rpi4,
местами расходится с VPS — сверяйся с актуальной версией на VPS перед правкой серверного кода).

7
docs/SESSION_LOG.md Normal file
View File

@ -0,0 +1,7 @@
# Журнал сессий
Хронология рабочих сессий (новые сверху). Каждая запись — самодостаточный файл в `sessions/`
с итогами, решениями, открытыми вопросами и инструкцией «как продолжить». Ведётся скиллом
`/session-handoff`.
- [2026-07-24 17:43 — Локальная копия, снятие лимитов /compress, скилл деплоя под VPS](sessions/2026-07-24-1743.md)

View File

@ -0,0 +1,36 @@
# 2026-07-24 17:43 — Локальная копия, снятие лимитов /compress, скилл деплоя под VPS
**Сделано:**
- Скачали проект WA Dev Tools с сервера в локальную папку `K:\Claude\wadevelop.ru` (код + `.git` + `npm install`, 870 пакетов). node_modules/uploads/downloads не тянули.
- Настроили git локально: `core.autocrlf=false`, `filemode=false` (Windows↔Linux), identity treamz, origin → Gitea rpi4:3002.
- **Сняли лимиты в инструменте `/compress` для админа** (`treamz@gmail.com`, `role=admin`): без лимита размера файла, без лимита количества (было 50), без rate-limit (было 30/мин). Применено и проверено на **боевом проде** — работает.
- **Ключевое открытие топологии:** боевой `wadevelop.ru` живёт НЕ на rpi4, а на VPS `195.140.146.84` (контейнер `wadevelop`). rpi4 — двойник, к домену не подключён.
- Создали и затем **переписали скилл `/deploy-wadevelop` под VPS** (SFTP + `docker restart`).
**Изменённые / созданные файлы:**
- [routes/compress.js](../../routes/compress.js) — admin-логика: `uploadUser`/`uploadAdmin`, `uploadArray`/`uploadSingle`, `limiter.skip = isAdmin`. (Правка залита на VPS-версию файла; в этом локальном репо — rpi4-вариант, коммиты `4259c3d`, `d997bad`.)
- [public/compress.html](../../public/compress.html) — `/auth/me` → admin-детект, `MAX_SIZE=Infinity` для админа, раздельная диагностика отсева (формат vs размер), подпись «без лимитов», `id="limitsHint"`.
- Скилл `C:\Users\tream\.claude\skills\deploy-wadevelop\SKILL.md` — переписан под VPS.
- Память `C:\Users\tream\.claude\projects\C--Users-tream\memory\project-wa-dev-tools.md` — зафиксирован VPS-прод и rpi4-двойник.
**Решения:**
- **Источник правды для прода — VPS `195.140.146.84`** (по решению пользователя). rpi4 — двойник/dev.
- Деплой на VPS — **прямой заливкой по SFTP + `docker restart wadevelop`**, НЕ через git (на VPS origin = `localhost:3002`, недоступен; репо на коммите `3061016` + локальные правки).
- Определение админа — по `req.session.user.role === 'admin'` (сервер) и `fetch('/auth/me').role` (клиент); роль хранится в БД `wa_tools`.
- Статику (`public/*`) VPS подхватывает без рестарта; серверный код (`*.js`, `.env`) — только после `docker restart wadevelop`.
**Открытые вопросы / следующие шаги:**
- [ ] Две расходящиеся копии кода: VPS-прод (`3061016` + `server.js` от 3 апр, папка `works/`) и rpi4/локалка (`87f8c55` с prod-правками). Свести в один git при желании.
- [ ] Локальная копия `K:\Claude\wadevelop.ru` склонирована с rpi4 — местами расходится с VPS. Возможна пересборка с VPS-кода как основы.
- [ ] Решить судьбу rpi4-двойника (оставить как dev / погасить).
- [ ] (мелочь) В логах контейнера warning `ERR_ERL_KEY_GEN_IPV6` от express-rate-limit v8 (keyGenerator + IPv6). Не критично.
**Как продолжить (для чистой сессии):**
- Контекст: прочитать память `project-wa-dev-tools.md`, `server-195-140-146-84.md`, `server-rpi4.md`. Понять: **прод = VPS 195.140.146.84 / контейнер `wadevelop` / `/opt/sites/www/wadevelop.ru/`**; rpi4 — двойник.
- Деплой: скилл `/deploy-wadevelop` (SFTP-заливка + `docker restart wadevelop`). НЕ деплоить на rpi4.
- SSH: `python3 C:/Users/tream/.claude/ssh_cmd.py root@195.140.146.84 "<cmd>"` (именно python3 — paramiko там).
- Проверка прода: `curl -s -o /dev/null -w "%{http_code}\n" https://wadevelop.ru/` (ждём 200); `docker logs wadevelop --tail 8`.
**Ссылки:**
- Прод: https://wadevelop.ru — VPS 195.140.146.84 (FirstVDS, treamz.fvds.ru), контейнер `wadevelop`
- Двойник: rpi4 192.168.31.200, `/mnt/webdata/www/images.wadevelop.ru`, Gitea rpi4:3002 (`treamz/wa-dev-tools`)

View File

@ -218,8 +218,9 @@ async function setupAdmin(app) {
// Middleware: check main session for admin role before AdminJS // Middleware: check main session for admin role before AdminJS
app.use(adminJs.options.rootPath, (req, res, next) => { app.use(adminJs.options.rootPath, (req, res, next) => {
// Allow AdminJS static assets (js, css, etc.) // Allow only AdminJS's own bundled frontend assets — NOT arbitrary paths ending in .js/.css
if (req.path.match(/\.(js|css|png|svg|ico|map|woff|woff2|ttf)$/)) return next(); // (a data endpoint ending in such a suffix would otherwise bypass the role check).
if (req.path.startsWith('/frontend/') && /\.(js|css|png|svg|ico|map|woff2?|ttf)$/.test(req.path)) return next();
if (req.session && req.session.user && req.session.user.role === 'admin') { if (req.session && req.session.user && req.session.user.role === 'admin') {
return next(); return next();

View File

@ -12,9 +12,19 @@ const logger = pino({
} : undefined, } : undefined,
}); });
const MAX_LOG_BYTES = 5 * 1024 * 1024; // rotate at 5MB, keep one previous file
function logToFile(text) { function logToFile(text) {
const ts = new Date().toISOString(); const ts = new Date().toISOString();
fs.appendFile(LOG_FILE, `[${ts}] ${text}\n`, () => {}); const line = `[${ts}] ${text}\n`;
fs.stat(LOG_FILE, (statErr, st) => {
if (!statErr && st.size > MAX_LOG_BYTES) {
try { fs.renameSync(LOG_FILE, LOG_FILE + '.1'); } catch {}
}
fs.appendFile(LOG_FILE, line, (err) => {
if (err) logger.warn({ error: err.message }, 'logToFile failed');
});
});
} }
module.exports = { module.exports = {

View File

@ -22,16 +22,25 @@ db.exec(`
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
started_at INTEGER, started_at INTEGER,
finished_at INTEGER finished_at INTEGER,
user_id TEXT
) )
`); `);
db.exec(`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(type)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(type)`);
// Migrate pre-existing DBs: add user_id column if missing.
try { db.exec(`ALTER TABLE jobs ADD COLUMN user_id TEXT`); } catch {}
// Reap jobs stuck in 'processing' after a crash/restart (never reaped by cleanup otherwise).
try {
const t = Date.now();
db.prepare(`UPDATE jobs SET status = 'error', error = 'Прервано перезапуском сервиса', finished_at = ?, updated_at = ? WHERE status = 'processing'`).run(t, t);
} catch {}
// Prepared statements // Prepared statements
const stmts = { const stmts = {
insert: db.prepare(`INSERT INTO jobs (id, type, status, payload, progress, created_at, updated_at) VALUES (?, ?, 'pending', ?, 0, ?, ?)`), insert: db.prepare(`INSERT INTO jobs (id, type, status, payload, progress, created_at, updated_at, user_id) VALUES (?, ?, 'pending', ?, 0, ?, ?, ?)`),
get: db.prepare(`SELECT * FROM jobs WHERE id = ?`), get: db.prepare(`SELECT * FROM jobs WHERE id = ?`),
updateStatus: db.prepare(`UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?`), updateStatus: db.prepare(`UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?`),
updateProgress: db.prepare(`UPDATE jobs SET progress = ?, updated_at = ? WHERE id = ?`), updateProgress: db.prepare(`UPDATE jobs SET progress = ?, updated_at = ? WHERE id = ?`),
@ -48,10 +57,10 @@ function generateId(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
} }
function addJob(type, payload) { function addJob(type, payload, userId) {
const id = generateId(type); const id = generateId(type);
const now = Date.now(); const now = Date.now();
stmts.insert.run(id, type, JSON.stringify(payload), now, now); stmts.insert.run(id, type, JSON.stringify(payload), now, now, userId != null ? String(userId) : null);
log.info(`Job added: ${id}`, { type }); log.info(`Job added: ${id}`, { type });
return id; return id;
} }

View File

@ -1,6 +1,14 @@
const session = require('express-session'); const session = require('express-session');
const MySQLStore = require('express-mysql-session')(session); const MySQLStore = require('express-mysql-session')(session);
// Fail fast instead of silently starting with insecure defaults.
if (!process.env.SESSION_SECRET) {
throw new Error('SESSION_SECRET не задан — установите его в .env');
}
if (!process.env.DB_PASSWORD) {
throw new Error('DB_PASSWORD не задан — установите его в .env');
}
const store = new MySQLStore({ const store = new MySQLStore({
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'wa_tools', user: process.env.DB_USER || 'wa_tools',
@ -14,7 +22,7 @@ const store = new MySQLStore({
module.exports = session({ module.exports = session({
key: 'wa_dev_tools', key: 'wa_dev_tools',
secret: process.env.SESSION_SECRET || 'change-me-in-env', secret: process.env.SESSION_SECRET,
store, store,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,

View File

@ -1,30 +1,64 @@
const { URL } = require('url'); const { URL } = require('url');
const dns = require('dns'); const dns = require('dns').promises;
const net = require('net'); const net = require('net');
const BLOCKED_RANGES = [ // ---- IPv4 private / reserved ranges ----
// IPv4 private const V4_BLOCKED = [
/^10\./, /^10\./,
/^172\.(1[6-9]|2\d|3[01])\./, /^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./, /^192\.168\./,
/^127\./, /^127\./,
/^0\./, /^0\./,
// Link-local /^169\.254\./, // link-local (incl. cloud metadata 169.254.169.254)
/^169\.254\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // CGNAT 100.64.0.0/10
// Loopback IPv6
/^::1$/,
/^fc/i,
/^fd/i,
/^fe80/i,
]; ];
function isPrivateIP(ip) { function isPrivateV4(ip) {
return BLOCKED_RANGES.some(re => re.test(ip)); return V4_BLOCKED.some((re) => re.test(ip));
}
// Convert an IPv4-mapped IPv6 address to dotted IPv4, or null.
// Handles both "::ffff:127.0.0.1" and hex form "::ffff:7f00:1".
function mappedToV4(ip) {
const m = ip.match(/^::ffff:([0-9a-f.:]+)$/i);
if (!m) return null;
const tail = m[1];
if (tail.includes('.')) return tail;
const parts = tail.split(':');
if (parts.length === 2) {
const hi = parseInt(parts[0], 16);
const lo = parseInt(parts[1], 16);
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
return [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.');
}
return null;
}
// Returns true if the address is private/reserved (or unrecognised → fail-closed).
function isPrivateIP(ipRaw) {
if (!ipRaw) return true;
const ip = String(ipRaw).replace(/^\[|\]$/g, '').toLowerCase();
const ver = net.isIP(ip);
if (ver === 4) return isPrivateV4(ip);
if (ver === 6) {
const asV4 = mappedToV4(ip);
if (asV4) return isPrivateV4(asV4);
if (ip === '::1' || ip === '::') return true; // loopback / unspecified
if (/^(fc|fd)/.test(ip)) return true; // ULA fc00::/7
if (/^fe[89ab]/.test(ip)) return true; // link-local fe80::/10
return false;
}
// Not a bare IP literal — caller should not pass hostnames here; treat as unsafe.
return true;
} }
/** /**
* Validate URL is safe for server-side requests (no SSRF) * Validate URL is safe for server-side requests (no SSRF).
* Returns { safe: true, url } or { safe: false, error } * Resolves BOTH A and AAAA and fails CLOSED on resolution errors.
* Returns { safe: true, url, addresses } or { safe: false, error }.
*/ */
async function validateUrl(rawUrl) { async function validateUrl(rawUrl) {
if (!rawUrl || typeof rawUrl !== 'string') { if (!rawUrl || typeof rawUrl !== 'string') {
@ -42,39 +76,38 @@ async function validateUrl(rawUrl) {
return { safe: false, error: 'Поддерживаются только http и https' }; return { safe: false, error: 'Поддерживаются только http и https' };
} }
const hostname = parsed.hostname; // hostname without IPv6 brackets
const host = parsed.hostname.replace(/^\[|\]$/g, '');
// Block direct IP access to private ranges // Direct IP literal (v4 or v6)
if (net.isIP(hostname)) { if (net.isIP(host)) {
if (isPrivateIP(hostname)) { if (isPrivateIP(host)) {
return { safe: false, error: 'Доступ к внутренним адресам запрещён' }; return { safe: false, error: 'Доступ к внутренним адресам запрещён' };
} }
return { safe: true, url: rawUrl }; return { safe: true, url: rawUrl };
} }
// Block common internal hostnames // Common internal hostnames
const lower = hostname.toLowerCase(); const lower = host.toLowerCase();
if (lower === 'localhost' || lower.endsWith('.local') || lower.endsWith('.internal')) { if (lower === 'localhost' || lower.endsWith('.local') || lower.endsWith('.internal') || lower.endsWith('.localhost')) {
return { safe: false, error: 'Доступ к внутренним адресам запрещён' }; return { safe: false, error: 'Доступ к внутренним адресам запрещён' };
} }
// DNS resolve and check IP // Resolve BOTH A and AAAA; fail CLOSED if nothing resolves.
return new Promise((resolve) => { let addrs = [];
dns.resolve4(hostname, (err, addresses) => { const [v4, v6] = await Promise.allSettled([dns.resolve4(host), dns.resolve6(host)]);
if (err || !addresses || addresses.length === 0) { if (v4.status === 'fulfilled') addrs = addrs.concat(v4.value);
// Allow through — DNS might fail but fetch will handle it if (v6.status === 'fulfilled') addrs = addrs.concat(v6.value);
resolve({ safe: true, url: rawUrl });
return; if (addrs.length === 0) {
} return { safe: false, error: 'Не удалось разрешить имя хоста' };
for (const addr of addresses) { }
if (isPrivateIP(addr)) { for (const addr of addrs) {
resolve({ safe: false, error: 'Доступ к внутренним адресам запрещён' }); if (isPrivateIP(addr)) {
return; return { safe: false, error: 'Доступ к внутренним адресам запрещён' };
} }
} }
resolve({ safe: true, url: rawUrl }); return { safe: true, url: rawUrl, addresses: addrs };
});
});
} }
module.exports = { validateUrl, isPrivateIP }; module.exports = { validateUrl, isPrivateIP };

View File

@ -1,12 +1,36 @@
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const sessionMiddleware = require('./session');
const queue = require('./queue');
const log = require('./logger'); const log = require('./logger');
let wss = null; let wss = null;
// Map jobId → Set of connected clients // Map jobId → Set of connected clients
const subscribers = new Map(); const subscribers = new Map();
// Minimal response stub so express-session can run during the HTTP upgrade handshake.
const resStub = { getHeader() {}, setHeader() {}, writeHead() {}, on() {}, once() {}, removeListener() {}, emit() {}, end() {} };
function attach(server) { function attach(server) {
wss = new WebSocketServer({ server, path: '/ws' }); // noServer + manual upgrade so we can authenticate before accepting the socket.
wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 });
server.on('upgrade', (req, socket, head) => {
let pathname;
try { pathname = new URL(req.url, 'http://localhost').pathname; } catch { pathname = req.url; }
if (pathname !== '/ws') return; // not ours — leave for others / default handling
sessionMiddleware(req, resStub, () => {
if (!req.session || !req.session.user) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws._user = req.session.user;
wss.emit('connection', ws, req);
});
});
});
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
ws._jobId = null; ws._jobId = null;
@ -15,15 +39,18 @@ function attach(server) {
try { try {
const msg = JSON.parse(data); const msg = JSON.parse(data);
if (msg.type === 'subscribe' && msg.jobId) { if (msg.type === 'subscribe' && msg.jobId) {
// Only allow subscribing to one's OWN job (prevents cross-user IDOR)
const job = queue.getJob(String(msg.jobId));
if (!job || String(job.user_id) !== String(ws._user && ws._user.id)) return;
// Unsubscribe from previous // Unsubscribe from previous
if (ws._jobId) { if (ws._jobId) {
const prev = subscribers.get(ws._jobId); const prev = subscribers.get(ws._jobId);
if (prev) { prev.delete(ws); if (prev.size === 0) subscribers.delete(ws._jobId); } if (prev) { prev.delete(ws); if (prev.size === 0) subscribers.delete(ws._jobId); }
} }
// Subscribe to new job // Subscribe to new job
ws._jobId = msg.jobId; ws._jobId = String(msg.jobId);
if (!subscribers.has(msg.jobId)) subscribers.set(msg.jobId, new Set()); if (!subscribers.has(ws._jobId)) subscribers.set(ws._jobId, new Set());
subscribers.get(msg.jobId).add(ws); subscribers.get(ws._jobId).add(ws);
} }
} catch {} } catch {}
}); });
@ -36,11 +63,11 @@ function attach(server) {
}); });
}); });
log.info('WebSocket server attached at /ws'); log.info('WebSocket server attached at /ws (authenticated)');
} }
function notify(jobId, data) { function notify(jobId, data) {
const subs = subscribers.get(jobId); const subs = subscribers.get(String(jobId));
if (!subs || subs.size === 0) return; if (!subs || subs.size === 0) return;
const msg = JSON.stringify({ jobId, ...data }); const msg = JSON.stringify({ jobId, ...data });
for (const ws of subs) { for (const ws of subs) {

View File

@ -1,23 +1,35 @@
const express = require('express'); const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router(); const router = express.Router();
const db = require('../lib/db'); const db = require('../lib/db');
const log = require('../lib/logger');
// Public API — used by landing and dashboard to load dynamic content // Public API — used by landing and dashboard to load dynamic content.
// These run BEFORE authMiddleware and share the small DB pool, so rate-limit them.
const publicLimiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip,
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Слишком много запросов' },
});
// Get all settings as key-value object // Get all settings as key-value object
router.get('/settings', async (req, res) => { router.get('/settings', publicLimiter, async (req, res) => {
try { try {
const [rows] = await db.pool.execute('SELECT setting_key, setting_value FROM settings'); const [rows] = await db.pool.execute('SELECT setting_key, setting_value FROM settings');
const settings = {}; const settings = {};
for (const r of rows) settings[r.setting_key] = r.setting_value; for (const r of rows) settings[r.setting_key] = r.setting_value;
res.json(settings); res.json(settings);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); log.error('api /settings error', { error: err.message });
res.status(500).json({ error: 'Внутренняя ошибка' });
} }
}); });
// Get categories with their tools // Get categories with their tools
router.get('/tools', async (req, res) => { router.get('/tools', publicLimiter, async (req, res) => {
try { try {
const [categories] = await db.pool.execute('SELECT * FROM categories ORDER BY sort_order'); const [categories] = await db.pool.execute('SELECT * FROM categories ORDER BY sort_order');
const [tools] = await db.pool.execute('SELECT * FROM tools WHERE is_enabled = 1 ORDER BY sort_order'); const [tools] = await db.pool.execute('SELECT * FROM tools WHERE is_enabled = 1 ORDER BY sort_order');
@ -29,12 +41,13 @@ router.get('/tools', async (req, res) => {
res.json(result); res.json(result);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); log.error('api /tools error', { error: err.message });
res.status(500).json({ error: 'Внутренняя ошибка' });
} }
}); });
// Get content blocks by section // Get content blocks by section
router.get('/content/:section', async (req, res) => { router.get('/content/:section', publicLimiter, async (req, res) => {
try { try {
const [rows] = await db.pool.execute( const [rows] = await db.pool.execute(
'SELECT block_key, title, body, sort_order FROM content_blocks WHERE section = ? AND is_visible = 1 ORDER BY sort_order', 'SELECT block_key, title, body, sort_order FROM content_blocks WHERE section = ? AND is_visible = 1 ORDER BY sort_order',
@ -42,13 +55,13 @@ router.get('/content/:section', async (req, res) => {
); );
res.json(rows); res.json(rows);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); log.error('api /content error', { error: err.message });
res.status(500).json({ error: 'Внутренняя ошибка' });
} }
}); });
// AI explain regex (proxies to local llama.cpp) // AI explain regex (proxies to local llama.cpp)
const rateLimit = require('express-rate-limit');
const aiLimiter = rateLimit({ const aiLimiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip, keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip,
windowMs: 60000, max: 10, message: { error: 'Слишком много запросов к AI' }, windowMs: 60000, max: 10, message: { error: 'Слишком много запросов к AI' },

View File

@ -7,6 +7,8 @@ const log = require('../lib/logger');
const router = express.Router(); const router = express.Router();
const SALT_ROUNDS = 10; const SALT_ROUNDS = 10;
// Dummy hash compared when the user doesn't exist, so login timing doesn't leak account existence.
const DUMMY_HASH = bcrypt.hashSync('wa-dev-tools-dummy-password', SALT_ROUNDS);
// Rate limiting for auth endpoints // Rate limiting for auth endpoints
const authLimiter = rateLimit({ const authLimiter = rateLimit({
@ -39,7 +41,9 @@ router.post('/login', authLimiter, express.json(), express.urlencoded({ extended
try { try {
const user = await db.findUserByEmail(email.toLowerCase().trim()); const user = await db.findUserByEmail(email.toLowerCase().trim());
if (!user) { // Always run bcrypt (dummy hash when user missing) so response time doesn't reveal account existence.
const valid = await bcrypt.compare(password, (user && user.password_hash) ? user.password_hash : DUMMY_HASH);
if (!user || !valid) {
return res.status(401).json({ error: 'Неверный email или пароль' }); return res.status(401).json({ error: 'Неверный email или пароль' });
} }
@ -47,11 +51,6 @@ router.post('/login', authLimiter, express.json(), express.urlencoded({ extended
return res.status(403).json({ error: 'Аккаунт заблокирован' }); return res.status(403).json({ error: 'Аккаунт заблокирован' });
} }
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: 'Неверный email или пароль' });
}
await db.updateLastLogin(user.id); await db.updateLastLogin(user.id);
// Session regeneration (prevent session fixation) // Session regeneration (prevent session fixation)
@ -116,10 +115,16 @@ router.post('/register', authLimiter, express.json(), express.urlencoded({ exten
const displayName = (name || '').trim() || emailClean.split('@')[0]; const displayName = (name || '').trim() || emailClean.split('@')[0];
const userId = await db.createUser(emailClean, hash, displayName); const userId = await db.createUser(emailClean, hash, displayName);
req.session.user = { id: userId, email: emailClean, name: displayName }; // Regenerate session before authenticating (prevent session fixation), same as login.
req.session.regenerate((err) => {
log.info(`Register: ${emailClean}`); if (err) {
res.json({ ok: true, redirect: '/dashboard' }); log.error('Session regenerate error (register)', { error: err.message });
return res.status(500).json({ error: 'Ошибка сервера' });
}
req.session.user = { id: userId, email: emailClean, name: displayName, role: 'user' };
log.info(`Register: ${emailClean}`);
res.json({ ok: true, redirect: '/dashboard' });
});
} catch (err) { } catch (err) {
log.error('Register error', { error: err.message }); log.error('Register error', { error: err.message });
res.status(500).json({ error: 'Ошибка сервера' }); res.status(500).json({ error: 'Ошибка сервера' });

View File

@ -36,8 +36,10 @@ const maxFileSize = (parseInt(process.env.MAX_FILE_SIZE_MB) || 20) * 1024 * 1024
const maxFiles = parseInt(process.env.MAX_FILES) || 50; const maxFiles = parseInt(process.env.MAX_FILES) || 50;
const imageFileFilter = (req, file, cb) => { const imageFileFilter = (req, file, cb) => {
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/tiff', 'image/gif', 'image/bmp', 'image/svg+xml']; // SVG intentionally excluded: sharp/librsvg would render it and honour external
if (validTypes.includes(file.mimetype) || file.originalname.match(/\.(jpe?g|png|webp|avif|tiff?|gif|bmp|svg|heic|heif)$/i)) cb(null, true); // <image href>/file:// references → SSRF / local-file read / SVG-bomb DoS.
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/tiff', 'image/gif', 'image/bmp'];
if (validTypes.includes(file.mimetype) || file.originalname.match(/\.(jpe?g|png|webp|avif|tiff?|gif|bmp|heic|heif)$/i)) cb(null, true);
else cb(new Error('Неподдерживаемый формат изображения')); else cb(new Error('Неподдерживаемый формат изображения'));
}; };
@ -138,6 +140,14 @@ router.post('/', limiter, uploadArray, async (req, res) => {
const stats = []; const stats = [];
const country = getCountry(req.ip); const country = getCountry(req.ip);
// Bind error/close listeners BEFORE piping — an unhandled 'error' on archive/output
// would otherwise crash the whole process via uncaughtException.
const archiveDone = new Promise((resolve, reject) => {
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
});
try { try {
archive.pipe(output); archive.pipe(output);
@ -192,7 +202,7 @@ router.post('/', limiter, uploadArray, async (req, res) => {
} }
await archive.finalize(); await archive.finalize();
await new Promise((resolve, reject) => { output.on('close', resolve); output.on('error', reject); }); await archiveDone;
res.json({ success: true, downloadUrl: `/download/${archiveName}`, stats }); res.json({ success: true, downloadUrl: `/download/${archiveName}`, stats });
} catch (err) { } catch (err) {

View File

@ -42,20 +42,42 @@ router.post('/api/proxy', proxyLimiter, express.json({ limit: '10mb' }), async (
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.min(Number(timeout) || 30000, 60000)); const timer = setTimeout(() => controller.abort(), Math.min(Number(timeout) || 30000, 60000));
let curMethod = method.toUpperCase();
const fetchOptions = { const fetchOptions = {
method: method.toUpperCase(), method: curMethod,
headers: Object.fromEntries( headers: Object.fromEntries(
Object.entries(headers).filter(([k]) => !['host', 'connection', 'transfer-encoding'].includes(k.toLowerCase())) Object.entries(headers).filter(([k]) => !['host', 'connection', 'transfer-encoding'].includes(k.toLowerCase()))
), ),
signal: controller.signal, signal: controller.signal,
redirect: 'follow', redirect: 'manual',
}; };
if (body != null && !['GET', 'HEAD'].includes(method.toUpperCase())) { if (body != null && !['GET', 'HEAD'].includes(curMethod)) {
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
} }
const response = await fetch(check.url, fetchOptions); // 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); clearTimeout(timer);
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;

View File

@ -61,16 +61,29 @@ async function fetchAndParse(url) {
let response; let response;
try { try {
response = await fetch(url, { // Follow redirects manually, re-validating each hop against SSRF rules.
signal: controller.signal, const MAX_REDIRECTS = 5;
redirect: 'follow', let currentUrl = url;
follow: 5, for (let i = 0; ; i++) {
headers: { response = await fetch(currentUrl, {
'User-Agent': 'Mozilla/5.0 (compatible; WAParser/1.0)', signal: controller.signal,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', redirect: 'manual',
'Accept-Language': 'ru,en;q=0.5', headers: {
}, 'User-Agent': 'Mozilla/5.0 (compatible; WAParser/1.0)',
}); 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'ru,en;q=0.5',
},
});
if (![301, 302, 303, 307, 308].includes(response.status)) break;
if (i >= MAX_REDIRECTS) throw new Error('Слишком много редиректов');
const loc = response.headers.get('location');
if (!loc) break;
let nextUrl;
try { nextUrl = new URL(loc, currentUrl).href; } catch { throw new Error('Некорректный редирект'); }
const rcheck = await validateUrl(nextUrl);
if (!rcheck.safe) throw new Error('Редирект на запрещённый адрес');
currentUrl = rcheck.url;
}
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
@ -85,9 +98,21 @@ async function fetchAndParse(url) {
const contentLength = parseInt(response.headers.get('content-length') || '0', 10); const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
if (contentLength > 2 * 1024 * 1024) throw new Error('Страница слишком большая (>2MB)'); if (contentLength > 2 * 1024 * 1024) throw new Error('Страница слишком большая (>2MB)');
const arrayBuf = await response.arrayBuffer(); // Stream the body and abort as soon as the 2MB cap is exceeded (avoids buffering huge/unbounded responses)
const htmlBuffer = Buffer.from(arrayBuf); const MAX_HTML = 2 * 1024 * 1024;
if (htmlBuffer.length > 2 * 1024 * 1024) throw new Error('Страница слишком большая (>2MB)'); const bodyReader = response.body?.getReader();
const chunks = [];
let total = 0;
if (bodyReader) {
while (true) {
const { done, value } = await bodyReader.read();
if (done) break;
total += value.length;
if (total > MAX_HTML) { try { await bodyReader.cancel(); } catch {} throw new Error('Страница слишком большая (>2MB)'); }
chunks.push(value);
}
}
const htmlBuffer = Buffer.concat(chunks.map((c) => Buffer.from(c)));
const charset = detectCharset(contentType, htmlBuffer); const charset = detectCharset(contentType, htmlBuffer);
let html; let html;
@ -139,7 +164,12 @@ async function fetchAndParse(url) {
cleanDoc.querySelectorAll('script, style, iframe, object, embed').forEach(el => el.remove()); cleanDoc.querySelectorAll('script, style, iframe, object, embed').forEach(el => el.remove());
cleanDoc.querySelectorAll('*').forEach(el => { cleanDoc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => { Array.from(el.attributes).forEach(attr => {
if (attr.name.startsWith('on') || attr.name.startsWith('data-')) el.removeAttribute(attr.name); const n = attr.name.toLowerCase();
if (n.startsWith('on') || n.startsWith('data-')) { el.removeAttribute(attr.name); return; }
// Strip dangerous URL schemes from href/src/action/xlink:href (javascript:, data:, vbscript:)
if (['href', 'src', 'action', 'formaction', 'xlink:href'].includes(n) && /^\s*(javascript|data|vbscript):/i.test(attr.value)) {
el.removeAttribute(attr.name);
}
}); });
}); });
cleanHtml = cleanDoc.body.innerHTML; cleanHtml = cleanDoc.body.innerHTML;

View File

@ -25,6 +25,9 @@ const upload = multer({
const limiter = rateLimit({ const limiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, windowMs: 60000, max: 30, message: { error: 'Слишком много запросов' } }); keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, windowMs: 60000, max: 30, message: { error: 'Слишком много запросов' } });
// Rate-limit ALL pdf routes (several spawn Ghostscript / do heavy pdf-lib work)
router.use(limiter);
// Fix multer filename encoding (Latin-1 → UTF-8 for Cyrillic) // Fix multer filename encoding (Latin-1 → UTF-8 for Cyrillic)
function fixFilename(str) { function fixFilename(str) {
try { try {
@ -75,7 +78,7 @@ router.get('/', (req, res) => {
}); });
// Upload // Upload
router.post('/upload', limiter, upload.array('files', 10), async (req, res) => { router.post('/upload', upload.array('files', 10), async (req, res) => {
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Файлы не загружены' }); if (!req.files || !req.files.length) return res.status(400).json({ error: 'Файлы не загружены' });
const results = []; const results = [];
@ -474,7 +477,9 @@ router.post('/extract-text', express.json(), async (req, res) => {
// PDF to Images (Ghostscript) // PDF to Images (Ghostscript)
router.post('/toImages', express.json(), async (req, res) => { router.post('/toImages', express.json(), async (req, res) => {
const { fileId, format = 'png', dpi = 150 } = req.body; const { fileId, format = 'png' } = req.body;
// Clamp DPI to avoid Ghostscript rendering gigantic bitmaps (memory/disk DoS)
const dpi = Math.min(300, Math.max(36, parseInt(req.body.dpi) || 150));
const f = getFile(fileId); const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' }); if (!f) return res.status(404).json({ error: 'Файл не найден' });
@ -499,10 +504,11 @@ router.post('/toImages', express.json(), async (req, res) => {
const zipPath = path.join(RESULTS_DIR, zipName); const zipPath = path.join(RESULTS_DIR, zipName);
const output = fs.createWriteStream(zipPath); const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', { zlib: { level: 6 } }); const archive = archiver('zip', { zlib: { level: 6 } });
const zipDone = new Promise((resolve, reject) => { output.on('close', resolve); output.on('error', reject); archive.on('error', reject); });
archive.pipe(output); archive.pipe(output);
archive.directory(tmpDir, false); archive.directory(tmpDir, false);
await archive.finalize(); await archive.finalize();
await new Promise(r => output.on('close', r)); await zipDone;
// Cleanup temp dir // Cleanup temp dir
fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f))); fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f)));

View File

@ -61,7 +61,15 @@ router.post('/api/redirect-analyze', redirectLimiter, express.json(), async (req
if (response.status < 300 || response.status >= 400) break; if (response.status < 300 || response.status >= 400) break;
if (!location) break; if (!location) break;
try { currentUrl = new URL(location, currentUrl).href; } catch { 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) { } catch (err) {
clearTimeout(timer); clearTimeout(timer);
chain.push({ url: currentUrl, status: 0, statusText: err.name === 'AbortError' ? 'Timeout' : err.message, location: null, time: Date.now() - stepStart, headers: {} }); chain.push({ url: currentUrl, status: 0, statusText: err.name === 'AbortError' ? 'Timeout' : err.message, location: null, time: Date.now() - stepStart, headers: {} });

View File

@ -55,12 +55,22 @@ const ICON_PATHS = {
bell: 'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0', bell: 'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0',
}; };
// Allow only well-formed CSS colours; anything else (e.g. '"><script>') → default.
function safeColor(input) {
const v = String(input || '').trim();
if (/^#[0-9a-f]{3,8}$/i.test(v)) return v;
if (/^rgba?\(\s*[\d.,\s%]+\)$/i.test(v)) return v;
if (/^hsla?\(\s*[\d.,\s%]+\)$/i.test(v)) return v;
if (/^[a-z]{1,20}$/i.test(v)) return v; // named colour
return '#0054e6';
}
router.post('/api/svg-ai', express.json(), (req, res) => { router.post('/api/svg-ai', express.json(), (req, res) => {
const { keyword, style, size, color } = req.body; const { keyword, style, size, color } = req.body;
const k = (keyword || '').toLowerCase().trim(); const k = (keyword || '').toLowerCase().trim();
const pathD = ICON_PATHS[k] || ICON_PATHS['star']; const pathD = ICON_PATHS[k] || ICON_PATHS['star'];
const s = parseInt(size) || 48; const s = Math.min(1024, Math.max(1, parseInt(size) || 48));
const c = color || '#0054e6'; const c = safeColor(color);
let svgStr; let svgStr;
if (style === 'filled') { if (style === 'filled') {

View File

@ -116,7 +116,7 @@ router.post('/upload', limiter, uploadMiddleware, async (req, res) => {
size: req.file.size, size: req.file.size,
duration: info.duration, duration: info.duration,
info, info,
}); }, req.session.user && req.session.user.id);
// Schedule cleanup of input file after 30 min // Schedule cleanup of input file after 30 min
setTimeout(() => { setTimeout(() => {
@ -133,10 +133,11 @@ router.post('/upload', limiter, uploadMiddleware, async (req, res) => {
}); });
// Convert // Convert
router.post('/convert', express.json(), async (req, res) => { router.post('/convert', limiter, express.json(), async (req, res) => {
const { jobId, mode, format, quality, startTime, endTime } = req.body; const { jobId, mode, format, quality, startTime, endTime } = req.body;
const job = queue.getJob(jobId); const job = queue.getJob(jobId);
if (!job) return res.status(404).json({ error: 'Задача не найдена' }); // 404 (not 403) on ownership mismatch — do not leak existence of others' jobs
if (!job || String(job.user_id) !== String(req.session.user && req.session.user.id)) return res.status(404).json({ error: 'Задача не найдена' });
if (job.status === 'processing') return res.status(409).json({ error: 'Уже обрабатывается' }); if (job.status === 'processing') return res.status(409).json({ error: 'Уже обрабатывается' });
queue.startJob(jobId); queue.startJob(jobId);
@ -221,7 +222,7 @@ router.post('/convert', express.json(), async (req, res) => {
// Progress // Progress
router.get('/progress/:jobId', (req, res) => { router.get('/progress/:jobId', (req, res) => {
const job = queue.getJob(req.params.jobId); const job = queue.getJob(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Not found' }); if (!job || String(job.user_id) !== String(req.session.user && req.session.user.id)) return res.status(404).json({ error: 'Not found' });
// Use live progress if available (more up-to-date) // Use live progress if available (more up-to-date)
const live = liveProgress.get(req.params.jobId); const live = liveProgress.get(req.params.jobId);

View File

@ -34,6 +34,8 @@ app.use(sessionMiddleware);
// Static assets (before auth — always public) // Static assets (before auth — always public)
app.use(express.static('public')); app.use(express.static('public'));
// Works portfolio — static HTML projects
app.use('/works', express.static('works'));
// Auth routes (before auth middleware — public) // Auth routes (before auth middleware — public)
app.use('/auth', require('./routes/auth')); app.use('/auth', require('./routes/auth'));
@ -58,6 +60,10 @@ app.use('/api', apiRouter);
const os = require('os'); const os = require('os');
const startTime = Date.now(); const startTime = Date.now();
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
// Public callers get a minimal response; detailed metrics only for authenticated admins.
const isAdmin = req.session && req.session.user && req.session.user.role === 'admin';
if (!isAdmin) return res.json({ status: 'ok' });
const mem = process.memoryUsage(); const mem = process.memoryUsage();
const queue = require('./lib/queue'); const queue = require('./lib/queue');
const wsInfo = require('./lib/ws'); const wsInfo = require('./lib/ws');
@ -115,12 +121,6 @@ app.use('/pdf', require('./routes/pdf'));
app.use('/favicon', require('./routes/favicon')); app.use('/favicon', require('./routes/favicon'));
app.use(require('./routes/pages')); app.use(require('./routes/pages'));
// Global error handler
app.use((err, req, res, next) => {
log.error('Unhandled error', { error: err.message, stack: err.stack });
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
});
// Start server (async for AdminJS setup) // Start server (async for AdminJS setup)
const PORT = parseInt(process.env.PORT) || 3000; const PORT = parseInt(process.env.PORT) || 3000;
@ -134,6 +134,13 @@ const PORT = parseInt(process.env.PORT) || 3000;
log.error('AdminJS setup failed', { error: err.message }); log.error('AdminJS setup failed', { error: err.message });
} }
// Global error handler — registered AFTER AdminJS so errors thrown inside /admin are caught too.
app.use((err, req, res, next) => {
log.error('Unhandled error', { error: err.message, stack: err.stack });
if (res.headersSent) return next(err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
});
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
log.info(`Server started on http://localhost:${PORT}`); log.info(`Server started on http://localhost:${PORT}`);
}); });