security hardening
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
require('dotenv').config({ path: process.env.ENV_FILE || '../.env' });
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { doubleCsrf } = require('csrf-csrf');
|
||||
const path = require('path');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
@@ -9,29 +13,96 @@ const PORT = parseInt(process.env.SETTINGS_PORT) || 12752;
|
||||
const INTERNAL_URL = process.env.INTERNAL_API_URL || `http://127.0.0.1:${process.env.INTERNAL_API_PORT || 12753}/internal`;
|
||||
const SECRET = process.env.INTERNAL_API_SECRET;
|
||||
const ADMIN_PASSWORD = process.env.SETTINGS_ADMIN_PASSWORD;
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.static(path.join(__dirname, 'public'), { index: false }));
|
||||
app.use(session({
|
||||
secret: SECRET || 'fallback-secret-change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 8 * 60 * 60 * 1000 // 8 hours
|
||||
if (!SESSION_SECRET) {
|
||||
console.error('[settings] FATAL: SESSION_SECRET env var is required (min 32 random bytes)');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!SECRET) {
|
||||
console.error('[settings] FATAL: INTERNAL_API_SECRET env var is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!ADMIN_PASSWORD) {
|
||||
console.error('[settings] FATAL: SETTINGS_ADMIN_PASSWORD env var is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Single-hop reverse proxy (Caddy at /opt/caddy/Caddyfile on the rustdesk
|
||||
// droplet — not accessible from this box; assumed to set X-Forwarded-Proto
|
||||
// and X-Forwarded-For). Required so express-session marks the connection
|
||||
// as secure and rate limits key off the real client IP.
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", 'https://fonts.googleapis.com'],
|
||||
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
scriptSrc: ["'self'"],
|
||||
connectSrc: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
objectSrc: ["'none'"]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Auth middleware
|
||||
app.use(express.json({ limit: '64kb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '64kb' }));
|
||||
|
||||
app.use(session({
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: IS_PROD,
|
||||
sameSite: 'strict',
|
||||
maxAge: 8 * 60 * 60 * 1000
|
||||
}
|
||||
}));
|
||||
|
||||
app.use(cookieParser(SESSION_SECRET));
|
||||
|
||||
const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({
|
||||
getSecret: () => SESSION_SECRET,
|
||||
getSessionIdentifier: (req) => req.sessionID || '',
|
||||
cookieName: IS_PROD ? '__Host-x-csrf-token' : 'x-csrf-token',
|
||||
cookieOptions: {
|
||||
sameSite: 'strict',
|
||||
secure: IS_PROD,
|
||||
httpOnly: true,
|
||||
path: '/'
|
||||
},
|
||||
getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token']
|
||||
});
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many login attempts, please try again later.' }
|
||||
});
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later.' }
|
||||
});
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session?.authed) return next();
|
||||
res.redirect('/login');
|
||||
}
|
||||
|
||||
// Internal API proxy helper
|
||||
async function callBot(method, apiPath, body) {
|
||||
const res = await fetch(`${INTERNAL_URL}${apiPath}`, {
|
||||
method,
|
||||
@@ -44,14 +115,21 @@ async function callBot(method, apiPath, body) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Routes
|
||||
app.use(express.static(path.join(__dirname, 'public'), { index: false }));
|
||||
|
||||
app.get('/api/csrf-token', (req, res) => {
|
||||
const csrfToken = generateCsrfToken(req, res);
|
||||
res.json({ csrfToken });
|
||||
});
|
||||
|
||||
app.use(doubleCsrfProtection);
|
||||
|
||||
app.get('/login', (req, res) => {
|
||||
if (req.session?.authed) return res.redirect('/');
|
||||
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
||||
});
|
||||
|
||||
app.post('/login', (req, res) => {
|
||||
if (!ADMIN_PASSWORD) return res.status(503).json({ error: 'SETTINGS_ADMIN_PASSWORD not set' });
|
||||
app.post('/login', loginLimiter, (req, res) => {
|
||||
if (req.body.password === ADMIN_PASSWORD) {
|
||||
req.session.authed = true;
|
||||
return res.json({ ok: true });
|
||||
@@ -60,36 +138,34 @@ app.post('/login', (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/logout', (req, res) => {
|
||||
req.session.destroy();
|
||||
res.redirect('/login');
|
||||
req.session.destroy(() => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
app.get('/', requireAuth, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Proxy to bot internal API
|
||||
app.get('/api/config', requireAuth, async (req, res) => {
|
||||
app.get('/api/config', apiLimiter, requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('GET', '/config')); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
|
||||
app.post('/api/config', requireAuth, async (req, res) => {
|
||||
app.post('/api/config', apiLimiter, requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('POST', '/config', req.body)); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
|
||||
app.get('/api/discord/guild', requireAuth, async (req, res) => {
|
||||
app.get('/api/discord/guild', apiLimiter, requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('GET', '/discord/guild')); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
|
||||
app.post('/api/restart', requireAuth, async (req, res) => {
|
||||
app.post('/api/restart', apiLimiter, requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('POST', '/restart', req.body)); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
|
||||
app.get('/api/restart/status', requireAuth, async (req, res) => {
|
||||
app.get('/api/restart/status', apiLimiter, requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('GET', '/restart/status')); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
@@ -98,6 +174,13 @@ app.get('*', requireAuth, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
if (err && (err.code === 'EBADCSRFTOKEN' || err.code === 'ERR_BAD_CSRF_TOKEN')) {
|
||||
return res.status(403).json({ error: 'Invalid CSRF token' });
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[settings] running on port ${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user