Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions api/src/badge-headers.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* Badge and activity header and query rows: { key, value, secret }. A secret
row's value never leaves the server. A save that omits it keeps the stored
value. */
value. The same holds for the login password: { username, password }. */

const SUBKEYS = ['headers', 'params'];

Expand Down Expand Up @@ -64,10 +64,30 @@ function rowsToObject(rows) {
return out;
}

/* A login without a username counts as none. A save that clears the username
removes the stored password. */
/** @param {unknown} login @returns {{ username: string, password: string }|null} */
function loginOf(login) {
if (!login || typeof login !== 'object') return null;
const { username, password } = /** @type {{ username?: unknown, password?: unknown }} */ (login);
if (typeof username !== 'string' || !username) return null;
return { username, password: typeof password === 'string' ? password : '' };
}

/* Header rows go in after the login. A row named Authorization replaces it. */
/** @param {any} rows @param {unknown} login @returns {Record<string,string>} */
function requestHeaders(rows, login) {
/** @type {Record<string,string>} */
const out = Object.create(null);
const l = loginOf(login);
if (l) out.Authorization = 'Basic ' + Buffer.from(`${l.username}:${l.password}`).toString('base64');
return Object.assign(out, rowsToObject(rows));
}

function requestParts(item) {
const src = item?.monitoring?.activity?.enabled ? item.monitoring.activity : item?.badge;
return {
headers: rowsToObject(src?.headers),
headers: requestHeaders(src?.headers, src?.login),
params: rowsToObject(src?.params),
};
}
Expand Down Expand Up @@ -96,6 +116,20 @@ function preserveRows(newRows, oldRows) {
return nrows;
}

/** @param {unknown} login */
function scrubLogin(login) {
const l = loginOf(login);
return l ? { username: l.username, passwordSet: l.password !== '' } : undefined;
}

/** @param {unknown} login @param {unknown} oldLogin */
function preserveLogin(login, oldLogin) {
const l = loginOf(login);
if (!l) return undefined;
if (!l.password) l.password = loginOf(oldLogin)?.password || '';
return l;
}

