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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions ui/js/admin-error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 24 additions & 1 deletion ui/test/admin-error.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading