diff --git a/CLAUDE.md b/CLAUDE.md index 609fd810..2b869343 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,8 +91,10 @@ Eclair, wired to RTL — for end-to-end testing across all three implementations `docker/README.md`, or the `rtl-docker-fixture` skill in `.claude/skills/`. It is dev-only; every credential in it is throwaway. -Backend code has no unit-test harness; `npm run test` runs the frontend Karma/Jasmine specs. -For backend changes, verify against the fixture and say so in the PR. +Backend regression tests live in `test/backend/` (plain `node:test`, run against the +compiled `backend/`). `npm run test` compiles the backend, then runs them +(`npm run testbackend`) before the frontend Karma/Jasmine specs, so they never test stale +code. For backend changes, also verify against the fixture and say so in the PR. ## Conventions diff --git a/backend/controllers/eclair/channels.js b/backend/controllers/eclair/channels.js index ea433bc8..b2a0a26b 100644 --- a/backend/controllers/eclair/channels.js +++ b/backend/controllers/eclair/channels.js @@ -53,7 +53,10 @@ export const getChannels = (req, res, next) => { options.form = req.query; logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels Node Id', data: options.form }); } - logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: options }); + // Log the call's shape only. Eclair authenticates with HTTP basic auth, so the options + // object carries the node's lnApiPassword in its authorization header, and node logs are + // routinely shared when debugging. + logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: { url: options.url, form: options.form } }); if (common.read_dummy_data) { common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); }); } diff --git a/backend/controllers/lnd/channels.js b/backend/controllers/lnd/channels.js index fdcdd8d9..ac9e9daf 100644 --- a/backend/controllers/lnd/channels.js +++ b/backend/controllers/lnd/channels.js @@ -4,10 +4,10 @@ import { Common } from '../../utils/common.js'; let options = null; const logger = Logger; const common = Common; -export const getAliasForChannel = (selNode, channel) => { +export const getAliasForChannel = (selNode, channel, requestOptions) => { const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : ''; - options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey; - return request(options).then((aliasBody) => { + requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey; + return request(requestOptions).then((aliasBody) => { logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Channels', msg: 'Alias Received', data: aliasBody.node.alias }); channel.remote_alias = aliasBody.node.alias && aliasBody.node.alias !== '' ? aliasBody.node.alias : aliasBody.node.pub_key.slice(0, 20); return channel; @@ -30,18 +30,26 @@ export const getAllChannels = (req, res, next) => { request(options).then((body) => { logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels List Received', data: body }); if (body.channels) { - return Promise.all(body.channels?.map((channel) => { + body.channels.forEach((channel) => { local = (channel.local_balance) ? +channel.local_balance : 0; remote = (channel.remote_balance) ? +channel.remote_balance : 0; total = local + remote; channel.balancedness = (total === 0) ? 1 : (1 - Math.abs((local - remote) / total)).toFixed(3); - return getAliasForChannel(req.session.selectedNode, channel); - })).then((values) => { - logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body }); - return res.status(200).json(body); - }).catch((errRes) => { - const err = common.handleError(errRes, 'Channels', 'Get All Channel Aliases Error', req.session.selectedNode); - return res.status(err.statusCode).json({ message: err.message, error: err.error }); + }); + const selNode = req.session.selectedNode; + const { qs: _qs, ...requestOptions } = options; + const getChannelAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions })); + common.runWithConcurrencyLimit(getChannelAliasesTasks, 20, () => { + try { + logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body }); + return res.status(200).json(body); + } + catch (e) { + logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get All Channel Aliases Error', error: e.message }); + if (!res.headersSent) { + res.status(500).json({ message: 'Get All Channel Aliases Error', error: e.message }); + } + } }); } else { @@ -66,26 +74,32 @@ export const getPendingChannels = (req, res, next) => { if (!body.total_limbo_balance) { body.total_limbo_balance = 0; } - const promises = []; + const selNode = req.session.selectedNode; + const { qs: _qs, ...requestOptions } = options; + const getPendingAliasesTasks = []; if (body.pending_open_channels && body.pending_open_channels.length > 0) { - body.pending_open_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); + body.pending_open_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions }))); } if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) { - body.pending_force_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); + body.pending_force_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions }))); } if (body.pending_closing_channels && body.pending_closing_channels.length > 0) { - body.pending_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); + body.pending_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions }))); } if (body.waiting_close_channels && body.waiting_close_channels.length > 0) { - body.waiting_close_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); + body.waiting_close_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions }))); } - return Promise.all(promises).then((values) => { - logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body }); - return res.status(200).json(body); - }). - catch((errRes) => { - const err = common.handleError(errRes, 'Channels', 'Get Pending Channel Aliases Error', req.session.selectedNode); - return res.status(err.statusCode).json({ message: err.message, error: err.error }); + common.runWithConcurrencyLimit(getPendingAliasesTasks, 20, () => { + try { + logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body }); + return res.status(200).json(body); + } + catch (e) { + logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Pending Channel Aliases Error', error: e.message }); + if (!res.headersSent) { + res.status(500).json({ message: 'Get Pending Channel Aliases Error', error: e.message }); + } + } }); }).catch((errRes) => { const err = common.handleError(errRes, 'Channels', 'List Pending Channels Error', req.session.selectedNode); @@ -102,15 +116,23 @@ export const getClosedChannels = (req, res, next) => { options.qs = req.query; request(options).then((body) => { if (body.channels && body.channels.length > 0) { - return Promise.all(body.channels?.map((channel) => { + body.channels.forEach((channel) => { channel.close_type = (!channel.close_type) ? 'COOPERATIVE_CLOSE' : channel.close_type; - return getAliasForChannel(req.session.selectedNode, channel); - })).then((values) => { - logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body }); - return res.status(200).json(body); - }).catch((errRes) => { - const err = common.handleError(errRes, 'Channels', 'Get Closed Channel Aliases Error', req.session.selectedNode); - return res.status(err.statusCode).json({ message: err.message, error: err.error }); + }); + const selNode = req.session.selectedNode; + const { qs: _qs, ...requestOptions } = options; + const getClosedAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions })); + common.runWithConcurrencyLimit(getClosedAliasesTasks, 20, () => { + try { + logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body }); + return res.status(200).json(body); + } + catch (e) { + logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Closed Channel Aliases Error', error: e.message }); + if (!res.headersSent) { + res.status(500).json({ message: 'Get Closed Channel Aliases Error', error: e.message }); + } + } }); } else { diff --git a/backend/controllers/lnd/graph.js b/backend/controllers/lnd/graph.js index 353cb1ae..49146f12 100644 --- a/backend/controllers/lnd/graph.js +++ b/backend/controllers/lnd/graph.js @@ -4,9 +4,9 @@ import { Common } from '../../utils/common.js'; let options = null; const logger = Logger; const common = Common; -export const getAliasFromPubkey = (selNode, pubkey) => { - options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey; - return request(options).then((res) => { +export const getAliasFromPubkey = (selNode, pubkey, requestOptions) => { + requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey; + return request(requestOptions).then((res) => { logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Graph', msg: 'Alias Received', data: res.node.alias }); return res.node.alias; }). @@ -83,19 +83,25 @@ export const getQueryRoutes = (req, res, next) => { request(options).then((body) => { logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Graph', msg: 'Query Routes Received', data: body }); if (body.routes && body.routes.length && body.routes.length > 0 && body.routes[0].hops && body.routes[0].hops.length && body.routes[0].hops.length > 0) { - return Promise.all(body.routes[0].hops?.map((hop) => getAliasFromPubkey(req.session.selectedNode, hop.pub_key))). - then((values) => { - body.routes[0].hops?.map((hop, i) => { - hop.hop_sequence = i + 1; - hop.pubkey_alias = values[i]; - return hop; - }); - logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes with Alias Received', data: body }); - res.status(200).json(body); - }). - catch((errRes) => { - const err = common.handleError(errRes, 'Graph', 'Get Query Routes Error', req.session.selectedNode); - return res.status(err.statusCode).json({ message: err.message, error: err.error }); + const selNode = req.session.selectedNode; + const { qs: _qs, ...requestOptions } = options; + const getRouteAliasesTasks = body.routes[0].hops.map((hop) => () => getAliasFromPubkey(selNode, hop.pub_key, { ...requestOptions })); + common.runWithConcurrencyLimit(getRouteAliasesTasks, 20, (values) => { + try { + body.routes[0].hops?.map((hop, i) => { + hop.hop_sequence = i + 1; + hop.pubkey_alias = typeof values[i] === 'string' ? values[i] : 'Unknown'; + return hop; + }); + logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes with Alias Received', data: body }); + res.status(200).json(body); + } + catch (e) { + logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Query Routes Error', error: e.message }); + if (!res.headersSent) { + res.status(500).json({ message: 'Get Query Routes Error', error: e.message }); + } + } }); } else { @@ -145,14 +151,21 @@ export const getAliasesForPubkeys = (req, res, next) => { } if (req.query.pubkeys) { const pubkeyArr = req.query.pubkeys.split(','); - return Promise.all(pubkeyArr?.map((pubkey) => getAliasFromPubkey(req.session.selectedNode, pubkey))). - then((values) => { - logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: values }); - res.status(200).json(values); - }). - catch((errRes) => { - const err = common.handleError(errRes, 'Graph', 'Get Aliases for Pubkeys Error', req.session.selectedNode); - return res.status(err.statusCode).json({ message: err.message, error: err.error }); + const selNode = req.session.selectedNode; + const { qs: _qs, ...requestOptions } = options; + const getAliasesTasks = pubkeyArr.map((pubkey) => () => getAliasFromPubkey(selNode, pubkey, { ...requestOptions })); + common.runWithConcurrencyLimit(getAliasesTasks, 20, (values) => { + try { + const safeValues = values.map((v) => (typeof v === 'string' ? v : 'Unknown')); + logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: safeValues }); + res.status(200).json(safeValues); + } + catch (e) { + logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Aliases for Pubkeys Error', error: e.message }); + if (!res.headersSent) { + res.status(500).json({ message: 'Get Aliases for Pubkeys Error', error: e.message }); + } + } }); } else { diff --git a/backend/controllers/shared/RTLConf.js b/backend/controllers/shared/RTLConf.js index 35270a0a..60f35764 100644 --- a/backend/controllers/shared/RTLConf.js +++ b/backend/controllers/shared/RTLConf.js @@ -1,6 +1,6 @@ import jwt from 'jsonwebtoken'; import * as fs from 'fs'; -import { sep } from 'path'; +import { resolve, sep } from 'path'; import ini from 'ini'; import parseHocon from 'hocon-parser'; import request from '../../utils/request.js'; @@ -76,7 +76,23 @@ export const getCurrencyRates = (req, res, next) => { }; export const getFile = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting File..' }); - const file = req.query.path ? req.query.path : (req.session.selectedNode.settings.channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak'); + const channelBackupPath = req.session.selectedNode.settings.channelBackupPath; + let file = ''; + if (req.query.path) { + // The UI only ever requests channel backup files; contain caller paths to the node's + // backup directory so this endpoint cannot read the config, macaroons or the SSO + // cookie (getConfig serves the config file masked; this must not bypass that). + const resolved = resolve(req.query.path); + if (resolved !== resolve(channelBackupPath) && !resolved.startsWith(resolve(channelBackupPath) + sep)) { + logger.log({ selectedNode: req.session.selectedNode, level: 'WARN', fileName: 'RTLConf', msg: 'Blocked file read outside the channel backup directory', data: req.query.path }); + const err = common.handleError({ statusCode: 403, message: 'Reading File Error', error: 'File path is outside the channel backup directory' }, 'RTLConf', 'Reading File Error', req.session.selectedNode); + return res.status(err.statusCode).json({ message: err.message, error: err.error }); + } + file = resolved; + } + else { + file = channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak'; + } logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'Channel Point', data: req.query.channel }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: file }); fs.readFile(file, 'utf8', (errRes, data) => { @@ -89,7 +105,8 @@ export const getFile = (req, res, next) => { return res.status(err.statusCode).json({ message: err.error, error: err.error }); } else { - logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received', data: data }); + // File contents can carry node credentials; never write them to the log. + logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received' }); res.status(200).json(data); } }); @@ -109,7 +126,6 @@ export const getApplicationSettings = (req, res, next) => { delete appConfData.SSO.rtlCookiePath; delete appConfData.SSO.cookieValue; delete appConfData.SSO.logoutRedirectLink; - appConfData.secret2FA = ''; appConfData.dbDirectoryPath = ''; appConfData.nodes[selNodeIdx].authentication = new Authentication(); delete appConfData.nodes[selNodeIdx].settings.bitcoindConfigPath; @@ -201,7 +217,12 @@ export const updateNodeSettings = (req, res, next) => { const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8')); const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index)); if (node && node.settings) { + // channelBackupPath anchors getFile's containment root and is documented as a + // config-file-only setting; accepting it from the API would let the caller being + // contained choose the containment base. Pin it to the server-held value. + const serverChannelBackupPath = node.settings.channelBackupPath; node.settings = { ...node.settings, ...req.body.settings }; + node.settings.channelBackupPath = serverChannelBackupPath; if (node.authentication && req.body.authentication) { if (req.body.authentication.boltzMacaroonPath) { node.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; @@ -220,7 +241,9 @@ export const updateNodeSettings = (req, res, next) => { fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8'); const selectedNode = common.findNode(req.session.selectedNode.index); if (selectedNode && selectedNode.settings) { + const serverChannelBackupPath = selectedNode.settings.channelBackupPath; selectedNode.settings = { ...selectedNode.settings, ...req.body.settings }; + selectedNode.settings.channelBackupPath = serverChannelBackupPath; if (selectedNode.authentication && req.body.authentication) { if (req.body.authentication.boltzMacaroonPath) { selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; @@ -281,7 +304,7 @@ export const updateApplicationSettings = (req, res, next) => { const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode))); runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes]; } - common.appConfig = JSON.parse(JSON.stringify({ + const newAppConfig = JSON.parse(JSON.stringify({ ...runtimeConfig, selectedNodeIndex: config.selectedNodeIndex !== undefined ? config.selectedNodeIndex : common.appConfig.selectedNodeIndex, @@ -292,21 +315,42 @@ export const updateApplicationSettings = (req, res, next) => { rtlConfFilePath: common.appConfig.rtlConfFilePath, rtlPass: common.appConfig.rtlPass })); - const fileConfig = JSON.parse(JSON.stringify(common.appConfig)); + const fileConfig = JSON.parse(JSON.stringify(newAppConfig)); delete fileConfig.selectedNodeIndex; delete fileConfig.enable2FA; delete fileConfig.allowPasswordUpdate; delete fileConfig.rtlConfFilePath; delete fileConfig.rtlPass; delete fileConfig.multiPass; + // Runtime-only SSO bearer; must not be persisted with the config. + if (fileConfig.SSO) { + delete fileConfig.SSO.cookieValue; + } fileConfig.nodes?.forEach((node) => { delete node.authentication?.options; delete node.authentication?.runeValue; }); - fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8'); - const newConfig = JSON.parse(JSON.stringify(common.appConfig)); - logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.maskPasswords(newConfig) }); - res.status(201).json(common.removeSecureData(newConfig)); + // Persist atomically (temp file + rename, so a mid-write failure cannot truncate the + // config) and only then adopt the new runtime config, so a failed write leaves the + // process on the old one. The temp file inherits the existing file's mode so a + // hardened 0600 is not silently downgraded; a fresh file gets 0600. Symlinks and + // single-file bind mounts cannot be renamed over — fall back to an in-place write, + // which preserves inode and mode. + const tempConfigFile = RTLConfFile + '.tmp'; + try { + fs.writeFileSync(tempConfigFile, JSON.stringify(fileConfig, null, 2), 'utf-8'); + fs.chmodSync(tempConfigFile, fs.existsSync(RTLConfFile) ? (fs.statSync(RTLConfFile).mode & 0o777) : 0o600); + fs.renameSync(tempConfigFile, RTLConfFile); + } + catch { + fs.rmSync(tempConfigFile, { force: true, recursive: true }); + fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8'); + } + common.appConfig = newAppConfig; + // removeSecureData clones, so the runtime config is untouched; it strips rtlPass, + // the TOTP seed, the SSO cookie and all per-node credentials symmetrically. + logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.removeSecureData(newAppConfig) }); + res.status(201).json(common.removeSecureData(newAppConfig)); } catch (errRes) { const errMsg = 'Update Default Node Error'; diff --git a/backend/controllers/shared/authenticate.js b/backend/controllers/shared/authenticate.js index 62b96ec3..13b64988 100644 --- a/backend/controllers/shared/authenticate.js +++ b/backend/controllers/shared/authenticate.js @@ -19,6 +19,9 @@ const loginInterval = setInterval(() => { } } }, LOCKING_PERIOD); +// The sweeper must not hold the event loop open on its own (it would keep +// `node --test` or a CLI invocation alive for the full 30-minute period). +loginInterval.unref(); export const getFailedInfo = (reqIP, currentTime) => { let failed = { count: 0, lastTried: currentTime }; if ((!failedLoginAttempts[reqIP]) || (currentTime > (failed.lastTried + LOCKING_PERIOD))) { @@ -45,6 +48,21 @@ const handleMultipleFailedAttemptsError = (failed, currentTime, errMsg) => { } }; export const verifyToken = (twoFAToken) => !!(common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && otplib.authenticator.check(twoFAToken, common.appConfig.secret2FA)); +// Mirrors isAuthenticated: a request carrying a valid session JWT has already +// completed 2FA at login, since tokens are only minted after verification when +// 2FA is enabled. Used to exempt in-app re-authorization (e.g. the password +// prompt before on-chain sends) from the TOTP requirement without opening a +// password-only path. +const hasValidAuthToken = (req) => { + try { + const token = req.headers.authorization.split(' ')[1]; + jwt.verify(token, common.secret_key); + return true; + } + catch (error) { + return false; + } +}; export const authenticateUser = (req, res, next) => { const { authenticateWith, authenticationValue, twoFAToken } = req.body; logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Authenticate', msg: 'Authenticating User..' }); @@ -84,8 +102,15 @@ export const authenticateUser = (req, res, next) => { const failed = getFailedInfo(reqIP, currentTime); const password = authenticationValue; if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) { - if (twoFAToken && twoFAToken !== '') { - if (!verifyToken(twoFAToken)) { + // Gate on the server-side 2FA configuration, not on the request: when 2FA is + // enabled a token is mandatory, so a request omitting twoFAToken is rejected + // instead of silently skipping verification. The login UI keys its token prompt + // on enable2FA, so both fields are consulted — a stale secret with 2FA disabled + // must not lock the operator out of a UI that never prompts for a token. + // Requests with a valid session token (in-app re-authorization, e.g. the + // password prompt before on-chain sends) are exempt from the TOTP requirement. + if (common.appConfig.enable2FA && common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && !hasValidAuthToken(req)) { + if (typeof twoFAToken !== 'string' || twoFAToken === '' || !verifyToken(twoFAToken)) { logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Authenticate', msg: 'Invalid Token! Failed IP ' + reqIP, error: { error: 'Invalid token.' } }); failed.count = failed.count + 1; failed.lastTried = currentTime; diff --git a/backend/routes/shared/authenticate.js b/backend/routes/shared/authenticate.js index 0cbdbbe4..7e1559a2 100644 --- a/backend/routes/shared/authenticate.js +++ b/backend/routes/shared/authenticate.js @@ -1,9 +1,12 @@ import exprs from 'express'; const { Router } = exprs; import { authenticateUser, verifyToken, resetPassword, logoutUser } from '../../controllers/shared/authenticate.js'; +import { isAuthenticated } from '../../utils/authCheck.js'; const router = Router(); router.post('/', authenticateUser); router.post('/token', verifyToken); -router.post('/reset', resetPassword); +// Password changes mint a fresh session token, so the route requires an existing +// authenticated session; the frontend interceptor attaches it for the settings UI. +router.post('/reset', isAuthenticated, resetPassword); router.get('/logout', logoutUser); export default router; diff --git a/backend/utils/common.js b/backend/utils/common.js index 9de430f9..5ffd6a11 100644 --- a/backend/utils/common.js +++ b/backend/utils/common.js @@ -22,22 +22,37 @@ export class CommonService { { name: 'JUL', days: 31 }, { name: 'AUG', days: 31 }, { name: 'SEP', days: 30 }, { name: 'OCT', days: 31 }, { name: 'NOV', days: 30 }, { name: 'DEC', days: 31 } ]; this.maskPasswords = (obj) => { - const keys = Object.keys(obj); - const length = keys.length; - if (length !== 0) { - for (let i = 0; i < length; i++) { - if (typeof obj[keys[i]] === 'object') { - keys[keys[i]] = this.maskPasswords(obj[keys[i]]); - } - if (typeof keys[i] === 'string' && - ((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') || - keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') || - keys[i].toLowerCase().includes('rpcuser'))) { - obj[keys[i]] = '*'.repeat(20); + // Clone up front: masking a live config object must not blank the credentials LN + // requests authenticate with (mirrors removeSecureData). + const masked = JSON.parse(JSON.stringify(obj)); + const maskRecursive = (current) => { + const keys = Object.keys(current); + const length = keys.length; + if (length !== 0) { + for (let i = 0; i < length; i++) { + // Header maps always carry credentials in this codebase (macaroon, rune, basic + // auth). Key-substring matching cannot catch them without also hiding the *Path + // fields the settings UI legitimately shows, so mask the whole map. + if (keys[i] === 'headers' && current[keys[i]] && typeof current[keys[i]] === 'object') { + Object.keys(current[keys[i]]).forEach((headerKey) => { current[keys[i]][headerKey] = '*'.repeat(20); }); + } + else if (current[keys[i]] && typeof current[keys[i]] === 'object') { + // Truthiness guard: null is 'object' too and must not reach Object.keys. + maskRecursive(current[keys[i]]); + } + if (typeof keys[i] === 'string' && + ((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') || + keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') || + keys[i].toLowerCase().includes('rpcuser') || keys[i].toLowerCase().includes('rpcauth') || + keys[i].toLowerCase().includes('secret2fa') || keys[i].toLowerCase().includes('cookievalue') || + keys[i].toLowerCase().includes('rtlpass') || keys[i].toLowerCase().includes('runevalue'))) { + current[keys[i]] = '*'.repeat(20); + } } } - } - return obj; + return current; + }; + return maskRecursive(masked); }; this.removeAuthSecureData = (node) => { if (node.authentication) { @@ -50,25 +65,55 @@ export class CommonService { return node; }; this.removeSecureData = (config) => { - delete config.rtlConfFilePath; - delete config.rtlPass; - delete config.multiPass; - delete config.multiPassHashed; - delete config.secret2FA; - config.nodes?.forEach((node) => this.removeAuthSecureData(node)); - return config; + // Clone before deleting: cookieValue is runtime-only, so mutating a caller's live + // appConfig would destroy SSO state with no way to restore it. + const sanitized = JSON.parse(JSON.stringify(config)); + delete sanitized.rtlConfFilePath; + delete sanitized.rtlPass; + delete sanitized.multiPass; + delete sanitized.multiPassHashed; + delete sanitized.secret2FA; + // The SSO cookie is a live bearer credential; it must never leave the server. + if (sanitized.SSO) { + delete sanitized.SSO.cookieValue; + } + sanitized.nodes?.forEach((node) => this.removeAuthSecureData(node)); + return sanitized; }; this.addSecureData = (config) => { config.rtlConfFilePath = this.appConfig.rtlConfFilePath; config.rtlPass = this.appConfig.rtlPass; - config.multiPassHashed = this.appConfig.multiPassHashed; - config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath; + // Pin the hash only when the server holds one: on a default install's first boot the + // file already has multiPassHashed but the in-memory config does not, and pinning + // undefined would erase the only password from the file on save, bricking the boot. + if (this.appConfig.multiPassHashed) { + config.multiPassHashed = this.appConfig.multiPassHashed; + } + else { + delete config.multiPassHashed; + } + // Deployment-level switches are pinned to server-held values: the settings API must + // not flip the authentication mode (disableAuth, SSO) or move SSO fields, the + // password policy, or the database location; no UI flow writes them. Pinning the + // whole SSO object also means a trimmed or missing SSO object can never wipe server + // state. + config.disableAuth = this.appConfig.disableAuth; + config.allowPasswordUpdate = this.appConfig.allowPasswordUpdate; + config.dbDirectoryPath = this.appConfig.dbDirectoryPath; + config.SSO = JSON.parse(JSON.stringify(this.appConfig.SSO || {})); if (this.appConfig.multiPass) { config.multiPass = this.appConfig.multiPass; } - if (config.secret2FA === this.appConfig.secret2FA) { + // Restore the TOTP seed when the client omits it — and when it sends an empty seed + // while still claiming 2FA is on (an inconsistent pair no honest flow produces). + // The settings UI's enable flow sends a non-empty seed; its disable flow sends an + // empty seed with enable2FA false. Both are honored. + if (config.secret2FA === undefined || (config.secret2FA === '' && config.enable2FA)) { config.secret2FA = this.appConfig.secret2FA; } + // enable2FA derives from the seed, matching the boot-time derivation in config.ts, + // so the two fields can never diverge after a save. + config.enable2FA = !!config.secret2FA; const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []); config.nodes?.forEach((node) => { const appConfigNode = appConfigNodes.get(node.index); @@ -103,7 +148,7 @@ export class CommonService { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Loop macaroon Error', error: err }); } } - this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options', data: swapOptions }); + this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options Set' }); return swapOptions; }; this.getBoltzServerOptions = (req) => { @@ -121,7 +166,7 @@ export class CommonService { this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Boltz macaroon Error', error: err }); } } - this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options', data: boltzOptions }); + this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options Set' }); return boltzOptions; }; this.getOptions = (req) => { @@ -167,7 +212,7 @@ export class CommonService { } } if (req.session.selectedNode) { - this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode, data: req.session.selectedNode.authentication.options }); + this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode }); } return { status: 200, message: 'Updated Successfully' }; } @@ -237,7 +282,7 @@ export class CommonService { form: '' }; } - this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode, data: node.authentication.options }); + this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode }); }); this.updateSelectedNodeOptions(req); } @@ -345,10 +390,11 @@ export class CommonService { this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: fileName, msg: errMsg, error: (typeof err === 'object' ? JSON.stringify(err) : err) }); let newErrorObj = { statusCode: 500, message: '', error: '' }; if (err.code && err.code === 'ENOENT') { + // The absolute path stays in the server log above but is not echoed to clients. newErrorObj = { statusCode: 500, - message: 'No such file or directory ' + (err.path ? err.path : ''), - error: 'No such file or directory ' + (err.path ? err.path : '') + message: 'No such file or directory', + error: 'No such file or directory' }; } else { diff --git a/backend/utils/config.js b/backend/utils/config.js index b001d38f..36ecca30 100644 --- a/backend/utils/config.js +++ b/backend/utils/config.js @@ -302,7 +302,9 @@ export class ConfigService { this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'Config', msg: 'Something went wrong while creating the backup directory: \n' + err }); } this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log'; - this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.nodes[idx]) }); + // maskPasswords keeps paths visible for debugging while redacting credential + // fields such as lnApiPassword before they reach the log file. + this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.maskPasswords(this.common.nodes[idx])) }); const log_file = this.common.nodes[idx].settings.logFile; if (fs.existsSync(log_file || '')) { fs.writeFile((log_file || ''), '', () => { }); diff --git a/frontend/index.html b/frontend/index.html index 7222bb90..693e03dd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,5 +15,5 @@