diff --git a/README.md b/README.md index c107ae3..2aa73f1 100644 --- a/README.md +++ b/README.md @@ -234,8 +234,24 @@ Use `state` to preserve data across the authentication redirect: { - if (state?.returnTo) { - window.location.href = state.returnTo; + if (typeof state?.returnTo !== "string") return; + // `state` round-trips through the redirect as plaintext in the URL and is + // not validated by WorkOS, so treat it as untrusted input. Resolve it + // against your own origin and only navigate if it stays same-origin; this + // rejects absolute URLs (https://evil.com), protocol-relative URLs + // (//evil.com), and javascript: URIs in one step. + let url; + try { + url = new URL(state.returnTo, window.location.origin); + } catch { + return; + } + if (url.origin === window.location.origin) { + // Navigate to the origin-validated absolute URL. Don't rebuild from + // url.pathname: a value like "https://your-app//evil.com" passes the + // origin check but has pathname "//evil.com", which is protocol-relative + // and would redirect off-site. + window.location.href = url.href; } }} > @@ -243,6 +259,13 @@ Use `state` to preserve data across the authentication redirect: ``` +> [!WARNING] +> Anything you read from `state` is **untrusted user input**. It travels as +> plaintext in the redirect URL and WorkOS cannot validate its contents. Before +> using a `state.returnTo`-style value to navigate, validate it as shown above — +> never pass it directly to `window.location.href`, which would execute a +> `javascript:` URI or follow an open-redirect to an attacker's site. + ### Handling Token Refresh Failures ```jsx