mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# Which Problems Are Solved - Native apps (mobile/desktop) use custom protocol schemes (e.g. `myapp://callback`, `io.zitadel.app://auth`) as OAuth/OIDC redirect URIs. The previous redirect-safety check in the login UI only allowed `http:`/`https:` absolute URLs, so these custom-scheme callbacks were wrongly rejected and native app login flows were broken. - Even for valid external redirects, `router.push()` triggers an RSC prefetch `fetch()` that is blocked by the CSP `connect-src 'self'` directive for non-same-origin URLs, so navigation to external/custom-protocol targets failed. - SAML and OIDC callback URLs returned from the backend were not consistently validated before being used for redirects/POSTs across the various flow entry points. # How the Problems Are Solved - Reworked `isSafeRedirectUri` to use `@braintree/sanitize-url` instead of a strict `http/https`-only allowlist. Custom protocol schemes are now permitted, while dangerous schemes (`javascript:`, `data:`, plus an explicit block of `file:`, `blob:`, `about:`) are still rejected. - Added `isExternalUrl` helper to distinguish internal relative paths (`/...`) from external/custom-protocol targets. - In `handleServerActionResponse`, external/custom-protocol redirects now navigate via `window.location.href` (full navigation) instead of `router.push()`, avoiding the CSP-blocked RSC prefetch. Internal paths continue to use `router.push()`. - Added consistent redirect-URI validation for SAML and OIDC callbacks across all relevant entry points (`oidc.ts`, `saml.ts`, `server/flow-initiation.ts` including the `prompt=none` path, and the SAML `samlData` POST case), blocking unsafe URLs with a clear warning and error response. # Additional Changes - Added `@braintree/sanitize-url` (`^7.1.2`) dependency to `apps/login` (`package.json` / `pnpm-lock.yaml`). - Added unit tests for `isExternalUrl` covering relative paths, absolute HTTP/HTTPS URLs, custom protocol schemes, and protocol-relative URLs. - Annotated the intentional `window.location.href` navigation with a CodeQL suppression comment, documenting that the URL is validated by `isSafeRedirectUri` beforehand. - Minor formatting cleanup in `console` JWT provider component. # Additional Context - This restores behavior that previously worked for native apps but regressed when redirect validation was tightened to `http`/`https` only. --------- Co-authored-by: Max Peintner <[email protected]>
96 lines
2.9 KiB
TypeScript
96 lines
2.9 KiB
TypeScript
import { sanitizeUrl } from "@braintree/sanitize-url";
|
|
|
|
export type ServerActionResponse =
|
|
| { redirect: string }
|
|
| { error: string }
|
|
| { samlData: { url: string; fields: Record<string, string> } }
|
|
| undefined
|
|
| null;
|
|
|
|
export function handleServerActionResponse(
|
|
response: ServerActionResponse,
|
|
router: { push: (url: string) => void },
|
|
setSamlData: (data: { url: string; fields: Record<string, string> }) => void,
|
|
setError: (error: string) => void,
|
|
): boolean {
|
|
if (!response) {
|
|
return false;
|
|
}
|
|
|
|
if ("redirect" in response && response.redirect) {
|
|
if (isSafeRedirectUri(response.redirect)) {
|
|
if (typeof window !== "undefined" && isExternalUrl(response.redirect)) {
|
|
// External/custom-protocol URLs: use window.location for full navigation.
|
|
// router.push() would trigger an RSC prefetch fetch() that gets blocked
|
|
// by CSP connect-src 'self' for non-same-origin URLs.
|
|
// CodeQL: This is safe — the URL is validated by isSafeRedirectUri above,
|
|
// which blocks javascript:, data:, file:, blob:, and about: schemes.
|
|
window.location.href = response.redirect; // lgtm[js/client-side-unvalidated-url-redirection]
|
|
} else {
|
|
router.push(response.redirect);
|
|
}
|
|
return true;
|
|
} else {
|
|
console.warn("handleServerActionResponse: Blocked unsafe redirect URI:", response.redirect);
|
|
setError("Unsafe redirect URI was blocked");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ("samlData" in response && response.samlData) {
|
|
if (isSafeRedirectUri(response.samlData.url)) {
|
|
setSamlData(response.samlData);
|
|
return true;
|
|
} else {
|
|
console.warn("handleServerActionResponse: Blocked unsafe SAML post URL:", response.samlData.url);
|
|
setError("Unsafe redirect URI was blocked");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ("error" in response && response.error) {
|
|
setError(response.error);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the URL is external (absolute URL or custom protocol).
|
|
* Relative paths (starting with "/" but not "//") are considered internal.
|
|
*/
|
|
export function isExternalUrl(url: string): boolean {
|
|
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const SANITIZE_BLANK = "about:blank";
|
|
const EXTRA_BLOCKED = new Set(["file:", "blob:", "about:"]);
|
|
|
|
/**
|
|
* Validates whether a given redirect URI is safe.
|
|
* Safe URIs are either relative paths or absolute URLs matching the current host.
|
|
* This prevents open redirect vulnerabilities and XSS via javascript:/data: URIs.
|
|
*/
|
|
export function isSafeRedirectUri(uri: string): boolean {
|
|
if (!uri) return false;
|
|
|
|
// 1. Relative paths are generally safe
|
|
if (uri.startsWith("/") && !uri.startsWith("//")) {
|
|
return true;
|
|
}
|
|
|
|
const sanitized = sanitizeUrl(uri);
|
|
if (sanitized === SANITIZE_BLANK) return false;
|
|
|
|
try {
|
|
const parsed = new URL(sanitized);
|
|
return !EXTRA_BLOCKED.has(parsed.protocol);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|