-
Notifications
You must be signed in to change notification settings - Fork 928
fix(site): sanitize login redirect #15208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
cc25e09
b1b1a3b
0857113
d39455a
e42fc84
237d292
5b3d48c
65d7992
b72750d
ac54ce0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { API } from "api/api"; | ||
import type { Region } from "api/typesGenerated"; | ||
import type { MetadataState } from "hooks/useEmbeddedMetadata"; | ||
import { cachedQuery } from "./util"; | ||
|
||
const regionsKey = ["regions"] as const; | ||
|
||
export const regions = (metadata: MetadataState<readonly Region[]>) => { | ||
return cachedQuery({ | ||
metadata, | ||
queryKey: regionsKey, | ||
queryFn: () => API.getWorkspaceProxyRegions(), | ||
}); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,9 @@ | ||
import { buildInfo } from "api/queries/buildInfo"; | ||
import { regions } from "api/queries/regions"; | ||
import { authMethods } from "api/queries/users"; | ||
import { useAuthContext } from "contexts/auth/AuthProvider"; | ||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; | ||
import { type FC, useEffect } from "react"; | ||
import { type FC, useEffect, useState } from "react"; | ||
import { Helmet } from "react-helmet-async"; | ||
import { useQuery } from "react-query"; | ||
import { Navigate, useLocation, useNavigate } from "react-router-dom"; | ||
|
@@ -28,6 +29,20 @@ export const LoginPage: FC = () => { | |
const navigate = useNavigate(); | ||
const { metadata } = useEmbeddedMetadata(); | ||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); | ||
const regionsQuery = useQuery(regions(metadata.regions)); | ||
const [redirectError, setRedirectError] = useState<Error | null>(null); | ||
let redirectUrl: URL | null = null; | ||
try { | ||
redirectUrl = new URL(redirectTo); | ||
} catch (err) { | ||
// Do nothing | ||
} | ||
|
||
const isApiRoute = redirectTo.startsWith("/api/v2"); | ||
const isLocalRedirect = | ||
(!redirectUrl || | ||
(redirectUrl && redirectUrl.host === window.location.host)) && | ||
!isApiRoute; | ||
|
||
useEffect(() => { | ||
if (!buildInfoQuery.data || isSignedIn) { | ||
|
@@ -41,42 +56,50 @@ export const LoginPage: FC = () => { | |
}); | ||
}, [isSignedIn, buildInfoQuery.data, user?.id]); | ||
|
||
if (isSignedIn) { | ||
if (buildInfoQuery.data) { | ||
// This uses `navigator.sendBeacon`, so window.href | ||
// will not stop the request from being sent! | ||
sendDeploymentEvent(buildInfoQuery.data, { | ||
type: "deployment_login", | ||
user_id: user?.id, | ||
}); | ||
useEffect(() => { | ||
if (!isSignedIn || !regionsQuery.data || isLocalRedirect) { | ||
return; | ||
} | ||
|
||
// If the redirect is going to a workspace application, and we | ||
// are missing authentication, then we need to change the href location | ||
// to trigger a HTTP request. This allows the BE to generate the auth | ||
// cookie required. Similarly for the OAuth2 exchange as the authorization | ||
// page is served by the backend. | ||
// If no redirect is present, then ignore this branched logic. | ||
if (redirectTo !== "" && redirectTo !== "/") { | ||
try { | ||
// This catches any absolute redirects. Relative redirects | ||
// will fail the try/catch. Subdomain apps are absolute redirects. | ||
const redirectURL = new URL(redirectTo); | ||
if (redirectURL.host !== window.location.host) { | ||
window.location.href = redirectTo; | ||
return null; | ||
} | ||
} catch { | ||
// Do nothing | ||
} | ||
// Path based apps and OAuth2. | ||
if (redirectTo.includes("/apps/") || redirectTo.includes("/oauth2/")) { | ||
window.location.href = redirectTo; | ||
return null; | ||
} | ||
const regions = regionsQuery.data.regions; | ||
const pathUrls = regions | ||
? regions | ||
.map((region) => { | ||
try { | ||
return new URL(region.path_app_url); | ||
} catch { | ||
return null; | ||
} | ||
}) | ||
.filter((url) => url !== null) | ||
: []; | ||
const wildcardHostnames = regions | ||
? regions | ||
.map((region) => region.wildcard_hostname) | ||
.filter((hostname) => hostname !== "") | ||
// remove the leading '*' from the hostname | ||
.map((hostname) => hostname.slice(1)) | ||
: []; | ||
|
||
const allowed = | ||
pathUrls.some((url) => url.host === window.location.host) || | ||
wildcardHostnames.some((wildcard) => | ||
window.location.host.endsWith(wildcard), | ||
) || | ||
// api routes need to be manually set with href | ||
isApiRoute; | ||
|
||
if (allowed) { | ||
window.location.href = redirectTo; | ||
} else { | ||
setRedirectError(new Error("invalid redirect url")); | ||
} | ||
}, [isSignedIn, regionsQuery.data, redirectTo, isLocalRedirect, isApiRoute]); | ||
|
||
return <Navigate to={redirectTo} replace />; | ||
if (isSignedIn && isLocalRedirect) { | ||
return ( | ||
<Navigate to={redirectUrl ? redirectUrl.pathname : redirectTo} replace /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So I poked around a bit more of the code, and I'm just making sure: there's a second file that calls the Because right now, the control flow is set up so that the component:
Not sure how the callback logic works, but would the updates to this file catch any issues from there? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, good find. This is being sent directly to the backend which handles approving the redirect URLs, so it doesn't need any validation from the frontend. |
||
); | ||
} | ||
| ||
if (isConfiguringTheFirstUser) { | ||
|
@@ -90,8 +113,10 @@ export const LoginPage: FC = () => { | |
</Helmet> | ||
<LoginPageView | ||
authMethods={authMethodsQuery.data} | ||
error={signInError} | ||
isLoading={isLoading || authMethodsQuery.isLoading} | ||
error={redirectError ?? signInError} | ||
isLoading={ | ||
isLoading || authMethodsQuery.isLoading || regionsQuery.isLoading | ||
} | ||
buildInfo={buildInfoQuery.data} | ||
isSigningIn={isSigningIn} | ||
onSignIn={async ({ email, password }) => { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
biome extension doesn't work in vscode unless you have dep in root dir 😢