Security hardening: helmet, rate limits, session fixes, admin auth

Critical fixes:
- helmet middleware (X-Frame-Options, HSTS, X-Content-Type, CSP, etc)
- Remove /admin from PUBLIC_PREFIXES (double auth: session + admin role)
- Session cookie: httpOnly, sameSite=lax, 1-day expiry (was 30 days)
- Session regeneration on login (prevent session fixation)
- Blocked user check on login

Rate limiting:
- /auth/login: 10 req / 15 min (brute force protection)
- /auth/register: 10 req / 15 min (spam protection)
- /parse, /metadata, /text, /preview: 20 req / min (DDoS via server)

Input sanitization:
- Strip HTML tags from display_name (stored XSS prevention)
This commit is contained in:
treamz 2026-03-22 00:39:38 +03:00
parent 64bf816e34
commit 5c7066b7d3
6 changed files with 75 additions and 18 deletions

View File

@ -5,7 +5,7 @@
const PUBLIC_PATHS = ['/', '/health', '/favicon.ico', '/status']; const PUBLIC_PATHS = ['/', '/health', '/favicon.ico', '/status'];
const PUBLIC_API = ['/api/settings', '/api/tools', '/api/content/advantages', '/api/content/dashboard']; 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']; const PUBLIC_FILES = ['/shared.css', '/shared.js', '/landing.html'];
function authMiddleware(req, res, next) { function authMiddleware(req, res, next) {

10
package-lock.json generated
View File

@ -22,6 +22,7 @@
"express-rate-limit": "^8.3.0", "express-rate-limit": "^8.3.0",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"geoip-lite": "^1.4.10", "geoip-lite": "^1.4.10",
"helmet": "^8.1.0",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"mariadb": "^3.5.2", "mariadb": "^3.5.2",
@ -6429,6 +6430,15 @@
"node": ">= 0.4" "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": { "node_modules/hoist-non-react-statics": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",

View File

@ -25,6 +25,7 @@
"express-rate-limit": "^8.3.0", "express-rate-limit": "^8.3.0",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"geoip-lite": "^1.4.10", "geoip-lite": "^1.4.10",
"helmet": "^8.1.0",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"mariadb": "^3.5.2", "mariadb": "^3.5.2",

View File

@ -1,12 +1,22 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const path = require('path'); const path = require('path');
const rateLimit = require('express-rate-limit');
const db = require('../lib/db'); const db = require('../lib/db');
const log = require('../lib/logger'); const log = require('../lib/logger');
const router = express.Router(); const router = express.Router();
const SALT_ROUNDS = 10; 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 // Pages
router.get('/login', (req, res) => { router.get('/login', (req, res) => {
if (req.session.user) return res.redirect('/dashboard'); if (req.session.user) return res.redirect('/dashboard');
@ -19,7 +29,7 @@ router.get('/register', (req, res) => {
}); });
// Login API // 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; const { email, password } = req.body;
if (!email || !password) { if (!email || !password) {
@ -32,6 +42,10 @@ router.post('/login', express.json(), express.urlencoded({ extended: true }), as
return res.status(401).json({ error: 'Неверный email или пароль' }); 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); const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) { if (!valid) {
return res.status(401).json({ error: 'Неверный email или пароль' }); return res.status(401).json({ error: 'Неверный email или пароль' });
@ -39,6 +53,14 @@ router.post('/login', express.json(), express.urlencoded({ extended: true }), as
await db.updateLastLogin(user.id); await db.updateLastLogin(user.id);
// 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: 'Ошибка сервера' });
}
req.session.user = { req.session.user = {
id: user.id, id: user.id,
email: user.email, email: user.email,
@ -47,17 +69,24 @@ router.post('/login', express.json(), express.urlencoded({ extended: true }), as
}; };
log.info(`Login: ${user.email}`); log.info(`Login: ${user.email}`);
res.json({ ok: true, redirect: req.session.returnTo || '/dashboard' }); res.json({ ok: true, redirect: returnTo || '/dashboard' });
delete req.session.returnTo; });
} catch (err) { } catch (err) {
log.error('Login error', { error: err.message }); log.error('Login error', { error: err.message });
res.status(500).json({ error: 'Ошибка сервера' }); 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 // Register API
router.post('/register', express.json(), express.urlencoded({ extended: true }), async (req, res) => { router.post('/register', authLimiter, express.json(), express.urlencoded({ extended: true }), async (req, res) => {
const { email, password, password2, name } = req.body; const { email, password, password2 } = req.body;
const name = sanitizeName(req.body.name);
if (!email || !password) { if (!email || !password) {
return res.status(400).json({ error: 'Email и пароль обязательны' }); return res.status(400).json({ error: 'Email и пароль обязательны' });

View File

@ -6,8 +6,13 @@ const geoip = require('geoip-lite');
const log = require('../lib/logger'); const log = require('../lib/logger');
const { validateUrl } = require('../lib/ssrf'); const { validateUrl } = require('../lib/ssrf');
const rateLimit = require('express-rate-limit');
const router = express.Router(); 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) // In-memory cache (max 50 entries, 10 min TTL)
const cache = new Map(); const cache = new Map();
const CACHE_TTL = 10 * 60 * 1000; const CACHE_TTL = 10 * 60 * 1000;
@ -165,7 +170,7 @@ async function fetchAndParse(url) {
} }
// Full parse // Full parse
router.get('/parse', async (req, res) => { router.get('/parse', parserLimiter, async (req, res) => {
const check = await validateUrl(req.query.url); const check = await validateUrl(req.query.url);
if (!check.safe) return res.status(400).json({ error: check.error }); if (!check.safe) return res.status(400).json({ error: check.error });
try { try {
@ -181,7 +186,7 @@ router.get('/parse', async (req, res) => {
}); });
// Metadata only // Metadata only
router.get('/metadata', async (req, res) => { router.get('/metadata', parserLimiter, async (req, res) => {
const check = await validateUrl(req.query.url); const check = await validateUrl(req.query.url);
if (!check.safe) return res.status(400).json({ error: check.error }); if (!check.safe) return res.status(400).json({ error: check.error });
try { try {
@ -197,7 +202,7 @@ router.get('/metadata', async (req, res) => {
}); });
// Text only // Text only
router.get('/text', async (req, res) => { router.get('/text', parserLimiter, async (req, res) => {
const check = await validateUrl(req.query.url); const check = await validateUrl(req.query.url);
if (!check.safe) return res.status(400).json({ error: check.error }); if (!check.safe) return res.status(400).json({ error: check.error });
try { try {
@ -209,7 +214,7 @@ router.get('/text', async (req, res) => {
}); });
// Preview card // Preview card
router.get('/preview', async (req, res) => { router.get('/preview', parserLimiter, async (req, res) => {
const check = await validateUrl(req.query.url); const check = await validateUrl(req.query.url);
if (!check.safe) return res.status(400).json({ error: check.error }); if (!check.safe) return res.status(400).json({ error: check.error });
try { try {

View File

@ -8,12 +8,24 @@ const authMiddleware = require('./lib/auth');
const app = express(); const app = express();
app.set('trust proxy', 1); app.set('trust proxy', 1);
// Security headers
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: false, // Tailwind CDN needs inline scripts
crossOriginEmbedderPolicy: false,
}));
// Session // Session
app.use(session({ app.use(session({
secret: process.env.SESSION_SECRET || 'change-me-in-env', secret: process.env.SESSION_SECRET || 'change-me-in-env',
resave: false, resave: false,
saveUninitialized: 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) // Static assets (before auth — always public)