Skip to content
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

feat: replace redirect url pattern with route key pattern #398

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

zhongliang02
Copy link
Contributor

@zhongliang02 zhongliang02 commented Jan 15, 2025

Context

Due to yet another open redirect VAPT finding (care360: GTA-55-008), it has proved to be very challenging to validate the callbackUrl parameter.

Approach

This PR completely removes the callbackUrl parameter in favor of the redirectRouteKey parameter.

In all situations where we redirect ,redirectRouteKey is first validated to be keyof AllRoutes, falling back to HOME when invalid. (AllRoutes is a dict with all the routes in the app).

Then we resolve the route key to the actual route.

How to read this PR

Please look at all the utilities lib/ schemas/ utils/ first to familiarize with the constructs that enable this route key pattern.

Then evaluate the components involved in the email login flow followed by the SGID login flow.

Finally you can evaluate utility component wrappers under AuthWrappers/

Risks and Testing

Manually tested on https://starter-3ela7h4z5-ogp-tooling.vercel.app/

  • Email login
  • SGID login

Copy link

vercel bot commented Jan 15, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
starter-kit ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jan 20, 2025 4:19am

@datadog-opengovsg
Copy link

datadog-opengovsg bot commented Jan 15, 2025

Datadog Report

Branch report: 01-15-feat_replace_redirect_url_with_route_key_pattern
Commit report: c1b1aa9
Test service: starter-kit

✅ 0 Failed, 11 Passed, 0 Skipped, 10.84s Total Time
⬆️ Test Sessions change in coverage: 1 increased (+0.11%)

@zhongliang02 zhongliang02 requested a review from karrui January 15, 2025 09:56
@zhongliang02 zhongliang02 marked this pull request as ready for review January 15, 2025 09:56
Copy link
Collaborator

@karrui karrui left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i feel like we can keep the changes to a minimum, instead of changing all the variables to something new. The concept is still the same right, just that the "redirect URL"s are now restricted to what the user has explicitly set. should we just change the callbackUrl schema and keep everything else the same?

(trying to make it easy for starter-kit derived project to update their code too, since there might be too many things to change in here for them)

Comment on lines 27 to 30

useEffect(() => {
if (!selectProfileStep) {
setHasLoginStateFlag()
if (!response.success) {
void router.replace(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be run through the zod validation schema too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's not necessary because SIGN_IN is a hardcoded constant


export const SgidLoginButton = (): JSX.Element | null => {
const router = useRouter()
const sgidLoginMutation = trpc.auth.sgid.login.useMutation({
onSuccess: async ({ redirectUrl }) => {
await router.push(redirectUrl)
window.location.href = redirectUrl
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when using nextjs, it is best practice to check for the location of window.location first, since this component might be SSR'd.
this might be a subtle bug if somehow invoked from the server.

why was the router.push call replaced with this? was there a particular reason?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm it's because redirectUrl is already known to be an external URL, so I thought there'd be no benefits from router.pushing it, but I was not aware of the possibility of SSR'ing the component, I thought it's always CSR unless there's getServerSideProps.

src/utils/url.ts Outdated
export const resolveRouteKey = (
v: unknown,
): (typeof AllRoutes)[keyof typeof AllRoutes] => {
return AllRoutes[routeKeySchema.parse(v)]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we return a fallback here? unsure, but better than users not changing routes maybe?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

routeKeySchema.parse(v) will fallback to HOME after parsing. routeKeySchema is guaranteed to produce keyof AllRoutes

@zhongliang02
Copy link
Contributor Author

Re: minimizing changes to variables, I think conceptually it is quite different though,

previous: ?callbackUrl=/home
new: ?redirectRouteKey=HOME, then ALL_ROUTES = {'HOME': '/home'} and we just router.push(ALL_ROUTES[...])

In theory we can remove this layer of indirection and change it to something like
?callbackUrl=/home and ALLOWED_ROUTES = ['/home', ...]

I'll make a commit with the above and we can see how that looks

@zhongliang02
Copy link
Contributor Author

zhongliang02 commented Jan 20, 2025

Reverted to the redirect url pattern, but now callbackUrlSchema simply follows a whitelist.
Technically this is more of a kitchen sink PR now, lmk if you prefer if I split it up to

  • callbackUrlSchema changes
  • useEffect fixes
  • sgid login minor refactoring

Testing checklist

  • Test email happy flow
  • Test sgid happy flow


return <FullscreenSpinner />
}

/**
* Page wrapper that renders children only if the login state localStorage flag has been set.
* Otherwise, will redirect to the route passed into the `redirectTo` prop.
* Otherwise, will redirect to SIGN_IN, which will redirect to route passed into the `redirectTo` prop.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this will redirect to HOME instead? is that intended?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component redirects to /sign-in?callbackUrl=<redirectTo> when not signed in, redirectTo defaults to HOME if not provided.


export const HOME = '/home'
export const PROFILE = '/profile'
export const SETTINGS_PROFILE = '/settings/profile'

export const ALLOWED_CALLBACK_ROUTES = [
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if this would work for dynamic routes (like /user/[userId]). If not, might break many autoredirects/ cause confusion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it doesnt, it will definitely break dynamic routes.

The issue is because router.push does too much magic (e.g. you can do router.push('/home/[x]?x=../signout') to /signout)

It became quite challenging to validate that router.push does not cause unintended navigation with user supplied input (?callbackUrl param) (especially across next versions), so the alternative is to use a hardcoded whitelist approach like the PR shows.

appUrlSchema can still be used to validate urls if the dev wishes to use dynamic urls, just that the additional friction is supposed to discourage that

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to hear about alternatives though, not sure what's the best approach tbh

@karrui karrui requested a review from spaceraccoon February 27, 2025 07:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants