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_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) {

10
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 и пароль обязательны' });

View File

@ -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 {

View File

@ -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)