diff --git a/api/test/backup-data.test.js b/api/test/backup-data.test.js index 09f09dcf..7d437d5e 100644 --- a/api/test/backup-data.test.js +++ b/api/test/backup-data.test.js @@ -364,6 +364,15 @@ test('pbs-groups lists every group in every store and namespace', async () => { assert.equal(calls[0].opts.skipTls, true); }); +test('pbs-groups sends the token over https for an address with no scheme', async () => { + const { ctx, calls } = pbsCtx({}, 'pbs-groups', () => ({ status: 200, data: { data: [] } }), { + ...PBS_ROW, + pbsUrl: 'pbs.lan:8007/', + }); + await dataFn(ctx); + assert.equal(calls[0].url, 'https://pbs.lan:8007/api2/json/admin/datastore'); +}); + test('pbs-groups refuses a token that would break the header', async () => { const { ctx, calls } = pbsCtx({}, 'pbs-groups', () => null, { ...PBS_ROW, pbsSecret: 'abc\r\nX-Evil: 1' }); await assert.rejects(() => dataFn(ctx), /token ID/); diff --git a/ui/admin/index.html b/ui/admin/index.html index b2ce00ba..b6248d85 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -320,6 +320,6 @@

General

- + diff --git a/ui/js/admin.js b/ui/js/admin.js index 94916cb1..bf3f68d7 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -66,7 +66,7 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=961efd4d'; +} from '/js/import-foreign.js?v=20baac2a'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=6680c0bf'; import { clampName } from '/js/limits.js?v=ea61a441'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js index dcb8a358..fe614345 100644 --- a/ui/js/import-foreign.js +++ b/ui/js/import-foreign.js @@ -69,14 +69,25 @@ function isAbsoluteLink(href) { /** @param {string} href @returns {string} `href` with http:// added when it is a bare host:port */ const withScheme = href => (/^[a-z0-9.-]+:\d{1,5}(?:[/?#]|$)/i.test(href) ? `http://${href}` : href); -/* Stop at a backslash. Browsers end the host there, so removing past it changes - where the link goes. */ -const LOGIN = '((?:[a-z][a-z0-9+.-]*:)?//)[^\\s/?#\\\\]*@'; -const LOGIN_AT_START = new RegExp(`^${LOGIN}`, 'i'); +/* A login can follow any run of slashes or backslashes after a web scheme. + Stop at a backslash inside it. Browsers end the host there. */ +const LOGIN = '((?:https?|wss?|ftp):[/\\\\]*|(?:[a-z][a-z0-9+.-]*:)?//)[^\\s/?#\\\\]*@'; const LOGIN_ANYWHERE = new RegExp(LOGIN, 'gi'); /** @param {string} href @returns {string} */ -const withoutLogin = href => href.replace(LOGIN_AT_START, '$1'); +function withoutLogin(href) { + if (!href) return href; + let url; + try { + url = new URL(href); + } catch { + return href.replace(new RegExp(`^${LOGIN}`, 'i'), '$1'); + } + if (!url.username && !url.password) return href; + url.username = ''; + url.password = ''; + return url.href; +} /** @param {string} s @returns {string} */ const withoutLoginInText = s => s.replace(LOGIN_ANYWHERE, '$1'); diff --git a/ui/test/import-foreign.test.mjs b/ui/test/import-foreign.test.mjs index 893e8c9d..18d9ebc5 100644 --- a/ui/test/import-foreign.test.mjs +++ b/ui/test/import-foreign.test.mjs @@ -736,6 +736,31 @@ test('a protocol-relative link loses its username and password too', () => { assert.equal(out.notes.filter(n => n.code === NOTE.LOGIN_REMOVED).length, 1); }); +test('a login behind one, three, back or no slashes is removed too', () => { + for (const href of [ + 'http:/admin:hunter2@nas.lan/', + 'http:///admin:hunter2@nas.lan/', + 'http:\\\\admin:hunter2@nas.lan/', + 'http:admin:hunter2@nas.lan/', + ]) { + const out = convertDockerLabels([container('nas', { name: 'NAS', group: 'G', href, siteMonitor: href })]); + const app = apps(out)[0]; + assert.equal(new URL(app.href).username, '', href); + const ping = app.monitoring.healthcheck.pingUrl; + if (ping) assert.equal(new URL(ping).username, '', href); + assert.ok(!JSON.stringify(out).includes('hunter2'), href); + assert.ok( + out.notes.some(n => n.code === NOTE.LOGIN_REMOVED), + href, + ); + } +}); + +test('a mail link keeps its address', () => { + const out = convertDockerLabels([container('m', { name: 'Mail', group: 'G', href: 'mailto:admin@nas.lan' })]); + assert.equal(apps(out)[0].href, 'mailto:admin@nas.lan'); +}); + test('no preview line shows a username and password', () => { const out = convertDockerLabels([ container('a', { name: 'A', group: 'G', href: 'https://admin:hunter2@{{HOMEPAGE_VAR_HOST}}/' }), diff --git a/ui/widgets/backup/data.js b/ui/widgets/backup/data.js index 1fac67b5..5d91fe80 100644 --- a/ui/widgets/backup/data.js +++ b/ui/widgets/backup/data.js @@ -118,6 +118,9 @@ const PBS_LOG_LINES = 5; const PBS_LOG_READS = 4; const PBS_TASK_NS_MAX = 2000; +/* The PBS API listens on HTTPS only. A bare host must not send the token over http. */ +const pbsBase = url => dupNormalizeBase(/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : `https://${url}`); + function pbsConn(row, ctx) { const url = (row.pbsUrl || '').trim(); if (!url) ctx.fail('Proxmox Backup Server URL required', { kind: ctx.KIND.INVALID }); @@ -127,7 +130,7 @@ function pbsConn(row, ctx) { if (!PBS_TOKEN_ID.test(tokenId) || !PBS_SECRET.test(secret)) { ctx.fail('Enter a token ID like user@pbs!name and its secret', { kind: ctx.KIND.INVALID }); } - return { base: dupNormalizeBase(url), tokenId, secret, skipTls: row.pbsSkipTls === true }; + return { base: pbsBase(url), tokenId, secret, skipTls: row.pbsSkipTls === true }; } async function pbsGet(conn, path, ctx, keep) {