Security audit fixes synced from VPS prod: SSRF/IDOR/WS-auth/session/rate-limits/crash-guards + session journal
This commit is contained in:
parent
d997bad179
commit
626a28f750
17
CLAUDE.md
Normal file
17
CLAUDE.md
Normal 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
7
docs/SESSION_LOG.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Журнал сессий
|
||||||
|
|
||||||
|
Хронология рабочих сессий (новые сверху). Каждая запись — самодостаточный файл в `sessions/`
|
||||||
|
с итогами, решениями, открытыми вопросами и инструкцией «как продолжить». Ведётся скиллом
|
||||||
|
`/session-handoff`.
|
||||||
|
|
||||||
|
- [2026-07-24 17:43 — Локальная копия, снятие лимитов /compress, скилл деплоя под VPS](sessions/2026-07-24-1743.md)
|
||||||
36
docs/sessions/2026-07-24-1743.md
Normal file
36
docs/sessions/2026-07-24-1743.md
Normal 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`)
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
17
lib/queue.js
17
lib/queue.js
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
109
lib/ssrf.js
109
lib/ssrf.js
@ -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 };
|
||||||
|
|||||||
39
lib/ws.js
39
lib/ws.js
@ -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) {
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
@ -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: 'Ошибка сервера' });
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)));
|
||||||
|
|||||||
@ -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: {} });
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
19
server.js
19
server.js
@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user