diff --git a/lib/auth.js b/lib/auth.js index 325569a..bb3e296 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -5,7 +5,7 @@ const PUBLIC_PATHS = ['/', '/health', '/favicon.ico', '/status']; const PUBLIC_API = ['/api/settings', '/api/tools', '/api/content/advantages', '/api/content/dashboard']; -const PUBLIC_PREFIXES = ['/auth/', '/vendor/', '/placeholder-img/', '/admin', '/status/']; +const PUBLIC_PREFIXES = ['/auth/', '/vendor/', '/placeholder-img/', '/status/']; const PUBLIC_FILES = ['/shared.css', '/shared.js', '/landing.html']; function authMiddleware(req, res, next) { diff --git a/package-lock.json b/package-lock.json index b2a4f4e..9f57e8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "express-rate-limit": "^8.3.0", "express-session": "^1.18.1", "geoip-lite": "^1.4.10", + "helmet": "^8.1.0", "iconv-lite": "^0.7.2", "jsdom": "^28.1.0", "mariadb": "^3.5.2", @@ -6429,6 +6430,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", diff --git a/package.json b/package.json index 0d8dcd7..784e0e6 100755 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "express-rate-limit": "^8.3.0", "express-session": "^1.18.1", "geoip-lite": "^1.4.10", + "helmet": "^8.1.0", "iconv-lite": "^0.7.2", "jsdom": "^28.1.0", "mariadb": "^3.5.2", diff --git a/routes/auth.js b/routes/auth.js index 87f5866..1e1f302 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,12 +1,22 @@ const express = require('express'); const bcrypt = require('bcrypt'); const path = require('path'); +const rateLimit = require('express-rate-limit'); const db = require('../lib/db'); const log = require('../lib/logger'); const router = express.Router(); const SALT_ROUNDS = 10; +// Rate limiting for auth endpoints +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 min + max: 10, // 10 attempts per window + message: { error: 'Слишком много попыток. Попробуйте через 15 минут.' }, + standardHeaders: true, + legacyHeaders: false, +}); + // Pages router.get('/login', (req, res) => { if (req.session.user) return res.redirect('/dashboard'); @@ -19,7 +29,7 @@ router.get('/register', (req, res) => { }); // Login API -router.post('/login', express.json(), express.urlencoded({ extended: true }), async (req, res) => { +router.post('/login', authLimiter, express.json(), express.urlencoded({ extended: true }), async (req, res) => { const { email, password } = req.body; if (!email || !password) { @@ -32,6 +42,10 @@ router.post('/login', express.json(), express.urlencoded({ extended: true }), as return res.status(401).json({ error: 'Неверный email или пароль' }); } + if (user.is_blocked) { + return res.status(403).json({ error: 'Аккаунт заблокирован' }); + } + const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { return res.status(401).json({ error: 'Неверный email или пароль' }); @@ -39,25 +53,40 @@ router.post('/login', express.json(), express.urlencoded({ extended: true }), as await db.updateLastLogin(user.id); - req.session.user = { - id: user.id, - email: user.email, - name: user.display_name || user.email.split('@')[0], - role: user.role || 'user', - }; + // Session regeneration (prevent session fixation) + const returnTo = req.session.returnTo; + req.session.regenerate((err) => { + if (err) { + log.error('Session regenerate error', { error: err.message }); + return res.status(500).json({ error: 'Ошибка сервера' }); + } - log.info(`Login: ${user.email}`); - res.json({ ok: true, redirect: req.session.returnTo || '/dashboard' }); - delete req.session.returnTo; + req.session.user = { + id: user.id, + email: user.email, + name: user.display_name || user.email.split('@')[0], + role: user.role || 'user', + }; + + log.info(`Login: ${user.email}`); + res.json({ ok: true, redirect: returnTo || '/dashboard' }); + }); } catch (err) { log.error('Login error', { error: err.message }); res.status(500).json({ error: 'Ошибка сервера' }); } }); +// Sanitize display name (strip HTML tags) +function sanitizeName(name) { + if (!name) return ''; + return name.replace(/<[^>]*>/g, '').trim().slice(0, 100); +} + // Register API -router.post('/register', express.json(), express.urlencoded({ extended: true }), async (req, res) => { - const { email, password, password2, name } = req.body; +router.post('/register', authLimiter, express.json(), express.urlencoded({ extended: true }), async (req, res) => { + const { email, password, password2 } = req.body; + const name = sanitizeName(req.body.name); if (!email || !password) { return res.status(400).json({ error: 'Email и пароль обязательны' }); diff --git a/routes/parser.js b/routes/parser.js index 125910f..73d8b49 100644 --- a/routes/parser.js +++ b/routes/parser.js @@ -6,8 +6,13 @@ const geoip = require('geoip-lite'); const log = require('../lib/logger'); const { validateUrl } = require('../lib/ssrf'); +const rateLimit = require('express-rate-limit'); + const router = express.Router(); +// Rate limit parser endpoints (prevent DDoS via server) +const parserLimiter = rateLimit({ windowMs: 60000, max: 20, message: { error: 'Слишком много запросов' } }); + // In-memory cache (max 50 entries, 10 min TTL) const cache = new Map(); const CACHE_TTL = 10 * 60 * 1000; @@ -165,7 +170,7 @@ async function fetchAndParse(url) { } // Full parse -router.get('/parse', async (req, res) => { +router.get('/parse', parserLimiter, async (req, res) => { const check = await validateUrl(req.query.url); if (!check.safe) return res.status(400).json({ error: check.error }); try { @@ -181,7 +186,7 @@ router.get('/parse', async (req, res) => { }); // Metadata only -router.get('/metadata', async (req, res) => { +router.get('/metadata', parserLimiter, async (req, res) => { const check = await validateUrl(req.query.url); if (!check.safe) return res.status(400).json({ error: check.error }); try { @@ -197,7 +202,7 @@ router.get('/metadata', async (req, res) => { }); // Text only -router.get('/text', async (req, res) => { +router.get('/text', parserLimiter, async (req, res) => { const check = await validateUrl(req.query.url); if (!check.safe) return res.status(400).json({ error: check.error }); try { @@ -209,7 +214,7 @@ router.get('/text', async (req, res) => { }); // Preview card -router.get('/preview', async (req, res) => { +router.get('/preview', parserLimiter, async (req, res) => { const check = await validateUrl(req.query.url); if (!check.safe) return res.status(400).json({ error: check.error }); try { diff --git a/server.js b/server.js index afa7f2a..7e10333 100755 --- a/server.js +++ b/server.js @@ -8,12 +8,24 @@ const authMiddleware = require('./lib/auth'); const app = express(); app.set('trust proxy', 1); +// Security headers +const helmet = require('helmet'); +app.use(helmet({ + contentSecurityPolicy: false, // Tailwind CDN needs inline scripts + crossOriginEmbedderPolicy: false, +})); + // Session app.use(session({ secret: process.env.SESSION_SECRET || 'change-me-in-env', resave: false, saveUninitialized: false, - cookie: { maxAge: 30 * 24 * 60 * 60 * 1000 }, + cookie: { + maxAge: 24 * 60 * 60 * 1000, // 1 day + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + }, })); // Static assets (before auth — always public)