[grade=A] fix(auth): preserve cross-host SSO return URLs
Codex: urn:ump:endpmb3rgtqogn2u2jkbbjmsaha6ysjjcxl46fd27ayig5yosawq
This commit is contained in:
Vendored
+69
-69
File diff suppressed because one or more lines are too long
+22
-11
@@ -249,20 +249,31 @@
|
|||||||
// back to window._showTotpOverlay() in `show()` below.
|
// back to window._showTotpOverlay() in `show()` below.
|
||||||
window.__dc_049_handled = true;
|
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);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.get('auth') === 'required') {
|
if (urlParams.get('auth') === 'required') {
|
||||||
// Save returnUrl the same way totp-auth.js does, so both paths share state.
|
// Preserve the gated service destination so submitTotpCode() can append
|
||||||
// We don't have access to the SITE constant here (it lives in globals.js's
|
// the one-time SSO handoff token and return the browser to that host.
|
||||||
// module scope), so we use a conservative origin-only check. Caddy's
|
|
||||||
// forward_auth already validates the request origin upstream.
|
|
||||||
const returnUrl = urlParams.get('return');
|
const returnUrl = urlParams.get('return');
|
||||||
if (returnUrl) {
|
if (returnUrl && isAllowedReturnUrl(returnUrl)) {
|
||||||
try {
|
try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {}
|
||||||
const parsed = new URL(returnUrl, window.location.origin);
|
|
||||||
if (parsed.origin === window.location.origin) {
|
|
||||||
try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
// Clean URL — happens after we've captured the redirect
|
// Clean URL — happens after we've captured the redirect
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
|
"test": "node --test tests/*.test.js",
|
||||||
"watch": "node build.js --watch"
|
"watch": "node build.js --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-4706f2a8fa';
|
const CACHE = 'dashcaddy-shell-3a6da6cb72';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
@@ -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/');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user