[grade=A] fix(auth): preserve cross-host SSO return URLs
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Codex: urn:ump:endpmb3rgtqogn2u2jkbbjmsaha6ysjjcxl46fd27ayig5yosawq
This commit is contained in:
Krystie
2026-07-24 14:36:28 -07:00
parent 75f835641f
commit 003b152230
5 changed files with 154 additions and 81 deletions
+69 -69
View File
File diff suppressed because one or more lines are too long
+22 -11
View File
@@ -249,20 +249,31 @@
// back to window._showTotpOverlay() in `show()` below.
window.__dc_049_handled = true;
function isAllowedReturnUrl(returnUrl) {
try {
const parsed = new URL(returnUrl, window.location.origin);
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
if (parsed.origin === window.location.origin) return true;
if (parsed.protocol !== 'https:') return false;
// globals.js is concatenated before this module in core.js, so SITE is
// available here. Permit exact hosts and subdomains under the configured
// private TLD (for example plex.sami), while rejecting lookalikes such as
// plex.sami.evil.example.
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
return parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
} catch (_) {
return false;
}
}
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('auth') === 'required') {
// Save returnUrl the same way totp-auth.js does, so both paths share state.
// We don't have access to the SITE constant here (it lives in globals.js's
// module scope), so we use a conservative origin-only check. Caddy's
// forward_auth already validates the request origin upstream.
// Preserve the gated service destination so submitTotpCode() can append
// the one-time SSO handoff token and return the browser to that host.
const returnUrl = urlParams.get('return');
if (returnUrl) {
try {
const parsed = new URL(returnUrl, window.location.origin);
if (parsed.origin === window.location.origin) {
try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {}
}
} catch (_) {}
if (returnUrl && isAllowedReturnUrl(returnUrl)) {
try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {}
}
// Clean URL — happens after we've captured the redirect
window.history.replaceState({}, '', window.location.pathname);
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"build": "node build.js",
"test": "node --test tests/*.test.js",
"watch": "node build.js --watch"
},
"devDependencies": {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-4706f2a8fa';
const CACHE = 'dashcaddy-shell-3a6da6cb72';
const PRECACHE = [
'/',
'/index.html',
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'auth-gate.js'), 'utf8');
function capturedRedirect(returnUrl, tld = '.sami') {
const stored = new Map();
const query = new URLSearchParams({ auth: 'required', return: returnUrl });
const location = {
origin: 'https://status.sami',
pathname: '/',
search: `?${query.toString()}`,
reload() {},
};
const context = {
URL,
URLSearchParams,
SITE: { tld },
sessionStorage: {
setItem(key, value) { stored.set(key, value); },
},
document: { getElementById() { return null; } },
setTimeout() {},
console,
window: {
location,
history: { replaceState() {} },
},
};
context.window.window = context.window;
vm.runInNewContext(source, context, { filename: 'auth-gate.js' });
return stored.get('totp_redirect');
}
test('preserves a return URL on another host under the configured private TLD', () => {
assert.equal(capturedRedirect('https://plex.sami/web/'), 'https://plex.sami/web/');
});
test('preserves a same-origin return URL', () => {
assert.equal(capturedRedirect('https://status.sami/settings'), 'https://status.sami/settings');
});
test('rejects lookalike domains, plaintext cross-host URLs, and non-web schemes', () => {
assert.equal(capturedRedirect('https://plex.sami.evil.example/'), undefined);
assert.equal(capturedRedirect('http://plex.sami/'), undefined);
assert.equal(capturedRedirect('javascript:alert(1)'), undefined);
});
test('accepts relative same-origin paths and protocol-relative HTTPS private hosts', () => {
assert.equal(capturedRedirect('/settings'), '/settings');
assert.equal(capturedRedirect('//plex.sami/web/'), '//plex.sami/web/');
});
test('normalizes a configured TLD without a leading dot', () => {
assert.equal(capturedRedirect('https://plex.sami/web/', 'sami'), 'https://plex.sami/web/');
});