mirror of
https://github.com/Ride-The-Lightning/RTL.git
synced 2026-08-13 12:33:07 +02:00
Reduce exposure of authentication secrets in logs and config responses
This commit is contained in:
parent
79ca7182a5
commit
96a1020134
9 changed files with 155 additions and 12 deletions
|
|
@ -299,6 +299,10 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ export class CommonService {
|
|||
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('rpcuser') || keys[i].toLowerCase().includes('secret2fa') ||
|
||||
keys[i].toLowerCase().includes('cookievalue'))) {
|
||||
obj[keys[i]] = '*'.repeat(20);
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +56,10 @@ export class CommonService {
|
|||
delete config.multiPass;
|
||||
delete config.multiPassHashed;
|
||||
delete config.secret2FA;
|
||||
// The SSO cookie is a live bearer credential; it must never leave the server.
|
||||
if (config.SSO) {
|
||||
delete config.SSO.cookieValue;
|
||||
}
|
||||
config.nodes?.forEach((node) => this.removeAuthSecureData(node));
|
||||
return config;
|
||||
};
|
||||
|
|
@ -63,6 +68,9 @@ export class CommonService {
|
|||
config.rtlPass = this.appConfig.rtlPass;
|
||||
config.multiPassHashed = this.appConfig.multiPassHashed;
|
||||
config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath;
|
||||
// cookieValue is stripped from client responses, so a settings save can never echo it;
|
||||
// restore the server-held value or the save would silently wipe the live SSO cookie.
|
||||
config.SSO.cookieValue = this.appConfig.SSO.cookieValue;
|
||||
if (this.appConfig.multiPass) {
|
||||
config.multiPass = this.appConfig.multiPass;
|
||||
}
|
||||
|
|
@ -103,7 +111,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 +129,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 +175,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 +245,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 lnApiPassword
|
||||
// and any other credential fields 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(JSON.parse(JSON.stringify(this.common.nodes[idx])))) });
|
||||
const log_file = this.common.nodes[idx].settings.logFile;
|
||||
if (fs.existsSync(log_file || '')) {
|
||||
fs.writeFile((log_file || ''), '', () => { });
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ this release should add its entry under the appropriate section below.
|
|||
(`test/backend/authenticate.test.mjs`). Users who have two-factor authentication enabled
|
||||
are encouraged to update promptly.
|
||||
|
||||
- **Config & logging: reduce exposure of authentication secrets**
|
||||
([#TBD](https://github.com/Ride-The-Lightning/RTL/pull/TBD)).
|
||||
Tightens redaction of authentication material in node logs and configuration API
|
||||
responses, and keeps runtime-only SSO state out of the persisted config file. Adds
|
||||
regression coverage (`test/backend/common.test.mjs`). Users are encouraged to update
|
||||
promptly.
|
||||
|
||||
## Code Health
|
||||
|
||||
- **Batch dependency update resolving the open Dependabot security PRs**
|
||||
|
|
|
|||
|
|
@ -299,6 +299,8 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export class CommonService {
|
|||
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('rpcuser') || keys[i].toLowerCase().includes('secret2fa') ||
|
||||
keys[i].toLowerCase().includes('cookievalue'))
|
||||
) {
|
||||
obj[keys[i]] = '*'.repeat(20);
|
||||
}
|
||||
|
|
@ -63,6 +64,8 @@ export class CommonService {
|
|||
delete config.multiPass;
|
||||
delete config.multiPassHashed;
|
||||
delete config.secret2FA;
|
||||
// The SSO cookie is a live bearer credential; it must never leave the server.
|
||||
if (config.SSO) { delete config.SSO.cookieValue; }
|
||||
config.nodes?.forEach((node) => this.removeAuthSecureData(node));
|
||||
return config;
|
||||
};
|
||||
|
|
@ -72,6 +75,9 @@ export class CommonService {
|
|||
config.rtlPass = this.appConfig.rtlPass;
|
||||
config.multiPassHashed = this.appConfig.multiPassHashed;
|
||||
config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath;
|
||||
// cookieValue is stripped from client responses, so a settings save can never echo it;
|
||||
// restore the server-held value or the save would silently wipe the live SSO cookie.
|
||||
config.SSO.cookieValue = this.appConfig.SSO.cookieValue;
|
||||
if (this.appConfig.multiPass) {
|
||||
config.multiPass = this.appConfig.multiPass;
|
||||
}
|
||||
|
|
@ -112,7 +118,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;
|
||||
};
|
||||
|
||||
|
|
@ -130,7 +136,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;
|
||||
};
|
||||
|
||||
|
|
@ -179,7 +185,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' };
|
||||
} catch (err) {
|
||||
|
|
@ -247,7 +253,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,7 +284,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 lnApiPassword
|
||||
// and any other credential fields 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(JSON.parse(JSON.stringify(this.common.nodes[idx])))) });
|
||||
const log_file = this.common.nodes[idx].settings.logFile;
|
||||
if (fs.existsSync(log_file || '')) {
|
||||
fs.writeFile((log_file || ''), '', () => { });
|
||||
|
|
|
|||
41
test/backend/common.test.mjs
Normal file
41
test/backend/common.test.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { Common } from '../../backend/utils/common.js';
|
||||
|
||||
test('maskPasswords masks TOTP and SSO cookie secrets along with passwords', () => {
|
||||
const config = {
|
||||
secret2FA: 'JBSWY3DPEHPK3PXP',
|
||||
multiPassHashed: 'password-hash',
|
||||
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', cookieValue: 'live-sso-cookie' },
|
||||
nodes: [{ index: 1, authentication: { lnApiPassword: 'eclair-pass', macaroonPath: '/macaroon/path' } }]
|
||||
};
|
||||
const masked = Common.maskPasswords(config);
|
||||
assert.equal(masked.secret2FA, '*'.repeat(20));
|
||||
assert.equal(masked.SSO.cookieValue, '*'.repeat(20));
|
||||
assert.equal(masked.multiPassHashed, '*'.repeat(20));
|
||||
assert.equal(masked.nodes[0].authentication.lnApiPassword, '*'.repeat(20));
|
||||
// Paths are configuration, not secrets — they must stay visible for the settings UI.
|
||||
assert.equal(masked.nodes[0].authentication.macaroonPath, '/macaroon/path');
|
||||
assert.equal(masked.SSO.rtlCookiePath, '/cookie-path');
|
||||
});
|
||||
|
||||
test('removeSecureData strips the SSO cookie along with the other secrets', () => {
|
||||
const config = {
|
||||
rtlConfFilePath: '/conf',
|
||||
rtlPass: 'password-hash',
|
||||
multiPassHashed: 'password-hash',
|
||||
secret2FA: 'JBSWY3DPEHPK3PXP',
|
||||
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' },
|
||||
nodes: [{ index: 1, authentication: { macaroonPath: '/macaroon/path', runeValue: 'rune', options: {} } }]
|
||||
};
|
||||
const cleaned = Common.removeSecureData(config);
|
||||
assert.equal(cleaned.rtlConfFilePath, undefined);
|
||||
assert.equal(cleaned.rtlPass, undefined);
|
||||
assert.equal(cleaned.multiPassHashed, undefined);
|
||||
assert.equal(cleaned.secret2FA, undefined);
|
||||
assert.equal(cleaned.SSO.cookieValue, undefined);
|
||||
// Non-secret SSO settings survive — the settings UI renders them.
|
||||
assert.equal(cleaned.SSO.rtlCookiePath, '/cookie-path');
|
||||
assert.equal(cleaned.nodes[0].authentication.macaroonPath, undefined);
|
||||
});
|
||||
|
|
@ -137,3 +137,74 @@ test('updateApplicationSettings preserves indexed node auth and sanitizes only p
|
|||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateApplicationSettings keeps the SSO cookie server-side without exposing or persisting it', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-sso-'));
|
||||
const oldConfig = {
|
||||
defaultNodeIndex: 0,
|
||||
dbDirectoryPath: '/db',
|
||||
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' },
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
lnNode: 'lnd-main',
|
||||
lnImplementation: 'LND',
|
||||
authentication: { macaroonPath: '/lnd/admin' },
|
||||
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
|
||||
}
|
||||
]
|
||||
};
|
||||
const runtimeConfig = clone({
|
||||
...oldConfig,
|
||||
selectedNodeIndex: 0,
|
||||
enable2FA: false,
|
||||
allowPasswordUpdate: true,
|
||||
rtlConfFilePath: tempDir,
|
||||
rtlPass: 'hashed-password',
|
||||
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' }
|
||||
});
|
||||
// The request carries only what the sanitized client can have seen: no cookieValue.
|
||||
// The server must re-attach it — a settings save must never wipe the live cookie —
|
||||
// while keeping it out of both the response and the persisted file.
|
||||
const requestBody = {
|
||||
...clone(oldConfig),
|
||||
selectedNodeIndex: 0,
|
||||
enable2FA: false,
|
||||
allowPasswordUpdate: true,
|
||||
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' }
|
||||
};
|
||||
|
||||
try {
|
||||
Common.appConfig = clone(runtimeConfig);
|
||||
Common.nodes = clone(runtimeConfig.nodes);
|
||||
Common.selectedNode = Common.nodes[0];
|
||||
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
|
||||
|
||||
let responseStatus;
|
||||
let responseBody;
|
||||
updateApplicationSettings(
|
||||
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
|
||||
{
|
||||
status: (status) => {
|
||||
responseStatus = status;
|
||||
return {
|
||||
json: (body) => {
|
||||
responseBody = body;
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal(responseStatus, 201);
|
||||
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
|
||||
assert.equal(Common.appConfig.SSO.rtlCookiePath, '/cookie-path');
|
||||
const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8'));
|
||||
assert.equal(fileConfig.SSO.cookieValue, undefined);
|
||||
assert.equal(responseBody.SSO.cookieValue, undefined);
|
||||
} finally {
|
||||
clearInterval(WSServer.pingInterval);
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue