diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed483de..fa29de61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A badge blocked for pointing at a private address now names + `ALLOW_PRIVATE_IPS=true` as the setting that allows it. + - An Admin save no longer changes the Unsplash wallpaper on every open dashboard. The photo is kept for a day. diff --git a/ui/js/admin-error.js b/ui/js/admin-error.js index 08fbb298..b43c8dfb 100644 --- a/ui/js/admin-error.js +++ b/ui/js/admin-error.js @@ -13,6 +13,10 @@ export const KIND = Object.freeze({ export const TONE = Object.freeze({ WARN: 'warn', ERROR: 'error' }); +/* Matches the two SSRF messages that ALLOW_PRIVATE_IPS would let through. + Keep it in step with the guard in api/src/proxy.js. */ +const PRIVATE_BLOCK_RE = /is a private address\.|resolves to private IP /; + /* An unknown or missing kind degrades to INTERNAL. */ export function readError(e) { const kind = e && typeof e.kind === 'string' && Object.values(KIND).includes(e.kind) ? e.kind : KIND.INTERNAL; @@ -51,6 +55,15 @@ export function badgeErrorAdvice(e) { }; } + if (kind === KIND.BLOCKED && PRIVATE_BLOCK_RE.test(message)) { + return { + tone: TONE.WARN, + message: `${message} Most homelab services live on private IPs. Set ALLOW_PRIVATE_IPS=true and restart the container.`, + openAuth: false, + sessionExpired: false, + }; + } + /* BLOCKED carries a reason this project wrote, so it is shown verbatim. */ return { tone: TONE.ERROR, diff --git a/ui/test/admin-error.test.mjs b/ui/test/admin-error.test.mjs index 63c29019..e365f90e 100644 --- a/ui/test/admin-error.test.mjs +++ b/ui/test/admin-error.test.mjs @@ -65,11 +65,34 @@ test('our own expired session does not offer an upstream API key', () => { neither '403' nor 'Forbidden', so neither matcher fired. */ test('an SSRF block shows its own reason', () => { const a = badgeErrorAdvice({ kind: KIND.BLOCKED, message: 'Blocked: localhost is a private address.' }); - assert.equal(a.tone, TONE.ERROR); assert.match(a.message, /private address/); assert.equal(a.openAuth, false); }); +/* A private address is what most homelab installs point a badge at, and one + setting unblocks it. */ +test('a blocked private address names the setting that allows it', () => { + for (const message of [ + 'Blocked: 192.168.1.5 is a private address.', + 'Blocked: nas.lan resolves to private IP 10.0.0.4.', + ]) { + const a = badgeErrorAdvice({ kind: KIND.BLOCKED, message }); + assert.equal(a.tone, TONE.WARN, message); + assert.match(a.message, /ALLOW_PRIVATE_IPS=true/, message); + assert.ok(a.message.includes(message), 'keeps the original reason'); + assert.equal(a.openAuth, false); + assert.equal(a.sessionExpired, false); + } +}); + +test('other blocked reasons are still shown verbatim', () => { + for (const message of ['Invalid URL', 'Blocked: nas.lan could not be resolved.', 'Blocked: ftp: is not allowed.']) { + const a = badgeErrorAdvice({ kind: KIND.BLOCKED, message }); + assert.equal(a.tone, TONE.ERROR, message); + assert.equal(a.message, message); + } +}); + test('an error with no kind degrades to a plain failure, it does not throw', () => { const a = badgeErrorAdvice(new Error('something odd')); assert.equal(a.tone, TONE.ERROR);