mirror of
https://github.com/Ride-The-Lightning/RTL.git
synced 2026-08-13 12:33:07 +02:00
Harden redaction helpers and secret restore paths
This commit is contained in:
parent
dd60ced0c1
commit
3c7119cb2a
8 changed files with 234 additions and 43 deletions
|
|
@ -309,7 +309,9 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
});
|
||||
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) });
|
||||
// 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(newConfig) });
|
||||
res.status(201).json(common.removeSecureData(newConfig));
|
||||
}
|
||||
catch (errRes) {
|
||||
|
|
|
|||
|
|
@ -26,14 +26,17 @@ export class CommonService {
|
|||
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]]);
|
||||
// Truthiness guard: null is 'object' too, and the recursive call mutates in
|
||||
// place — assigning into keys[] here clobbered the key list for numeric keys.
|
||||
if (obj[keys[i]] && typeof obj[keys[i]] === 'object') {
|
||||
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') || keys[i].toLowerCase().includes('secret2fa') ||
|
||||
keys[i].toLowerCase().includes('cookievalue'))) {
|
||||
keys[i].toLowerCase().includes('cookievalue') || keys[i].toLowerCase().includes('rtlpass') ||
|
||||
keys[i].toLowerCase().includes('runevalue'))) {
|
||||
obj[keys[i]] = '*'.repeat(20);
|
||||
}
|
||||
}
|
||||
|
|
@ -51,30 +54,41 @@ export class CommonService {
|
|||
return node;
|
||||
};
|
||||
this.removeSecureData = (config) => {
|
||||
delete config.rtlConfFilePath;
|
||||
delete config.rtlPass;
|
||||
delete config.multiPass;
|
||||
delete config.multiPassHashed;
|
||||
delete config.secret2FA;
|
||||
// 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 (config.SSO) {
|
||||
delete config.SSO.cookieValue;
|
||||
if (sanitized.SSO) {
|
||||
delete sanitized.SSO.cookieValue;
|
||||
}
|
||||
config.nodes?.forEach((node) => this.removeAuthSecureData(node));
|
||||
return config;
|
||||
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;
|
||||
// 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;
|
||||
// Merge rather than replace: sanitized client responses never carry cookieValue, and
|
||||
// a trimmed or missing SSO object must not silently wipe server-held SSO state. The
|
||||
// cookie is always pinned to the server-held value.
|
||||
config.SSO = {
|
||||
...(this.appConfig.SSO || {}),
|
||||
...(config.SSO || {}),
|
||||
rtlCookiePath: this.appConfig.SSO?.rtlCookiePath,
|
||||
cookieValue: this.appConfig.SSO?.cookieValue
|
||||
};
|
||||
if (this.appConfig.multiPass) {
|
||||
config.multiPass = this.appConfig.multiPass;
|
||||
}
|
||||
if (config.secret2FA === this.appConfig.secret2FA) {
|
||||
// Restore the TOTP seed only when the client omits it (sanitized responses never
|
||||
// include it, so an echo would otherwise wipe it). An explicit value is the settings
|
||||
// UI's enable/disable flow and is honored.
|
||||
if (config.secret2FA === undefined) {
|
||||
config.secret2FA = this.appConfig.secret2FA;
|
||||
}
|
||||
const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []);
|
||||
|
|
@ -353,10 +367,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 {
|
||||
|
|
|
|||
|
|
@ -302,8 +302,8 @@ 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';
|
||||
// maskPasswords keeps paths visible for debugging while redacting lnApiPassword
|
||||
// and any other credential fields before they reach the log file.
|
||||
// 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(JSON.parse(JSON.stringify(this.common.nodes[idx])))) });
|
||||
const log_file = this.common.nodes[idx].settings.logFile;
|
||||
if (fs.existsSync(log_file || '')) {
|
||||
|
|
|
|||
|
|
@ -307,7 +307,9 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
});
|
||||
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) });
|
||||
// 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(newConfig) });
|
||||
res.status(201).json(common.removeSecureData(newConfig));
|
||||
} catch (errRes) {
|
||||
const errMsg = 'Update Default Node Error';
|
||||
|
|
|
|||
|
|
@ -31,14 +31,17 @@ export class CommonService {
|
|||
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]]);
|
||||
// Truthiness guard: null is 'object' too, and the recursive call mutates in
|
||||
// place — assigning into keys[] here clobbered the key list for numeric keys.
|
||||
if (obj[keys[i]] && typeof obj[keys[i]] === 'object') {
|
||||
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') || keys[i].toLowerCase().includes('secret2fa') ||
|
||||
keys[i].toLowerCase().includes('cookievalue'))
|
||||
keys[i].toLowerCase().includes('cookievalue') || keys[i].toLowerCase().includes('rtlpass') ||
|
||||
keys[i].toLowerCase().includes('runevalue'))
|
||||
) {
|
||||
obj[keys[i]] = '*'.repeat(20);
|
||||
}
|
||||
|
|
@ -59,29 +62,40 @@ export class CommonService {
|
|||
};
|
||||
|
||||
public removeSecureData = (config: ApplicationConfig) => {
|
||||
delete config.rtlConfFilePath;
|
||||
delete config.rtlPass;
|
||||
delete config.multiPass;
|
||||
delete config.multiPassHashed;
|
||||
delete config.secret2FA;
|
||||
// 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 (config.SSO) { delete config.SSO.cookieValue; }
|
||||
config.nodes?.forEach((node) => this.removeAuthSecureData(node));
|
||||
return config;
|
||||
if (sanitized.SSO) { delete sanitized.SSO.cookieValue; }
|
||||
sanitized.nodes?.forEach((node) => this.removeAuthSecureData(node));
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
public addSecureData = (config: ApplicationConfig) => {
|
||||
config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
|
||||
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;
|
||||
// Merge rather than replace: sanitized client responses never carry cookieValue, and
|
||||
// a trimmed or missing SSO object must not silently wipe server-held SSO state. The
|
||||
// cookie is always pinned to the server-held value.
|
||||
config.SSO = {
|
||||
...(this.appConfig.SSO || {}),
|
||||
...(config.SSO || {}),
|
||||
rtlCookiePath: this.appConfig.SSO?.rtlCookiePath,
|
||||
cookieValue: this.appConfig.SSO?.cookieValue
|
||||
};
|
||||
if (this.appConfig.multiPass) {
|
||||
config.multiPass = this.appConfig.multiPass;
|
||||
}
|
||||
if (config.secret2FA === this.appConfig.secret2FA) {
|
||||
// Restore the TOTP seed only when the client omits it (sanitized responses never
|
||||
// include it, so an echo would otherwise wipe it). An explicit value is the settings
|
||||
// UI's enable/disable flow and is honored.
|
||||
if (config.secret2FA === undefined) {
|
||||
config.secret2FA = this.appConfig.secret2FA;
|
||||
}
|
||||
const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []);
|
||||
|
|
@ -370,10 +384,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 {
|
||||
newErrorObj = {
|
||||
|
|
|
|||
|
|
@ -284,8 +284,8 @@ 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';
|
||||
// maskPasswords keeps paths visible for debugging while redacting lnApiPassword
|
||||
// and any other credential fields before they reach the log file.
|
||||
// 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(JSON.parse(JSON.stringify(this.common.nodes[idx])))) });
|
||||
const log_file = this.common.nodes[idx].settings.logFile;
|
||||
if (fs.existsSync(log_file || '')) {
|
||||
|
|
|
|||
|
|
@ -39,3 +39,39 @@ test('removeSecureData strips the SSO cookie along with the other secrets', () =
|
|||
assert.equal(cleaned.SSO.rtlCookiePath, '/cookie-path');
|
||||
assert.equal(cleaned.nodes[0].authentication.macaroonPath, undefined);
|
||||
});
|
||||
|
||||
test('removeSecureData does not mutate its input', () => {
|
||||
// cookieValue is runtime-only: if a caller ever passes the live appConfig, an in-place
|
||||
// delete would wipe SSO state with no way to restore it. The function must clone.
|
||||
const config = { rtlPass: 'password-hash', SSO: { cookieValue: 'live-sso-cookie' }, nodes: [] };
|
||||
Common.removeSecureData(config);
|
||||
assert.equal(config.rtlPass, 'password-hash');
|
||||
assert.equal(config.SSO.cookieValue, 'live-sso-cookie');
|
||||
});
|
||||
|
||||
test('maskPasswords masks rtlPass and runeValue', () => {
|
||||
const config = {
|
||||
rtlPass: 'login-hash',
|
||||
nodes: [{ index: 1, authentication: { runeValue: 'cln-rune' } }]
|
||||
};
|
||||
const masked = Common.maskPasswords(config);
|
||||
assert.equal(masked.rtlPass, '*'.repeat(20));
|
||||
assert.equal(masked.nodes[0].authentication.runeValue, '*'.repeat(20));
|
||||
});
|
||||
|
||||
test('maskPasswords tolerates null values and numeric keys without skipping secrets', () => {
|
||||
// Integer-like keys order first; the recursion must not clobber its own key list, and
|
||||
// typeof null === 'object' must not send it into Object.keys(null).
|
||||
const config = { '1': { nested: 'value' }, lnApiPassword: 'eclair-pass', nothing: null };
|
||||
const masked = Common.maskPasswords(config);
|
||||
assert.equal(masked.lnApiPassword, '*'.repeat(20));
|
||||
assert.equal(masked.nothing, null);
|
||||
assert.deepEqual(masked['1'], { nested: 'value' });
|
||||
});
|
||||
|
||||
test('handleError does not echo the absolute file path to the caller', () => {
|
||||
// The path belongs in the server log, not in the API response.
|
||||
const err = Common.handleError({ code: 'ENOENT', path: '/secret/dir/RTL-Config.json' }, 'Test', 'Reading Config Error', { lnImplementation: 'LND', settings: {} });
|
||||
assert.equal(err.error.includes('/secret/dir'), false);
|
||||
assert.equal(err.message.includes('/secret/dir'), false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -208,3 +208,124 @@ test('updateApplicationSettings keeps the SSO cookie server-side without exposin
|
|||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateApplicationSettings restores omitted secret2FA and merges a trimmed SSO object', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-secrets-'));
|
||||
const oldConfig = {
|
||||
defaultNodeIndex: 0,
|
||||
dbDirectoryPath: '/db',
|
||||
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: 'https://logout.example' },
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
lnNode: 'lnd-main',
|
||||
lnImplementation: 'LND',
|
||||
authentication: { macaroonPath: '/lnd/admin' },
|
||||
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
|
||||
}
|
||||
]
|
||||
};
|
||||
const runtimeConfig = clone({
|
||||
...oldConfig,
|
||||
selectedNodeIndex: 0,
|
||||
enable2FA: true,
|
||||
allowPasswordUpdate: true,
|
||||
rtlConfFilePath: tempDir,
|
||||
rtlPass: 'hashed-password',
|
||||
secret2FA: 'live-totp-seed',
|
||||
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: 'https://logout.example', cookieValue: 'live-sso-cookie' }
|
||||
});
|
||||
// Sanitized responses carry neither secret2FA nor cookieValue, so an echoing client
|
||||
// omits both; a trimmed SSO object also lacks logoutRedirectLink. All three must
|
||||
// survive the save server-side.
|
||||
const requestBody = {
|
||||
...clone(oldConfig),
|
||||
selectedNodeIndex: 0,
|
||||
enable2FA: true,
|
||||
allowPasswordUpdate: true,
|
||||
SSO: { rtlSSO: 0 }
|
||||
};
|
||||
|
||||
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;
|
||||
updateApplicationSettings(
|
||||
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
|
||||
{
|
||||
status: (status) => {
|
||||
responseStatus = status;
|
||||
return { json: () => {} };
|
||||
}
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal(responseStatus, 201);
|
||||
assert.equal(Common.appConfig.secret2FA, 'live-totp-seed');
|
||||
assert.equal(Common.appConfig.enable2FA, true);
|
||||
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
|
||||
assert.equal(Common.appConfig.SSO.logoutRedirectLink, 'https://logout.example');
|
||||
assert.equal(Common.appConfig.SSO.rtlSSO, 0);
|
||||
const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8'));
|
||||
assert.equal(fileConfig.SSO.cookieValue, undefined);
|
||||
assert.equal(fileConfig.secret2FA, 'live-totp-seed');
|
||||
} finally {
|
||||
clearInterval(WSServer.pingInterval);
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateApplicationSettings tolerates a request body without an SSO object', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-nosso-'));
|
||||
const oldConfig = {
|
||||
defaultNodeIndex: 0,
|
||||
dbDirectoryPath: '/db',
|
||||
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
lnNode: 'lnd-main',
|
||||
lnImplementation: 'LND',
|
||||
authentication: { macaroonPath: '/lnd/admin' },
|
||||
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
|
||||
}
|
||||
]
|
||||
};
|
||||
const requestBody = clone(oldConfig);
|
||||
delete requestBody.SSO;
|
||||
|
||||
try {
|
||||
Common.appConfig = clone({
|
||||
...oldConfig,
|
||||
selectedNodeIndex: 0,
|
||||
rtlConfFilePath: tempDir,
|
||||
rtlPass: 'hashed-password',
|
||||
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
|
||||
});
|
||||
Common.nodes = clone(oldConfig.nodes);
|
||||
Common.selectedNode = Common.nodes[0];
|
||||
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
|
||||
|
||||
let responseStatus;
|
||||
updateApplicationSettings(
|
||||
{ body: requestBody, session: { selectedNode: Common.selectedNode } },
|
||||
{
|
||||
status: (status) => {
|
||||
responseStatus = status;
|
||||
return { json: () => {} };
|
||||
}
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal(responseStatus, 201);
|
||||
assert.equal(typeof Common.appConfig.SSO, 'object');
|
||||
} finally {
|
||||
clearInterval(WSServer.pingInterval);
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue