security hardening
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user