From 626a28f750431059559c4da43b46ccb245970d17 Mon Sep 17 00:00:00 2001 From: treamz Date: Fri, 24 Jul 2026 23:34:29 +0300 Subject: [PATCH] Security audit fixes synced from VPS prod: SSRF/IDOR/WS-auth/session/rate-limits/crash-guards + session journal --- CLAUDE.md | 17 +++++ docs/SESSION_LOG.md | 7 ++ docs/sessions/2026-07-24-1743.md | 36 ++++++++++ lib/admin.js | 5 +- lib/logger.js | 12 +++- lib/queue.js | 17 +++-- lib/session.js | 10 ++- lib/ssrf.js | 109 ++++++++++++++++++++----------- lib/ws.js | 39 +++++++++-- routes/api.js | 29 +++++--- routes/auth.js | 25 ++++--- routes/compress.js | 16 ++++- routes/httpclient.js | 30 +++++++-- routes/parser.js | 58 ++++++++++++---- routes/pdf.js | 12 +++- routes/redirects.js | 10 ++- routes/svgeditor.js | 14 +++- routes/video.js | 9 +-- server.js | 19 ++++-- 19 files changed, 367 insertions(+), 107 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/SESSION_LOG.md create mode 100644 docs/sessions/2026-07-24-1743.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..509d999 --- /dev/null +++ b/CLAUDE.md @@ -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 перед правкой серверного кода). diff --git a/docs/SESSION_LOG.md b/docs/SESSION_LOG.md new file mode 100644 index 0000000..31f94cb --- /dev/null +++ b/docs/SESSION_LOG.md @@ -0,0 +1,7 @@ +# Журнал сессий + +Хронология рабочих сессий (новые сверху). Каждая запись — самодостаточный файл в `sessions/` +с итогами, решениями, открытыми вопросами и инструкцией «как продолжить». Ведётся скиллом +`/session-handoff`. + +- [2026-07-24 17:43 — Локальная копия, снятие лимитов /compress, скилл деплоя под VPS](sessions/2026-07-24-1743.md) diff --git a/docs/sessions/2026-07-24-1743.md b/docs/sessions/2026-07-24-1743.md new file mode 100644 index 0000000..439c1d2 --- /dev/null +++ b/docs/sessions/2026-07-24-1743.md @@ -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 ""` (именно 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`) diff --git a/lib/admin.js b/lib/admin.js index 1211611..410e792 100644 --- a/lib/admin.js +++ b/lib/admin.js @@ -218,8 +218,9 @@ async function setupAdmin(app) { // Middleware: check main session for admin role before AdminJS app.use(adminJs.options.rootPath, (req, res, next) => { - // Allow AdminJS static assets (js, css, etc.) - if (req.path.match(/\.(js|css|png|svg|ico|map|woff|woff2|ttf)$/)) return next(); + // Allow only AdminJS's own bundled frontend assets — NOT arbitrary paths ending in .js/.css + // (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') { return next(); diff --git a/lib/logger.js b/lib/logger.js index b9a6d43..15afccd 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -12,9 +12,19 @@ const logger = pino({ } : undefined, }); +const MAX_LOG_BYTES = 5 * 1024 * 1024; // rotate at 5MB, keep one previous file + function logToFile(text) { 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 = { diff --git a/lib/queue.js b/lib/queue.js index 5f4fa68..c9df0a5 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -22,16 +22,25 @@ db.exec(` created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, 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_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 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 = ?`), updateStatus: db.prepare(`UPDATE jobs SET status = ?, 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)}`; } -function addJob(type, payload) { +function addJob(type, payload, userId) { const id = generateId(type); 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 }); return id; } diff --git a/lib/session.js b/lib/session.js index dc21727..735d9cc 100644 --- a/lib/session.js +++ b/lib/session.js @@ -1,6 +1,14 @@ const session = require('express-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({ host: process.env.DB_HOST || 'localhost', user: process.env.DB_USER || 'wa_tools', @@ -14,7 +22,7 @@ const store = new MySQLStore({ module.exports = session({ key: 'wa_dev_tools', - secret: process.env.SESSION_SECRET || 'change-me-in-env', + secret: process.env.SESSION_SECRET, store, resave: false, saveUninitialized: false, diff --git a/lib/ssrf.js b/lib/ssrf.js index 44804b4..214d5c8 100644 --- a/lib/ssrf.js +++ b/lib/ssrf.js @@ -1,30 +1,64 @@ const { URL } = require('url'); -const dns = require('dns'); +const dns = require('dns').promises; const net = require('net'); -const BLOCKED_RANGES = [ - // IPv4 private +// ---- IPv4 private / reserved ranges ---- +const V4_BLOCKED = [ /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^127\./, /^0\./, - // Link-local - /^169\.254\./, - // Loopback IPv6 - /^::1$/, - /^fc/i, - /^fd/i, - /^fe80/i, + /^169\.254\./, // link-local (incl. cloud metadata 169.254.169.254) + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // CGNAT 100.64.0.0/10 ]; -function isPrivateIP(ip) { - return BLOCKED_RANGES.some(re => re.test(ip)); +function isPrivateV4(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) - * Returns { safe: true, url } or { safe: false, error } + * Validate URL is safe for server-side requests (no SSRF). + * Resolves BOTH A and AAAA and fails CLOSED on resolution errors. + * Returns { safe: true, url, addresses } or { safe: false, error }. */ async function validateUrl(rawUrl) { if (!rawUrl || typeof rawUrl !== 'string') { @@ -42,39 +76,38 @@ async function validateUrl(rawUrl) { 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 - if (net.isIP(hostname)) { - if (isPrivateIP(hostname)) { + // Direct IP literal (v4 or v6) + if (net.isIP(host)) { + if (isPrivateIP(host)) { return { safe: false, error: 'Доступ к внутренним адресам запрещён' }; } return { safe: true, url: rawUrl }; } - // Block common internal hostnames - const lower = hostname.toLowerCase(); - if (lower === 'localhost' || lower.endsWith('.local') || lower.endsWith('.internal')) { + // Common internal hostnames + const lower = host.toLowerCase(); + if (lower === 'localhost' || lower.endsWith('.local') || lower.endsWith('.internal') || lower.endsWith('.localhost')) { return { safe: false, error: 'Доступ к внутренним адресам запрещён' }; } - // DNS resolve and check IP - return new Promise((resolve) => { - dns.resolve4(hostname, (err, addresses) => { - if (err || !addresses || addresses.length === 0) { - // Allow through — DNS might fail but fetch will handle it - resolve({ safe: true, url: rawUrl }); - return; - } - for (const addr of addresses) { - if (isPrivateIP(addr)) { - resolve({ safe: false, error: 'Доступ к внутренним адресам запрещён' }); - return; - } - } - resolve({ safe: true, url: rawUrl }); - }); - }); + // Resolve BOTH A and AAAA; fail CLOSED if nothing resolves. + let addrs = []; + const [v4, v6] = await Promise.allSettled([dns.resolve4(host), dns.resolve6(host)]); + if (v4.status === 'fulfilled') addrs = addrs.concat(v4.value); + if (v6.status === 'fulfilled') addrs = addrs.concat(v6.value); + + if (addrs.length === 0) { + return { safe: false, error: 'Не удалось разрешить имя хоста' }; + } + for (const addr of addrs) { + if (isPrivateIP(addr)) { + return { safe: false, error: 'Доступ к внутренним адресам запрещён' }; + } + } + return { safe: true, url: rawUrl, addresses: addrs }; } module.exports = { validateUrl, isPrivateIP }; diff --git a/lib/ws.js b/lib/ws.js index 22e87fa..d190a31 100644 --- a/lib/ws.js +++ b/lib/ws.js @@ -1,12 +1,36 @@ const { WebSocketServer } = require('ws'); +const sessionMiddleware = require('./session'); +const queue = require('./queue'); const log = require('./logger'); let wss = null; // Map jobId → Set of connected clients 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) { - 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) => { ws._jobId = null; @@ -15,15 +39,18 @@ function attach(server) { try { const msg = JSON.parse(data); 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 if (ws._jobId) { const prev = subscribers.get(ws._jobId); if (prev) { prev.delete(ws); if (prev.size === 0) subscribers.delete(ws._jobId); } } // Subscribe to new job - ws._jobId = msg.jobId; - if (!subscribers.has(msg.jobId)) subscribers.set(msg.jobId, new Set()); - subscribers.get(msg.jobId).add(ws); + ws._jobId = String(msg.jobId); + if (!subscribers.has(ws._jobId)) subscribers.set(ws._jobId, new Set()); + subscribers.get(ws._jobId).add(ws); } } 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) { - const subs = subscribers.get(jobId); + const subs = subscribers.get(String(jobId)); if (!subs || subs.size === 0) return; const msg = JSON.stringify({ jobId, ...data }); for (const ws of subs) { diff --git a/routes/api.js b/routes/api.js index c501143..bf5b78a 100644 --- a/routes/api.js +++ b/routes/api.js @@ -1,23 +1,35 @@ const express = require('express'); +const rateLimit = require('express-rate-limit'); const router = express.Router(); 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 -router.get('/settings', async (req, res) => { +router.get('/settings', publicLimiter, async (req, res) => { try { const [rows] = await db.pool.execute('SELECT setting_key, setting_value FROM settings'); const settings = {}; for (const r of rows) settings[r.setting_key] = r.setting_value; res.json(settings); } 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 -router.get('/tools', async (req, res) => { +router.get('/tools', publicLimiter, async (req, res) => { try { 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'); @@ -29,12 +41,13 @@ router.get('/tools', async (req, res) => { res.json(result); } 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 -router.get('/content/:section', async (req, res) => { +router.get('/content/:section', publicLimiter, async (req, res) => { try { 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', @@ -42,13 +55,13 @@ router.get('/content/:section', async (req, res) => { ); res.json(rows); } 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) -const rateLimit = require('express-rate-limit'); const aiLimiter = rateLimit({ 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' }, diff --git a/routes/auth.js b/routes/auth.js index caed283..9d92ab2 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -7,6 +7,8 @@ const log = require('../lib/logger'); const router = express.Router(); 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 const authLimiter = rateLimit({ @@ -39,7 +41,9 @@ router.post('/login', authLimiter, express.json(), express.urlencoded({ extended try { 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 или пароль' }); } @@ -47,11 +51,6 @@ router.post('/login', authLimiter, express.json(), express.urlencoded({ extended 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); // 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 userId = await db.createUser(emailClean, hash, displayName); - req.session.user = { id: userId, email: emailClean, name: displayName }; - - log.info(`Register: ${emailClean}`); - res.json({ ok: true, redirect: '/dashboard' }); + // Regenerate session before authenticating (prevent session fixation), same as login. + req.session.regenerate((err) => { + if (err) { + 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) { log.error('Register error', { error: err.message }); res.status(500).json({ error: 'Ошибка сервера' }); diff --git a/routes/compress.js b/routes/compress.js index a0cad9a..3afa520 100644 --- a/routes/compress.js +++ b/routes/compress.js @@ -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 imageFileFilter = (req, file, cb) => { - const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/tiff', 'image/gif', 'image/bmp', 'image/svg+xml']; - if (validTypes.includes(file.mimetype) || file.originalname.match(/\.(jpe?g|png|webp|avif|tiff?|gif|bmp|svg|heic|heif)$/i)) cb(null, true); + // SVG intentionally excluded: sharp/librsvg would render it and honour external + // /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('Неподдерживаемый формат изображения')); }; @@ -138,6 +140,14 @@ router.post('/', limiter, uploadArray, async (req, res) => { const stats = []; 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 { archive.pipe(output); @@ -192,7 +202,7 @@ router.post('/', limiter, uploadArray, async (req, res) => { } 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 }); } catch (err) { diff --git a/routes/httpclient.js b/routes/httpclient.js index b709695..a2c5f32 100644 --- a/routes/httpclient.js +++ b/routes/httpclient.js @@ -42,20 +42,42 @@ router.post('/api/proxy', proxyLimiter, express.json({ limit: '10mb' }), async ( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), Math.min(Number(timeout) || 30000, 60000)); + let curMethod = method.toUpperCase(); const fetchOptions = { - method: method.toUpperCase(), + method: curMethod, headers: Object.fromEntries( Object.entries(headers).filter(([k]) => !['host', 'connection', 'transfer-encoding'].includes(k.toLowerCase())) ), 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); } - 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); const elapsed = Date.now() - startTime; diff --git a/routes/parser.js b/routes/parser.js index f0c477f..88b9308 100644 --- a/routes/parser.js +++ b/routes/parser.js @@ -61,16 +61,29 @@ async function fetchAndParse(url) { let response; try { - response = await fetch(url, { - signal: controller.signal, - redirect: 'follow', - follow: 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', - }, - }); + // Follow redirects manually, re-validating each hop against SSRF rules. + const MAX_REDIRECTS = 5; + let currentUrl = url; + for (let i = 0; ; i++) { + response = await fetch(currentUrl, { + signal: controller.signal, + redirect: 'manual', + 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 { clearTimeout(timeout); } @@ -85,9 +98,21 @@ async function fetchAndParse(url) { const contentLength = parseInt(response.headers.get('content-length') || '0', 10); if (contentLength > 2 * 1024 * 1024) throw new Error('Страница слишком большая (>2MB)'); - const arrayBuf = await response.arrayBuffer(); - const htmlBuffer = Buffer.from(arrayBuf); - if (htmlBuffer.length > 2 * 1024 * 1024) throw new Error('Страница слишком большая (>2MB)'); + // Stream the body and abort as soon as the 2MB cap is exceeded (avoids buffering huge/unbounded responses) + const MAX_HTML = 2 * 1024 * 1024; + 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); let html; @@ -139,7 +164,12 @@ async function fetchAndParse(url) { cleanDoc.querySelectorAll('script, style, iframe, object, embed').forEach(el => el.remove()); cleanDoc.querySelectorAll('*').forEach(el => { 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; diff --git a/routes/pdf.js b/routes/pdf.js index 67e36e7..cdc017d 100644 --- a/routes/pdf.js +++ b/routes/pdf.js @@ -25,6 +25,9 @@ const upload = multer({ 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: 'Слишком много запросов' } }); +// 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) function fixFilename(str) { try { @@ -75,7 +78,7 @@ router.get('/', (req, res) => { }); // 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: 'Файлы не загружены' }); const results = []; @@ -474,7 +477,9 @@ router.post('/extract-text', express.json(), async (req, res) => { // PDF to Images (Ghostscript) 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); 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 output = fs.createWriteStream(zipPath); 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.directory(tmpDir, false); await archive.finalize(); - await new Promise(r => output.on('close', r)); + await zipDone; // Cleanup temp dir fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f))); diff --git a/routes/redirects.js b/routes/redirects.js index 79a3288..dac86fb 100644 --- a/routes/redirects.js +++ b/routes/redirects.js @@ -61,7 +61,15 @@ router.post('/api/redirect-analyze', redirectLimiter, express.json(), async (req if (response.status < 300 || response.status >= 400) 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) { clearTimeout(timer); chain.push({ url: currentUrl, status: 0, statusText: err.name === 'AbortError' ? 'Timeout' : err.message, location: null, time: Date.now() - stepStart, headers: {} }); diff --git a/routes/svgeditor.js b/routes/svgeditor.js index 704d4bf..4d17df5 100644 --- a/routes/svgeditor.js +++ b/routes/svgeditor.js @@ -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', }; +// Allow only well-formed CSS colours; anything else (e.g. '">