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
+5 -3
View File
@@ -22,7 +22,7 @@ npm run dev
The node agent must run on every Incus host with permission to access Incus, ZFS, `/dev/zvol`, Restic, and S3 credentials. In production this usually means running it as root or through a tightly scoped service account with the needed privileges. The node agent must run on every Incus host with permission to access Incus, ZFS, `/dev/zvol`, Restic, and S3 credentials. In production this usually means running it as root or through a tightly scoped service account with the needed privileges.
Set `API_TOKEN` in `backend/.env`; the management server uses that token when calling the agent. Set `API_TOKEN` in `backend/.env`; the management server uses that token when calling the agent. The token is required and must be at least 32 characters long.
Required commands: Required commands:
@@ -42,7 +42,9 @@ npm install
npm run dev npm run dev
``` ```
The management API stores nodes, users, sessions, and central schedules in SQLite. Configure the first admin user through `AUTH_USERNAME` and `AUTH_PASSWORD` before the first start. If no password is configured, the development fallback is `admin`. The management API stores nodes, users, sessions, and central schedules in SQLite. Configure the first admin user through `AUTH_USERNAME` and `AUTH_PASSWORD` before the first start. Startup fails if the initial password is missing.
Agent URLs must use `https://` by default. For local development only, set `ALLOW_INSECURE_AGENT_HTTP=true` in `management/.env` to permit `http://` node URLs.
The management API uses Node's built-in SQLite module and requires Node.js 22.5 or newer. The management API uses Node's built-in SQLite module and requires Node.js 22.5 or newer.
@@ -71,7 +73,7 @@ Changing most node-agent values applies to new API calls and jobs immediately. C
## Safety Notes ## Safety Notes
Restore is intentionally guarded twice: the backend validates the snapshot against the VM, and the UI requires typing the VM name before sending the restore request. Restore jobs are never retried automatically. Restore is intentionally guarded twice: the backend validates the snapshot against the VM, and the UI requires typing the VM name before sending the restore request. VM restores create a pre-restore ZFS snapshot and roll back to it if writing the disk fails. Restore jobs are never retried automatically.
## Deployment ## Deployment
+1 -1
View File
@@ -8,5 +8,5 @@ RESTIC_KEEP_DAILY=7
RESTIC_KEEP_WEEKLY=0 RESTIC_KEEP_WEEKLY=0
RESTIC_KEEP_MONTHLY=0 RESTIC_KEEP_MONTHLY=0
PORT=3000 PORT=3000
API_TOKEN="" API_TOKEN="change-me-to-at-least-32-characters"
ALLOWED_MANAGEMENT_IPS="" ALLOWED_MANAGEMENT_IPS=""
+11 -1
View File
@@ -5,6 +5,7 @@ import path from 'node:path';
dotenv.config(); dotenv.config();
const envPath = path.resolve(process.cwd(), '.env'); const envPath = path.resolve(process.cwd(), '.env');
const minApiTokenLength = 32;
export const requiredEnv = [ export const requiredEnv = [
'AWS_ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID',
@@ -36,6 +37,10 @@ export const config = {
}, },
}; };
if (!config.apiToken || config.apiToken.length < minApiTokenLength) {
throw new Error(`API_TOKEN is required and must be at least ${minApiTokenLength} characters long.`);
}
export const editableEnv = [ export const editableEnv = [
{ key: 'AWS_ACCESS_KEY_ID', label: 'AWS access key ID', required: true, secret: true }, { key: 'AWS_ACCESS_KEY_ID', label: 'AWS access key ID', required: true, secret: true },
{ key: 'AWS_SECRET_ACCESS_KEY', label: 'AWS secret access key', required: true, secret: true }, { key: 'AWS_SECRET_ACCESS_KEY', label: 'AWS secret access key', required: true, secret: true },
@@ -47,7 +52,7 @@ export const editableEnv = [
{ key: 'RESTIC_KEEP_WEEKLY', label: 'Keep weekly snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_WEEKLY', label: 'Keep weekly snapshots', required: false, secret: false },
{ key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly snapshots', required: false, secret: false },
{ key: 'PORT', label: 'API port', required: false, secret: false }, { key: 'PORT', label: 'API port', required: false, secret: false },
{ key: 'API_TOKEN', label: 'API token', required: false, secret: true }, { key: 'API_TOKEN', label: 'API token', required: true, secret: true },
{ key: 'ALLOWED_MANAGEMENT_IPS', label: 'Allowed management IPs', required: false, secret: false }, { key: 'ALLOWED_MANAGEMENT_IPS', label: 'Allowed management IPs', required: false, secret: false },
]; ];
@@ -77,6 +82,11 @@ export async function writeEnvSettings(values) {
for (const [key, value] of Object.entries(values || {})) { for (const [key, value] of Object.entries(values || {})) {
if (!allowedKeys.has(key)) continue; if (!allowedKeys.has(key)) continue;
if (key === 'API_TOKEN' && String(value || '').length < minApiTokenLength) {
const error = new Error(`API_TOKEN must be at least ${minApiTokenLength} characters long.`);
error.status = 400;
throw error;
}
nextValues[key] = String(value ?? ''); nextValues[key] = String(value ?? '');
} }
+15
View File
@@ -74,6 +74,21 @@ export function runRestic(args, options = {}) {
}); });
} }
export async function resticSnapshotFileSize(snapshotId, filename) {
const result = await runRestic(['ls', '--json', snapshotId]);
const wanted = `/${filename}`;
for (const line of result.stdout.split('\n')) {
if (!line.trim()) continue;
const entry = JSON.parse(line);
if (entry.type === 'file' && (entry.path === filename || entry.path === wanted || entry.path?.endsWith(wanted))) {
return Number(entry.size);
}
}
const error = new Error(`Could not verify Restic file size for ${filename} in snapshot ${snapshotId}.`);
error.status = 500;
throw error;
}
export function createProgressStream(totalBytes, onProgress) { export function createProgressStream(totalBytes, onProgress) {
let currentBytes = 0; let currentBytes = 0;
return new Transform({ return new Transform({
-4
View File
@@ -21,10 +21,6 @@ app.use((req, res, next) => {
res.status(403).json({ error: 'Forbidden management source.' }); res.status(403).json({ error: 'Forbidden management source.' });
return; return;
} }
if (!config.apiToken) {
next();
return;
}
const header = req.get('authorization') || ''; const header = req.get('authorization') || '';
if (header === `Bearer ${config.apiToken}`) { if (header === `Bearer ${config.apiToken}`) {
next(); next();
+45 -3
View File
@@ -6,7 +6,7 @@ import path from 'node:path';
import { finished } from 'node:stream/promises'; import { finished } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises'; import { setTimeout as delay } from 'node:timers/promises';
import { config } from '../config.js'; import { config } from '../config.js';
import { createProgressStream, spawnCommand, runRestic } from '../executor.js'; import { createProgressStream, resticSnapshotFileSize, spawnCommand, runRestic } from '../executor.js';
import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js'; import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
import { validateVmExists } from '../validators.js'; import { validateVmExists } from '../validators.js';
@@ -59,7 +59,9 @@ export async function runBackupJob(job, instance = null) {
setJobStep(job, 'Streaming block device to Restic'); setJobStep(job, 'Streaming block device to Restic');
const snapshotStream = await deviceReadStream(snapshotDevice); const snapshotStream = await deviceReadStream(snapshotDevice);
let streamedBytes = 0;
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => { const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
streamedBytes = currentBytes;
setJobProgress(job, { setJobProgress(job, {
currentBytes, currentBytes,
totalBytes: bytesTotal, totalBytes: bytesTotal,
@@ -68,11 +70,13 @@ export async function runBackupJob(job, instance = null) {
}); });
}); });
try { try {
await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.raw`, '--tag', job.vmName, '--tag', 'data', '--tag', 'virtual-machine'], { const result = await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.raw`, '--tag', job.vmName, '--tag', 'data', '--tag', 'virtual-machine'], {
env: { ...process.env, ...config.resticEnv }, env: { ...process.env, ...config.resticEnv },
input: snapshotStream.pipe(progressStream), input: snapshotStream.pipe(progressStream),
log: (line) => appendJobLog(job, line), log: (line) => appendJobLog(job, line),
}); });
const snapshotId = parseResticSnapshotId(result);
await verifyResticFileSize(job, snapshotId, `${job.vmName}.raw`, streamedBytes || totalBytes);
} finally { } finally {
if (!snapshotStream.destroyed) { if (!snapshotStream.destroyed) {
snapshotStream.destroy(); snapshotStream.destroy();
@@ -124,7 +128,9 @@ async function runContainerBackupJob(job) {
const zfs = spawn('zfs', ['send', snapshot], { stdio: ['ignore', 'pipe', 'pipe'] }); const zfs = spawn('zfs', ['send', snapshot], { stdio: ['ignore', 'pipe', 'pipe'] });
const zfsClosed = waitForProcess(zfs, 'zfs send'); const zfsClosed = waitForProcess(zfs, 'zfs send');
zfs.stderr.on('data', (chunk) => appendJobLog(job, chunk.toString().trimEnd())); zfs.stderr.on('data', (chunk) => appendJobLog(job, chunk.toString().trimEnd()));
let streamedBytes = 0;
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => { const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
streamedBytes = currentBytes;
setJobProgress(job, { setJobProgress(job, {
currentBytes, currentBytes,
totalBytes: bytesTotal, totalBytes: bytesTotal,
@@ -134,11 +140,13 @@ async function runContainerBackupJob(job) {
}); });
let resticOk = false; let resticOk = false;
try { try {
await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.zfs`, '--tag', job.vmName, '--tag', 'data', '--tag', 'container'], { const result = await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.zfs`, '--tag', job.vmName, '--tag', 'data', '--tag', 'container'], {
env: { ...process.env, ...config.resticEnv }, env: { ...process.env, ...config.resticEnv },
input: zfs.stdout.pipe(progressStream), input: zfs.stdout.pipe(progressStream),
log: (line) => appendJobLog(job, line), log: (line) => appendJobLog(job, line),
}); });
const snapshotId = parseResticSnapshotId(result);
await verifyResticFileSize(job, snapshotId, `${job.vmName}.zfs`, streamedBytes || totalBytes);
resticOk = true; resticOk = true;
} finally { } finally {
if (!resticOk && !zfs.killed) zfs.kill('SIGTERM'); if (!resticOk && !zfs.killed) zfs.kill('SIGTERM');
@@ -270,6 +278,40 @@ function positiveInteger(value) {
return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0; return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0;
} }
function parseResticSnapshotId(result) {
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
const match = output.match(/snapshot\s+([0-9a-f]{8,64})\s+saved/i);
if (!match) {
throw new Error('Restic backup completed but no snapshot ID could be parsed.');
}
return match[1];
}
async function verifyResticFileSize(job, snapshotId, filename, expectedBytes) {
if (!expectedBytes) {
appendJobLog(job, `Skipping size verification for ${snapshotId}: expected size is unknown.`);
return;
}
try {
const storedBytes = await resticSnapshotFileSize(snapshotId, filename);
if (storedBytes !== expectedBytes) {
throw new Error(`Backup verification failed for ${snapshotId}: stored ${formatBytes(storedBytes)}, expected ${formatBytes(expectedBytes)}.`);
}
appendJobLog(job, `Verified Restic snapshot ${snapshotId}: ${formatBytes(storedBytes)}.`);
} catch (error) {
appendJobLog(job, error.message);
await forgetFailedSnapshot(job, snapshotId);
throw error;
}
}
async function forgetFailedSnapshot(job, snapshotId) {
appendJobLog(job, `Removing failed Restic snapshot ${snapshotId}.`);
await runRestic(['forget', snapshotId, '--prune'], {
log: (line) => appendJobLog(job, line),
}).catch((error) => appendJobLog(job, `Failed to remove Restic snapshot ${snapshotId}: ${error.message}`));
}
async function cleanupBackup(job, zvol, snapshotName) { async function cleanupBackup(job, zvol, snapshotName) {
setJobStep(job, 'Running cleanup'); setJobStep(job, 'Running cleanup');
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], { await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], {
+27 -6
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { config } from '../config.js'; import { config } from '../config.js';
import { spawnCommand, streamResticDumpToDd } from '../executor.js'; import { resticSnapshotFileSize, spawnCommand, streamResticDumpToDd } from '../executor.js';
import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js'; import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
import { validateSnapshotForVm, validateVmExists } from '../validators.js'; import { validateSnapshotForVm, validateVmExists } from '../validators.js';
@@ -34,8 +34,10 @@ restoreRouter.post('/:vmName', async (req, res, next) => {
async function runRestoreJob(job, snapshotId) { async function runRestoreJob(job, snapshotId) {
const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`; const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`;
const device = `/dev/zvol/${zvol}`; const device = `/dev/zvol/${zvol}`;
const rollbackSnapshot = `${zvol}@pre-restore-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`;
let volmodeDev = false; let volmodeDev = false;
let diskWriteOk = false; let diskWriteOk = false;
let rollbackSnapshotCreated = false;
try { try {
setJobRunning(job, 'Stopping VM'); setJobRunning(job, 'Stopping VM');
@@ -45,19 +47,31 @@ async function runRestoreJob(job, snapshotId) {
log: (line) => appendJobLog(job, line), log: (line) => appendJobLog(job, line),
}); });
setJobStep(job, 'Creating pre-restore ZFS snapshot');
setJobProgress(job, { percent: 6 });
await spawnCommand('zfs', ['snapshot', rollbackSnapshot], { log: (line) => appendJobLog(job, line) });
rollbackSnapshotCreated = true;
appendJobLog(job, `Created rollback snapshot ${rollbackSnapshot}`);
setJobStep(job, 'Setting ZFS volume to device mode'); setJobStep(job, 'Setting ZFS volume to device mode');
setJobProgress(job, { percent: 8 }); setJobProgress(job, { percent: 10 });
await spawnCommand('zfs', ['set', 'volmode=dev', zvol], { log: (line) => appendJobLog(job, line) }); await spawnCommand('zfs', ['set', 'volmode=dev', zvol], { log: (line) => appendJobLog(job, line) });
volmodeDev = true; volmodeDev = true;
setJobStep(job, 'Settling device nodes'); setJobStep(job, 'Settling device nodes');
setJobProgress(job, { percent: 12 }); setJobProgress(job, { percent: 14 });
await spawnCommand('udevadm', ['trigger'], { log: (line) => appendJobLog(job, line) }); await spawnCommand('udevadm', ['trigger'], { log: (line) => appendJobLog(job, line) });
await spawnCommand('udevadm', ['settle'], { log: (line) => appendJobLog(job, line) }); await spawnCommand('udevadm', ['settle'], { log: (line) => appendJobLog(job, line) });
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
setJobStep(job, 'Writing Restic snapshot to block device'); setJobStep(job, 'Checking restore size');
const totalBytes = await zfsVolumeSize(zvol); const totalBytes = await zfsVolumeSize(zvol);
const resticBytes = await resticSnapshotFileSize(snapshotId, `${job.vmName}.raw`);
if (totalBytes && resticBytes && totalBytes !== resticBytes) {
throw new Error(`Restore size mismatch: Restic file is ${formatBytes(resticBytes)}, target volume is ${formatBytes(totalBytes)}.`);
}
setJobStep(job, 'Writing Restic snapshot to block device');
await streamResticDumpToDd(snapshotId, `${job.vmName}.raw`, device, (line) => appendJobLog(job, line), { await streamResticDumpToDd(snapshotId, `${job.vmName}.raw`, device, (line) => appendJobLog(job, line), {
totalBytes, totalBytes,
onProgress: ({ currentBytes, totalBytes: bytesTotal, percent }) => { onProgress: ({ currentBytes, totalBytes: bytesTotal, percent }) => {
@@ -80,6 +94,7 @@ async function runRestoreJob(job, snapshotId) {
setJobProgress(job, { percent: 97 }); setJobProgress(job, { percent: 97 });
await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) }); await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) });
appendJobLog(job, `Pre-restore rollback snapshot kept: ${rollbackSnapshot}`);
finishJob(job, 'success'); finishJob(job, 'success');
} catch (error) { } catch (error) {
appendJobLog(job, error.message); appendJobLog(job, error.message);
@@ -90,9 +105,15 @@ async function runRestoreJob(job, snapshotId) {
log: (line) => appendJobLog(job, line), log: (line) => appendJobLog(job, line),
}).catch((cleanupError) => appendJobLog(job, cleanupError.message)); }).catch((cleanupError) => appendJobLog(job, cleanupError.message));
} }
if (!diskWriteOk) { if (rollbackSnapshotCreated && !diskWriteOk) {
appendJobLog(job, 'VM was not restarted because disk restore did not complete successfully.'); setJobStep(job, 'Rolling back failed restore');
await spawnCommand('zfs', ['rollback', '-r', rollbackSnapshot], {
ignoreExitCode: true,
log: (line) => appendJobLog(job, line),
}).catch((cleanupError) => appendJobLog(job, cleanupError.message));
appendJobLog(job, `Rolled back to ${rollbackSnapshot}`);
} }
appendJobLog(job, 'VM was not restarted because restore did not complete successfully.');
finishJob(job, 'failed', error); finishJob(job, 'failed', error);
} }
} }
+7 -2
View File
@@ -15,11 +15,11 @@ Important `.env` values:
```env ```env
PORT=3000 PORT=3000
API_TOKEN="long-random-token" API_TOKEN="long-random-token-at-least-32-characters"
ALLOWED_MANAGEMENT_IPS="management-server-ip" ALLOWED_MANAGEMENT_IPS="management-server-ip"
``` ```
If `ALLOWED_MANAGEMENT_IPS` is set, the agent only accepts requests from those comma-separated IP addresses. `API_TOKEN` is required and must be at least 32 characters long. If `ALLOWED_MANAGEMENT_IPS` is set, the agent only accepts requests from those comma-separated IP addresses.
Install systemd service: Install systemd service:
@@ -49,8 +49,13 @@ SESSION_SECRET="long-random-secret"
AUTH_USERNAME="admin" AUTH_USERNAME="admin"
AUTH_PASSWORD="initial-password" AUTH_PASSWORD="initial-password"
DATABASE_PATH="./management.sqlite" DATABASE_PATH="./management.sqlite"
CORS_ORIGINS="https://backup.example.com"
SESSION_COOKIE_SECURE=true
ALLOW_INSECURE_AGENT_HTTP=false
``` ```
`AUTH_PASSWORD` is required for the first start when the user database is empty. `CORS_ORIGINS` must list the frontend origins that are allowed to use cookie-authenticated API calls. Agent URLs must use `https://`; only set `ALLOW_INSECURE_AGENT_HTTP=true` for local development.
Reset an existing password: Reset an existing password:
```bash ```bash
-16
View File
@@ -6,22 +6,6 @@ export const api = axios.create({
withCredentials: true, withCredentials: true,
}); });
const token = window.localStorage.getItem('incusBackupApiToken') || import.meta.env.VITE_API_TOKEN;
if (token) {
api.defaults.headers.common.Authorization = `Bearer ${token}`;
}
export function setApiToken(tokenValue) {
const token = String(tokenValue || '');
if (token) {
window.localStorage.setItem('incusBackupApiToken', token);
api.defaults.headers.common.Authorization = `Bearer ${token}`;
return;
}
window.localStorage.removeItem('incusBackupApiToken');
delete api.defaults.headers.common.Authorization;
}
export function errorMessage(error) { export function errorMessage(error) {
return error.response?.data?.error || error.message || 'Request failed.'; return error.response?.data?.error || error.message || 'Request failed.';
} }
+3
View File
@@ -3,3 +3,6 @@ SESSION_SECRET="change-me"
AUTH_USERNAME="admin" AUTH_USERNAME="admin"
AUTH_PASSWORD="change-me" AUTH_PASSWORD="change-me"
DATABASE_PATH="./management.sqlite" 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 { createSession, deleteSession, getUserBySession, getUserByUsername } from './store.js';
import { config } from './config.js';
import { verifyPassword } from './crypto.js'; import { verifyPassword } from './crypto.js';
const cookieName = 'incus_backup_session'; const cookieName = 'incus_backup_session';
@@ -34,12 +35,12 @@ export function logout(sessionId) {
export function setSessionCookie(res, session) { export function setSessionCookie(res, session) {
res.setHeader('Set-Cookie', [ 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) { 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) { function readCookie(req, name) {
@@ -50,3 +51,8 @@ function readCookie(req, name) {
} }
return ''; 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', authUsername: process.env.AUTH_USERNAME || 'admin',
authPassword: process.env.AUTH_PASSWORD || '', authPassword: process.env.AUTH_PASSWORD || '',
databasePath: path.resolve(process.cwd(), process.env.DATABASE_PATH || './management.sqlite'), 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(); const existing = db.prepare('SELECT id FROM users LIMIT 1').get();
if (!existing) { 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 now = new Date().toISOString();
const password = config.authPassword || 'admin';
db.prepare('INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)') 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(); 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(express.json());
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);
+47 -1
View File
@@ -4,17 +4,43 @@ import { createAuditEvent } from '../store.js';
export const authRouter = Router(); export const authRouter = Router();
const loginAttempts = new Map();
const maxAttempts = 5;
const windowMs = 15 * 60 * 1000;
authRouter.get('/session', (req, res) => { authRouter.get('/session', (req, res) => {
const session = currentSession(req); const session = currentSession(req);
res.json({ authenticated: Boolean(session), user: session?.user || null }); res.json({ authenticated: Boolean(session), user: session?.user || null });
}); });
authRouter.post('/login', (req, res) => { 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) { 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.' }); res.status(401).json({ error: 'Invalid username or password.' });
return; return;
} }
loginAttempts.delete(key);
setSessionCookie(res, result.session); setSessionCookie(res, result.session);
createAuditEvent({ createAuditEvent({
user: result.user, user: result.user,
@@ -39,3 +65,23 @@ authRouter.post('/logout', (req, res) => {
clearSessionCookie(res); clearSessionCookie(res);
res.json({ ok: true }); 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 { Router } from 'express';
import { agentRequest, publicNode } from '../agentClient.js'; import { agentRequest, publicNode } from '../agentClient.js';
import { config } from '../config.js';
import { createAuditEvent, createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js'; import { createAuditEvent, createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js';
export const nodesRouter = Router(); export const nodesRouter = Router();
@@ -88,7 +89,7 @@ function validateNodeInput(body, options = {}) {
error.status = 400; error.status = 400;
throw error; throw error;
} }
if (baseUrl) new URL(baseUrl); if (baseUrl) validateBaseUrl(baseUrl);
return { return {
...(name !== undefined ? { name } : {}), ...(name !== undefined ? { name } : {}),
@@ -97,3 +98,15 @@ function validateNodeInput(body, options = {}) {
...(values.enabled !== undefined ? { enabled: Boolean(values.enabled) } : {}), ...(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;
}