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:
parent
64bf816e34
commit
5c7066b7d3
@ -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
10
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 и пароль обязательны' });
|
||||
|
||||
@ -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 {
|
||||
|
||||
14
server.js
14
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user