From 0046156e589cd989edbbe8339c1e3cde9c032baf Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 21 May 2026 14:09:08 +0200 Subject: [PATCH] security hardening --- README.md | 8 +++--- backend/.env.example | 2 +- backend/src/config.js | 12 ++++++++- backend/src/executor.js | 15 +++++++++++ backend/src/index.js | 4 --- backend/src/routes/backup.js | 48 +++++++++++++++++++++++++++++++--- backend/src/routes/restore.js | 33 ++++++++++++++++++----- docs/deployment.md | 9 +++++-- frontend/src/api.js | 16 ------------ management/.env.example | 3 +++ management/src/auth.js | 10 +++++-- management/src/config.js | 8 ++++++ management/src/db.js | 6 +++-- management/src/index.js | 11 +++++++- management/src/routes/auth.js | 48 +++++++++++++++++++++++++++++++++- management/src/routes/nodes.js | 15 ++++++++++- 16 files changed, 205 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index b079453..744577b 100644 --- a/README.md +++ b/README.md @@ -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. -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: @@ -42,7 +42,9 @@ npm install 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. @@ -71,7 +73,7 @@ Changing most node-agent values applies to new API calls and jobs immediately. C ## 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 diff --git a/backend/.env.example b/backend/.env.example index 051175c..02fcda1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,5 +8,5 @@ RESTIC_KEEP_DAILY=7 RESTIC_KEEP_WEEKLY=0 RESTIC_KEEP_MONTHLY=0 PORT=3000 -API_TOKEN="" +API_TOKEN="change-me-to-at-least-32-characters" ALLOWED_MANAGEMENT_IPS="" diff --git a/backend/src/config.js b/backend/src/config.js index a9053d2..b49f832 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -5,6 +5,7 @@ import path from 'node:path'; dotenv.config(); const envPath = path.resolve(process.cwd(), '.env'); +const minApiTokenLength = 32; export const requiredEnv = [ '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 = [ { 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 }, @@ -47,7 +52,7 @@ export const editableEnv = [ { 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: '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 }, ]; @@ -77,6 +82,11 @@ export async function writeEnvSettings(values) { for (const [key, value] of Object.entries(values || {})) { 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 ?? ''); } diff --git a/backend/src/executor.js b/backend/src/executor.js index 3016924..2f20389 100644 --- a/backend/src/executor.js +++ b/backend/src/executor.js @@ -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) { let currentBytes = 0; return new Transform({ diff --git a/backend/src/index.js b/backend/src/index.js index 4a83f81..0b191a8 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -21,10 +21,6 @@ app.use((req, res, next) => { res.status(403).json({ error: 'Forbidden management source.' }); return; } - if (!config.apiToken) { - next(); - return; - } const header = req.get('authorization') || ''; if (header === `Bearer ${config.apiToken}`) { next(); diff --git a/backend/src/routes/backup.js b/backend/src/routes/backup.js index 5505605..d8b51fc 100644 --- a/backend/src/routes/backup.js +++ b/backend/src/routes/backup.js @@ -6,7 +6,7 @@ import path from 'node:path'; import { finished } from 'node:stream/promises'; import { setTimeout as delay } from 'node:timers/promises'; 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 { validateVmExists } from '../validators.js'; @@ -59,7 +59,9 @@ export async function runBackupJob(job, instance = null) { setJobStep(job, 'Streaming block device to Restic'); const snapshotStream = await deviceReadStream(snapshotDevice); + let streamedBytes = 0; const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => { + streamedBytes = currentBytes; setJobProgress(job, { currentBytes, totalBytes: bytesTotal, @@ -68,11 +70,13 @@ export async function runBackupJob(job, instance = null) { }); }); 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 }, input: snapshotStream.pipe(progressStream), log: (line) => appendJobLog(job, line), }); + const snapshotId = parseResticSnapshotId(result); + await verifyResticFileSize(job, snapshotId, `${job.vmName}.raw`, streamedBytes || totalBytes); } finally { if (!snapshotStream.destroyed) { snapshotStream.destroy(); @@ -124,7 +128,9 @@ async function runContainerBackupJob(job) { const zfs = spawn('zfs', ['send', snapshot], { stdio: ['ignore', 'pipe', 'pipe'] }); const zfsClosed = waitForProcess(zfs, 'zfs send'); zfs.stderr.on('data', (chunk) => appendJobLog(job, chunk.toString().trimEnd())); + let streamedBytes = 0; const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => { + streamedBytes = currentBytes; setJobProgress(job, { currentBytes, totalBytes: bytesTotal, @@ -134,11 +140,13 @@ async function runContainerBackupJob(job) { }); let resticOk = false; 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 }, input: zfs.stdout.pipe(progressStream), log: (line) => appendJobLog(job, line), }); + const snapshotId = parseResticSnapshotId(result); + await verifyResticFileSize(job, snapshotId, `${job.vmName}.zfs`, streamedBytes || totalBytes); resticOk = true; } finally { if (!resticOk && !zfs.killed) zfs.kill('SIGTERM'); @@ -270,6 +278,40 @@ function positiveInteger(value) { 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) { setJobStep(job, 'Running cleanup'); await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], { diff --git a/backend/src/routes/restore.js b/backend/src/routes/restore.js index fe82865..267aa59 100644 --- a/backend/src/routes/restore.js +++ b/backend/src/routes/restore.js @@ -1,6 +1,6 @@ import { Router } from 'express'; 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 { validateSnapshotForVm, validateVmExists } from '../validators.js'; @@ -34,8 +34,10 @@ restoreRouter.post('/:vmName', async (req, res, next) => { async function runRestoreJob(job, snapshotId) { const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`; const device = `/dev/zvol/${zvol}`; + const rollbackSnapshot = `${zvol}@pre-restore-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; let volmodeDev = false; let diskWriteOk = false; + let rollbackSnapshotCreated = false; try { setJobRunning(job, 'Stopping VM'); @@ -45,19 +47,31 @@ async function runRestoreJob(job, snapshotId) { 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'); - setJobProgress(job, { percent: 8 }); + setJobProgress(job, { percent: 10 }); await spawnCommand('zfs', ['set', 'volmode=dev', zvol], { log: (line) => appendJobLog(job, line) }); volmodeDev = true; 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', ['settle'], { log: (line) => appendJobLog(job, line) }); 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 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), { totalBytes, onProgress: ({ currentBytes, totalBytes: bytesTotal, percent }) => { @@ -80,6 +94,7 @@ async function runRestoreJob(job, snapshotId) { setJobProgress(job, { percent: 97 }); await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) }); + appendJobLog(job, `Pre-restore rollback snapshot kept: ${rollbackSnapshot}`); finishJob(job, 'success'); } catch (error) { appendJobLog(job, error.message); @@ -90,9 +105,15 @@ async function runRestoreJob(job, snapshotId) { log: (line) => appendJobLog(job, line), }).catch((cleanupError) => appendJobLog(job, cleanupError.message)); } - if (!diskWriteOk) { - appendJobLog(job, 'VM was not restarted because disk restore did not complete successfully.'); + if (rollbackSnapshotCreated && !diskWriteOk) { + 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); } } diff --git a/docs/deployment.md b/docs/deployment.md index 825cce5..1db2836 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -15,11 +15,11 @@ Important `.env` values: ```env PORT=3000 -API_TOKEN="long-random-token" +API_TOKEN="long-random-token-at-least-32-characters" 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: @@ -49,8 +49,13 @@ SESSION_SECRET="long-random-secret" AUTH_USERNAME="admin" AUTH_PASSWORD="initial-password" 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: ```bash diff --git a/frontend/src/api.js b/frontend/src/api.js index ac94402..4ab791b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -6,22 +6,6 @@ export const api = axios.create({ 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) { return error.response?.data?.error || error.message || 'Request failed.'; } diff --git a/management/.env.example b/management/.env.example index 5914720..e0c4fbb 100644 --- a/management/.env.example +++ b/management/.env.example @@ -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 diff --git a/management/src/auth.js b/management/src/auth.js index 62e9ea6..d414df9 100644 --- a/management/src/auth.js +++ b/management/src/auth.js @@ -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}`; +} diff --git a/management/src/config.js b/management/src/config.js index d69cc88..42424d8 100644 --- a/management/src/config.js +++ b/management/src/config.js @@ -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', }; diff --git a/management/src/db.js b/management/src/db.js index 539c087..9483386 100644 --- a/management/src/db.js +++ b/management/src/db.js @@ -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); } } diff --git a/management/src/index.js b/management/src/index.js index 9210a26..b2702fd 100644 --- a/management/src/index.js +++ b/management/src/index.js @@ -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); diff --git a/management/src/routes/auth.js b/management/src/routes/auth.js index 9c3688c..6b6e4de 100644 --- a/management/src/routes/auth.js +++ b/management/src/routes/auth.js @@ -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 }); +} diff --git a/management/src/routes/nodes.js b/management/src/routes/nodes.js index e64c73c..52cfefb 100644 --- a/management/src/routes/nodes.js +++ b/management/src/routes/nodes.js @@ -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; +}