diff --git a/CHANGELOG.md b/CHANGELOG.md index 917614c..ed4ffad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ```text release(v2.0.0): feat(pro): client portals + portal login flow +release(v2.0.1): fix: harden portal + core login redirects for codeql ``` ### Core v2.0.0 diff --git a/public/js/main.js b/public/js/main.js index c64ef85..26df44a 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -225,6 +225,32 @@ window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false; return p.then(r => r.clone()); }; + // ---- Safe redirect helper (prevents open redirects) ---- + function sanitizeRedirect(raw, { fallback = '/' } = {}) { + if (!raw) return fallback; + try { + const str = String(raw).trim(); + if (!str) return fallback; + + const candidate = new URL(str, window.location.origin); + + // Enforce same-origin + if (candidate.origin !== window.location.origin) { + return fallback; + } + + // Limit to http/https + if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') { + return fallback; + } + + // Return relative URL + return candidate.pathname + candidate.search + candidate.hash; + } catch { + return fallback; + } + } + // Gentle toast normalizer (compatible with showToast(message, duration)) const origToast = window.showToast; if (typeof origToast === 'function' && !origToast.__frWrapped) { @@ -886,15 +912,16 @@ function bindDarkMode() { // If index.html was opened with ?redirect=, honor that first try { const url = new URL(window.location.href); - const redirect = url.searchParams.get('redirect'); - if (redirect) { - window.location.href = redirect; + const raw = url.searchParams.get('redirect'); + const safe = sanitizeRedirect(raw, { fallback: null }); + if (safe) { + window.location.href = safe; return; } } catch { // ignore URL/param issues and fall back to normal behavior } - + const start = Date.now(); (function poll() { checkAuth().then(({ authed }) => { diff --git a/public/js/portal-login.js b/public/js/portal-login.js index 2d4cbe3..8fda906 100644 --- a/public/js/portal-login.js +++ b/public/js/portal-login.js @@ -1,15 +1,54 @@ // public/js/portal-login.js // -------- URL helpers -------- -function getRedirectTarget() { - try { - const url = new URL(window.location.href); - const r = url.searchParams.get('redirect'); - return r && r.trim() ? r.trim() : '/'; - } catch { - return '/'; +function sanitizeRedirect(raw, { fallback = '/' } = {}) { + if (!raw) return fallback; + try { + const str = String(raw).trim(); + if (!str) return fallback; + + // Resolve against current origin so relative URLs work + const candidate = new URL(str, window.location.origin); + + // 1) Must stay on the same origin + if (candidate.origin !== window.location.origin) { + return fallback; } + + // 2) Only allow http/https + if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') { + return fallback; + } + + // Return a relative URL (prevents host changes) + return candidate.pathname + candidate.search + candidate.hash; + } catch { + return fallback; } +} + +function getRedirectTarget() { + try { + const url = new URL(window.location.href); + const raw = url.searchParams.get('redirect'); + + // Default fallback: root + let target = sanitizeRedirect(raw, { fallback: '/' }); + + // If there was no *usable* redirect but we have a portal slug, + // send them back to that portal by default. + if (!target || target === '/') { + const slug = getPortalSlugFromUrl(); + if (slug) { + target = sanitizeRedirect('/portal/' + encodeURIComponent(slug), { fallback: '/' }); + } + } + + return target || '/'; + } catch { + return '/'; + } +} function getPortalSlugFromUrl() { try {