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