function eachActivityLike(item, fn) {
if (!item || typeof item !== 'object') return;
if (item.badge) fn(item.badge);
Expand All @@ -105,13 +139,15 @@ function eachActivityLike(item, fn) {
function scrubItemBadgeSecrets(item) {
eachActivityLike(item, block => {
for (const k of SUBKEYS) if (block[k] != null) block[k] = scrubRows(block[k]);
if (block.login != null) block.login = scrubLogin(block.login);
});
}

function preserveItemBadgeSecrets(newItem, oldItem) {
const oldBlocks = { badge: oldItem?.badge, activity: oldItem?.monitoring?.activity };
const apply = (block, old) => {
for (const k of SUBKEYS) if (block[k] != null) block[k] = preserveRows(block[k], old?.[k]);
if (block.login != null) block.login = preserveLogin(block.login, old?.login);
};
if (newItem?.badge) apply(newItem.badge, oldBlocks.badge);
if (newItem?.monitoring?.activity) apply(newItem.monitoring.activity, oldBlocks.activity);
Expand All @@ -135,6 +171,8 @@ module.exports = {
droppedRowCount,
firstMalformedRow,
rowsToObject,
loginOf,
requestHeaders,
requestParts,
scrubRows,
scrubItemBadgeSecrets,
Expand Down
6 changes: 3 additions & 3 deletions api/src/config-secrets.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
misses others leaks a stored credential. */

const { scrubConfigSecrets, preserveConfigSecrets, secretSpec } = require('./widget-secrets');
const { scrubItemBadgeSecrets, preserveItemBadgeSecrets, toRows } = require('./badge-headers');
const { scrubItemBadgeSecrets, preserveItemBadgeSecrets, toRows, loginOf } = require('./badge-headers');
const { badgeRequestMatchesSaved, alignSavedConfig, rewriteChanged, widgetTlsChanged } = require('./secret-scope');
const { getRegistry } = require('./widgets');

Expand All @@ -19,10 +19,10 @@ function rowsHoldSecret(rows) {
}

function blockHoldsSecret(block) {
return !!block && (rowsHoldSecret(block.headers) || rowsHoldSecret(block.params));
return !!block && (rowsHoldSecret(block.headers) || rowsHoldSecret(block.params) || !!loginOf(block.login)?.password);
}

const asRequest = block => ({ url: block?.url, headers: block?.headers, params: block?.params });
const asRequest = block => ({ url: block?.url, headers: block?.headers, params: block?.params, login: block?.login });

function widgetHoldsSecret(config, entry) {
if (!config || !entry) return false;
Expand Down
21 changes: 16 additions & 5 deletions api/src/routes/badges.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ const { PING_MS, FETCH_MS, BATCH_MS } = require('../timeouts');
const { IS_DEMO } = require('../demo');
const demoData = require('../demo-data');
const { collectNumbers, computeBadgeValue, computeLabelValues, firstFiringLabel } = require('../badge-extract');
const { requestParts, toRows, preserveItemBadgeSecrets, rowsToObject, droppedRowCount } = require('../badge-headers');
const {
requestParts,
toRows,
preserveItemBadgeSecrets,
rowsToObject,
requestHeaders,
droppedRowCount,
} = require('../badge-headers');
const log = require('../log');
const { fail, KIND, errorBody } = require('../api-error');
const { badgeRequestMatchesSaved, RETYPE_MESSAGE } = require('../secret-scope');
Expand Down Expand Up @@ -173,25 +180,29 @@ on('POST', '/api/badge-proxy', async (req, res) => {
picks the URL while the server picks the credential. */
let headerRows = toRows(body.headers);
let paramRows = toRows(body.params);
let login = body.login;
let declined = false;
if (itemId) {
const stored = loadConfig().items?.find(i => i && i.id === itemId);
if (stored) {
const oldSrc = stored.monitoring?.activity?.enabled ? stored.monitoring.activity : stored.badge;
if (
badgeRequestMatchesSaved({ url, headers: headerRows, params: paramRows }, oldSrc) &&
badgeRequestMatchesSaved({ url, headers: headerRows, params: paramRows, login }, oldSrc) &&
(skipTls === true) === (stored.skipTlsVerify === true)
) {
const shim = { badge: { headers: headerRows, params: paramRows } };
preserveItemBadgeSecrets(shim, { badge: { headers: oldSrc?.headers, params: oldSrc?.params } });
const shim = { badge: { headers: headerRows, params: paramRows, login } };
preserveItemBadgeSecrets(shim, {
badge: { headers: oldSrc?.headers, params: oldSrc?.params, login: oldSrc?.login },
});
headerRows = shim.badge.headers;
paramRows = shim.badge.params;
login = shim.badge.login;
} else {
declined = true;
}
}
}
const headers = rowsToObject(headerRows);
const headers = requestHeaders(headerRows, login);
const params = rowsToObject(paramRows);
const fullUrl = Object.keys(params).length
? url + (url.includes('?') ? '&' : '?') + new URLSearchParams(params)
Expand Down
3 changes: 2 additions & 1 deletion api/src/secret-scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
manifest marks cosmetic or transient are excluded. */

const { SAVED_ROW, secretSpec, cosmeticSpec, transientSpec } = require('./widget-secrets');
const { toRows } = require('./badge-headers');
const { toRows, loginOf } = require('./badge-headers');

function stableEqual(a, b) {
if (a === b) return true;
Expand Down Expand Up @@ -125,6 +125,7 @@ function rowsMatch(newRows, oldRows) {
function badgeRequestMatchesSaved(request, stored) {
if (!stored) return false;
if ((request.url || '') !== (stored.url || '')) return false;
if ((loginOf(request.login)?.username || '') !== (loginOf(stored.login)?.username || '')) return false;
return rowsMatch(request.headers, stored.headers) && rowsMatch(request.params, stored.params);
}

Expand Down
51 changes: 51 additions & 0 deletions api/test/badge-headers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,54 @@ test('a row with an empty value is not sent as a header', () => {
]);
assert.deepEqual({ ...out }, { Accept: 'application/json' });
});

const basic = (u, p) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');

test('a login becomes a Basic Authorization header', () => {
const item = { badge: { login: { username: 'monitor', password: 'pw' } } };
assert.deepEqual(requestParts(item).headers, { Authorization: basic('monitor', 'pw') });
});

test('a login without a username sends nothing', () => {
for (const login of [{ username: '', password: 'pw' }, { password: 'pw' }, 'monitor:pw', null]) {
assert.deepEqual(requestParts({ badge: { login } }).headers, {}, JSON.stringify(login));
}
});

test('an Authorization header row replaces the login', () => {
const item = {
badge: {
login: { username: 'monitor', password: 'pw' },
headers: [{ key: 'Authorization', value: 'Bearer T', secret: true }],
},
};
assert.deepEqual(requestParts(item).headers, { Authorization: 'Bearer T' });
});

test('scrub hides the login password and keeps the username', () => {
const item = { monitoring: { activity: { login: { username: 'monitor', password: 'pw' } } } };
scrubItemBadgeSecrets(item);
assert.deepEqual(item.monitoring.activity.login, { username: 'monitor', passwordSet: true });
});

test('scrub drops a login without a username', () => {
const item = { badge: { login: { password: 'pw' } } };
scrubItemBadgeSecrets(item);
assert.equal(item.badge.login, undefined);
});

test('preserve refills an omitted login password and keeps a retyped one', () => {
const old = { badge: { login: { username: 'monitor', password: 'stored' } } };
const kept = { badge: { login: { username: 'monitor', passwordSet: true } } };
preserveItemBadgeSecrets(kept, old);
assert.deepEqual(kept.badge.login, { username: 'monitor', password: 'stored' });
const retyped = { badge: { login: { username: 'monitor', password: 'new' } } };
preserveItemBadgeSecrets(retyped, old);
assert.deepEqual(retyped.badge.login, { username: 'monitor', password: 'new' });
});

test('clearing the username removes the stored login', () => {
const item = { badge: { login: { username: '' } } };
preserveItemBadgeSecrets(item, { badge: { login: { username: 'monitor', password: 'stored' } } });
assert.equal(item.badge.login, undefined);
});
30 changes: 30 additions & 0 deletions api/test/config-secrets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -387,3 +387,33 @@ test('without savedRow a removed row still withholds by position', () => {
assert.equal(next.items[0].widgetConfig.slots[0].dupPass, undefined);
assert.deepEqual(withheld, [{ id: 'b1', label: 'b1' }]);
});

const loginApp = (url, username, login = { username }) => ({
items: [{ id: 'l1', type: 'app', monitoring: { activity: { enabled: true, url, login } } }],
});

test('a login password survives a save that changed nothing about the request', () => {
const stored = loginApp('https://legit.example/api', 'monitor', { username: 'monitor', password: 'REAL' });
const incoming = loginApp('https://legit.example/api', 'monitor');
const { withheld } = preserveAllSecrets(incoming, stored);
assert.equal(incoming.items[0].monitoring.activity.login.password, 'REAL');
assert.deepEqual(withheld, []);
});

test('a login password is not refilled once the request points elsewhere', () => {
const stored = loginApp('https://legit.example/api', 'monitor', { username: 'monitor', password: 'REAL' });
const incoming = loginApp('https://attacker.example/collect', 'monitor');
const { withheld } = preserveAllSecrets(incoming, stored);
assert.equal(incoming.items[0].monitoring.activity.login.password, '');
assert.deepEqual(
withheld.map(w => w.id),
['l1'],
);
});

test('a login password is not refilled for another username', () => {
const stored = loginApp('https://legit.example/api', 'monitor', { username: 'monitor', password: 'REAL' });
const incoming = loginApp('https://legit.example/api', 'admin');
preserveAllSecrets(incoming, stored);
assert.equal(incoming.items[0].monitoring.activity.login.password, '');
});
43 changes: 43 additions & 0 deletions api/test/secret-scope-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ before(async () => {
params: [{ key: 'mode', value: 'full', secret: false }],
},
},
{
id: 'app-login',
type: 'app',
monitoring: {
activity: {
enabled: true,
url: `${realBase}/login-api`,
login: { username: 'monitor', password: SECRET_VALUE },
},
},
},
{
id: 'w1',
type: 'widget',
Expand Down Expand Up @@ -171,6 +182,38 @@ test('badge-proxy does not reuse the stored credential with certificate checking
assert.ok(!sawSecret(realSeen), 'a changed certificate setting must not reuse the credential');
});

const basicPassword = seen => {
const auth = seen.at(-1)?.headers.authorization || '';
return Buffer.from(auth.replace(/^Basic /, ''), 'base64')
.toString()
.split(':')[1];
};

test('badge-proxy signs in with the stored password at the saved destination', async () => {
realSeen.length = 0;
const r = await post('/api/badge-proxy', {
itemId: 'app-login',
url: `${realBase}/login-api`,
login: { username: 'monitor' },
});
assert.equal(r.status, 200);
assert.equal(basicPassword(realSeen), SECRET_VALUE);
});

test('badge-proxy does not send the stored password to a caller-chosen host', async () => {
evilSeen.length = 0;
await post('/api/badge-proxy', { itemId: 'app-login', url: `${evilBase}/collect`, login: { username: 'monitor' } });
assert.equal(evilSeen.length, 1);
assert.notEqual(basicPassword(evilSeen), SECRET_VALUE);
});

test('badge-proxy does not send the stored password for another username', async () => {
realSeen.length = 0;
await post('/api/badge-proxy', { itemId: 'app-login', url: `${realBase}/login-api`, login: { username: 'admin' } });
assert.equal(realSeen.length, 1);
assert.notEqual(basicPassword(realSeen), SECRET_VALUE);
});

/* ── widget-options ───────────────────────────────────────────────────────── */

test('widget-options sends the stored credential to the saved destination', async () => {
Expand Down
2 changes: 1 addition & 1 deletion ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,6 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div></div>
</div>

<script type="module" src="/js/admin.js?v=4ec9f747"></script>
<script type="module" src="/js/admin.js?v=97387bfd"></script>
</body>
</html>
4 changes: 4 additions & 0 deletions ui/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@
"value": "Wert",
"authentication": "Authentifizierung",
"addToHeader": "Zum Header hinzufügen",
"signIn": "Anmeldung",
"username": "Benutzername",
"password": "Passwort",
"urlHasLogin": "Gib Benutzername und Passwort unter Anmeldung ein. Eine Anmeldung in der URL wird nicht gesendet.",
"unit": "Einheit",
"unitPh": "z. B. GB",
"badgeMin": "Anzeigen ab",
Expand Down
4 changes: 4 additions & 0 deletions ui/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@
"value": "Value",
"authentication": "Authentication",
"addToHeader": "Add to Header",
"signIn": "Sign In",
"username": "Username",
"password": "Password",
"urlHasLogin": "Enter the username and password under Sign In. A login in the URL is not sent.",
"unit": "Unit",
"unitPh": "e.g. GB",
"badgeMin": "Show From",
Expand Down
4 changes: 4 additions & 0 deletions ui/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@
"value": "Valor",
"authentication": "Autenticación",
"addToHeader": "Añadir a la cabecera",
"signIn": "Inicio de sesión",
"username": "Usuario",
"password": "Contraseña",
"urlHasLogin": "Introduce el usuario y la contraseña en Inicio de sesión. Los datos de acceso de la URL no se envían.",
"unit": "Unidad",
"unitPh": "p. ej. GB",
"badgeMin": "Mostrar desde",
Expand Down
4 changes: 4 additions & 0 deletions ui/i18n/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@
"value": "مقدار",
"authentication": "احراز هویت",
"addToHeader": "افزودن به هدر",
"signIn": "ورود",
"username": "نام کاربری",
"password": "رمز عبور",
"urlHasLogin": "نام کاربری و رمز عبور را در بخش ورود وارد کنید. اطلاعات ورود داخل آدرس ارسال نمی‌شود.",
"unit": "واحد",
"unitPh": "مثلا GB",
"badgeMin": "نمایش از",
Expand Down
4 changes: 4 additions & 0 deletions ui/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@
"value": "Valeur",
"authentication": "Authentification",
"addToHeader": "Ajouter à l’en-tête",
"signIn": "Connexion",
"username": "Nom d’utilisateur",
"password": "Mot de passe",
"urlHasLogin": "Saisissez le nom d’utilisateur et le mot de passe sous Connexion. Les identifiants dans l’URL ne sont pas envoyés.",
"unit": "Unité",
"unitPh": "p. ex. Go",
"badgeMin": "Afficher à partir de",
Expand Down
4 changes: 4 additions & 0 deletions ui/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@
"value": "值",
"authentication": "身份验证",
"addToHeader": "添加到标头",
"signIn": "登录",
"username": "用户名",
"password": "密码",
"urlHasLogin": "请在“登录”中填写用户名和密码。URL 中的登录信息不会发送。",
"unit": "单位",
"unitPh": "例如 GB",
"badgeMin": "显示阈值",
Expand Down
Loading
Loading