security hardening

This commit is contained in:
Philipp
2026-05-21 14:09:08 +02:00
parent 3103aba972
commit 0046156e58
16 changed files with 205 additions and 43 deletions
+3
View File
@@ -3,3 +3,6 @@ SESSION_SECRET="change-me"
AUTH_USERNAME="admin"
AUTH_PASSWORD="change-me"
DATABASE_PATH="./management.sqlite"
CORS_ORIGINS="http://localhost:5173"
SESSION_COOKIE_SECURE=false
ALLOW_INSECURE_AGENT_HTTP=true
+8 -2
View File
@@ -1,4 +1,5 @@
import { createSession, deleteSession, getUserBySession, getUserByUsername } from './store.js';
import { config } from './config.js';
import { verifyPassword } from './crypto.js';
const cookieName = 'incus_backup_session';
@@ -34,12 +35,12 @@ export function logout(sessionId) {
export function setSessionCookie(res, session) {
res.setHeader('Set-Cookie', [
`${cookieName}=${session.id}; Path=/; HttpOnly; SameSite=Lax; Expires=${session.expiresAt.toUTCString()}`,
cookieHeader(cookieName, session.id, `Expires=${session.expiresAt.toUTCString()}`),
]);
}
export function clearSessionCookie(res) {
res.setHeader('Set-Cookie', [`${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`]);
res.setHeader('Set-Cookie', [cookieHeader(cookieName, '', 'Max-Age=0')]);
}
function readCookie(req, name) {
@@ -50,3 +51,8 @@ function readCookie(req, name) {
}
return '';
}
function cookieHeader(name, value, lifetime) {
const secure = config.sessionCookieSecure ? '; Secure' : '';
return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Strict${secure}; ${lifetime}`;
}
+8
View File
@@ -9,4 +9,12 @@ export const config = {
authUsername: process.env.AUTH_USERNAME || 'admin',
authPassword: process.env.AUTH_PASSWORD || '',
databasePath: path.resolve(process.cwd(), process.env.DATABASE_PATH || './management.sqlite'),
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
sessionCookieSecure: process.env.SESSION_COOKIE_SECURE
? process.env.SESSION_COOKIE_SECURE === 'true'
: process.env.NODE_ENV === 'production',
allowInsecureAgentHttp: process.env.ALLOW_INSECURE_AGENT_HTTP === 'true',
};
+4 -2
View File
@@ -80,10 +80,12 @@ export function migrate() {
const existing = db.prepare('SELECT id FROM users LIMIT 1').get();
if (!existing) {
if (!config.authPassword) {
throw new Error('AUTH_PASSWORD is required for initial admin user creation. Set AUTH_PASSWORD before starting management.');
}
const now = new Date().toISOString();
const password = config.authPassword || 'admin';
db.prepare('INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)')
.run(`user_${cryptoId()}`, config.authUsername, hashPassword(password), now);
.run(`user_${cryptoId()}`, config.authUsername, hashPassword(config.authPassword), now);
}
}
+10 -1
View File
@@ -15,7 +15,16 @@ migrate();
const app = express();
app.use(cors({ origin: true, credentials: true }));
app.use(cors({
origin(origin, callback) {
if (!origin || config.corsOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error('CORS origin is not allowed.'));
},
credentials: true,
}));
app.use(express.json());
app.use('/api/auth', authRouter);
+47 -1
View File
@@ -4,17 +4,43 @@ import { createAuditEvent } from '../store.js';
export const authRouter = Router();
const loginAttempts = new Map();
const maxAttempts = 5;
const windowMs = 15 * 60 * 1000;
authRouter.get('/session', (req, res) => {
const session = currentSession(req);
res.json({ authenticated: Boolean(session), user: session?.user || null });
});
authRouter.post('/login', (req, res) => {
const result = login(req.body?.username, req.body?.password);
const username = String(req.body?.username || '');
const key = loginAttemptKey(req, username);
const attempt = currentAttempt(key);
if (attempt.count >= maxAttempts) {
createAuditEvent({
action: 'login_blocked',
targetType: 'session',
targetId: username || null,
details: { ip: clientIp(req), reason: 'rate_limit' },
});
res.status(429).json({ error: 'Too many login attempts. Try again later.' });
return;
}
const result = login(username, req.body?.password);
if (!result) {
recordFailedAttempt(key);
createAuditEvent({
action: 'login_failed',
targetType: 'session',
targetId: username || null,
details: { ip: clientIp(req) },
});
res.status(401).json({ error: 'Invalid username or password.' });
return;
}
loginAttempts.delete(key);
setSessionCookie(res, result.session);
createAuditEvent({
user: result.user,
@@ -39,3 +65,23 @@ authRouter.post('/logout', (req, res) => {
clearSessionCookie(res);
res.json({ ok: true });
});
function loginAttemptKey(req, username) {
return `${clientIp(req)}:${String(username || '').toLowerCase()}`;
}
function clientIp(req) {
return String(req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
}
function currentAttempt(key) {
const now = Date.now();
const current = loginAttempts.get(key);
if (!current || current.resetAt <= now) return { count: 0, resetAt: now + windowMs };
return current;
}
function recordFailedAttempt(key) {
const attempt = currentAttempt(key);
loginAttempts.set(key, { count: attempt.count + 1, resetAt: attempt.resetAt });
}
+14 -1
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { agentRequest, publicNode } from '../agentClient.js';
import { config } from '../config.js';
import { createAuditEvent, createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js';
export const nodesRouter = Router();
@@ -88,7 +89,7 @@ function validateNodeInput(body, options = {}) {
error.status = 400;
throw error;
}
if (baseUrl) new URL(baseUrl);
if (baseUrl) validateBaseUrl(baseUrl);
return {
...(name !== undefined ? { name } : {}),
@@ -97,3 +98,15 @@ function validateNodeInput(body, options = {}) {
...(values.enabled !== undefined ? { enabled: Boolean(values.enabled) } : {}),
};
}
function validateBaseUrl(baseUrl) {
const url = new URL(baseUrl);
if (url.protocol === 'https:') return;
if (url.protocol === 'http:' && config.allowInsecureAgentHttp) {
console.warn(`ALLOW_INSECURE_AGENT_HTTP=true permits insecure agent URL: ${baseUrl}`);
return;
}
const error = new Error('Node URL must use https://. Set ALLOW_INSECURE_AGENT_HTTP=true only for local development.');
error.status = 400;
throw error;
}