feat(conf): mask SMTP/OAuth secrets + leave-blank-to-keep on /conf (v1.17.1)

GET /api/conf no longer returns smtp.pass / oauth.jwtSecret in cleartext
(masked to ********). POST treats a blank or ******** secret submission as
"keep the stored value," so editing the From address or token lifetimes no
longer requires re-entering or leaks the SMTP password / JWT secret. The /conf
form fields carry a leave-unchanged hint. Storage stays in OpenBao at
secret/sso-manager/conf (unchanged); no theta-suite policy change needed.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-01 21:15:16 -04:00
parent 320e7594e4
commit 25b0d57a97
6 changed files with 112 additions and 13 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.17.0",
"version": "1.17.1",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+40 -8
View File
@@ -12,12 +12,31 @@ router.use(async (req, res, next) => {
}
});
// Secret fields stored inside secret/sso-manager/conf. These are NEVER returned
// in cleartext by GET /api/conf (masked to MASK below) and, on save, a blank or
// mask-valued submission preserves the stored value so an admin editing an
// unrelated field (e.g. the From address) doesn't have to re-enter — or leak —
// the SMTP password / OAuth JWT secret. Mirrors the plugin-secrets discipline.
const MASK = '********';
const SECRET_PATHS = [
['smtp', 'pass'],
['oauth', 'jwtSecret'],
];
function maskSecrets(obj) {
const out = JSON.parse(JSON.stringify(obj));
for (const [grp, key] of SECRET_PATHS) {
if (out[grp] && out[grp][key]) out[grp][key] = MASK;
}
return out;
}
router.get('/', async (req, res) => {
const editable = {
const editable = maskSecrets({
smtp: conf.smtp || {},
discovery: conf.discovery || {},
oauth: conf.oauth || {}
};
});
res.json(editable);
});
@@ -38,18 +57,31 @@ function applyToLiveConf(src) {
router.post('/', async (req, res, next) => {
try {
const existing = await baoConf.get('sso-manager/conf') || {};
// Deep merge req.body into existing
for (const key of Object.keys(req.body)) {
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) {
existing[key] = { ...(existing[key] || {}), ...req.body[key] };
const incoming = req.body || {};
// Preserve secret fields the admin left blank (or left showing the mask):
// drop them from the incoming merge so the stored value survives. Only a
// genuinely new, non-blank, non-mask value overwrites.
for (const [grp, key] of SECRET_PATHS) {
if (incoming[grp] && incoming[grp][key] !== undefined) {
const submitted = incoming[grp][key];
if (submitted === '' || submitted === MASK) delete incoming[grp][key];
}
}
// Deep merge incoming into existing
for (const key of Object.keys(incoming)) {
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
} else {
existing[key] = req.body[key];
existing[key] = incoming[key];
}
}
await baoConf.set('sso-manager/conf', existing);
// Reflect the saved values in the live conf immediately (the next boot's
// bao-conf.init() would pick them up too, but this keeps running readers
// current without a restart, as the old conf_manager did).
// current without a restart, as the old conf_manager did). `existing`
// carries the preserved secret values, so live conf keeps them too.
applyToLiveConf(existing);
res.json({ success: true });
} catch(err) {
+7 -3
View File
@@ -83,7 +83,9 @@
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
<p class="text-muted mb-0">
Manage runtime configuration such as SMTP settings and OAuth parameters.
These secrets are stored securely in OpenBao Vault.
These are stored securely in OpenBao and take effect immediately. Secret fields
(the SMTP password and OAuth JWT secret) are masked — leave them unchanged to
keep the stored value.
</p>
</div>
<div>
@@ -115,9 +117,10 @@
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
</div>
<div class="mb-3">
<label class="form-label">From Address</label>
@@ -144,9 +147,10 @@
<div class="mb-3">
<label class="form-label">JWT Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret">
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div>
</div>
<div class="mb-3">
<label class="form-label">Access Token Lifetime (seconds)</label>