Files
zitadel/apps/login/src/lib/client-utils.ts
T
4dc89b499f fix(login): allow custom protocols for native apps again (#12332)
# 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]>
2026-06-30 13:19:40 +00:00

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;
}
}