feat: moves getSafeRedirect into payload package (#12593)
This commit is contained in:
@@ -16,11 +16,10 @@ import {
|
||||
useConfig,
|
||||
useTranslation,
|
||||
} from '@payloadcms/ui'
|
||||
import { formatAdminURL, getLoginOptions } from 'payload/shared'
|
||||
import { formatAdminURL, getLoginOptions, getSafeRedirect } from 'payload/shared'
|
||||
|
||||
import type { LoginFieldProps } from '../LoginField/index.js'
|
||||
|
||||
import { getSafeRedirect } from '../../../utilities/getSafeRedirect.js'
|
||||
import { LoginField } from '../LoginField/index.js'
|
||||
import './index.scss'
|
||||
|
||||
@@ -92,7 +91,7 @@ export const LoginForm: React.FC<{
|
||||
initialState={initialState}
|
||||
method="POST"
|
||||
onSuccess={handleLogin}
|
||||
redirect={getSafeRedirect(searchParams?.redirect, adminRoute)}
|
||||
redirect={getSafeRedirect({ fallbackTo: adminRoute, redirectTo: searchParams?.redirect })}
|
||||
waitForAutocomplete
|
||||
>
|
||||
<div className={`${baseClass}__inputWrap`}>
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { AdminViewServerProps, ServerProps } from 'payload'
|
||||
|
||||
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
||||
import { redirect } from 'next/navigation.js'
|
||||
import { getSafeRedirect } from 'payload/shared'
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import { Logo } from '../../elements/Logo/index.js'
|
||||
import { getSafeRedirect } from '../../utilities/getSafeRedirect.js'
|
||||
import { LoginForm } from './LoginForm/index.js'
|
||||
import './index.scss'
|
||||
export const loginBaseClass = 'login'
|
||||
@@ -25,7 +25,7 @@ export function LoginView({ initPageResult, params, searchParams }: AdminViewSer
|
||||
routes: { admin },
|
||||
} = config
|
||||
|
||||
const redirectUrl = getSafeRedirect(searchParams.redirect, admin)
|
||||
const redirectUrl = getSafeRedirect({ fallbackTo: admin, redirectTo: searchParams.redirect })
|
||||
|
||||
if (user) {
|
||||
redirect(redirectUrl)
|
||||
|
||||
@@ -38,7 +38,6 @@ export {
|
||||
} from '../fields/config/types.js'
|
||||
|
||||
export { getFieldPaths } from '../fields/getFieldPaths.js'
|
||||
|
||||
export * from '../fields/validations.js'
|
||||
|
||||
export type {
|
||||
@@ -50,21 +49,21 @@ export type {
|
||||
GetFolderDataResult,
|
||||
Subfolder,
|
||||
} from '../folders/types.js'
|
||||
export { formatFolderOrDocumentItem } from '../folders/utils/formatFolderOrDocumentItem.js'
|
||||
|
||||
export { formatFolderOrDocumentItem } from '../folders/utils/formatFolderOrDocumentItem.js'
|
||||
export { validOperators, validOperatorSet } from '../types/constants.js'
|
||||
|
||||
export { formatFilesize } from '../uploads/formatFilesize.js'
|
||||
export { isImage } from '../uploads/isImage.js'
|
||||
|
||||
export { isImage } from '../uploads/isImage.js'
|
||||
export { combineWhereConstraints } from '../utilities/combineWhereConstraints.js'
|
||||
|
||||
export {
|
||||
deepCopyObject,
|
||||
deepCopyObjectComplex,
|
||||
deepCopyObjectSimple,
|
||||
deepCopyObjectSimpleWithoutReactComponents,
|
||||
} from '../utilities/deepCopyObject.js'
|
||||
|
||||
export {
|
||||
deepMerge,
|
||||
deepMergeWithCombinedArrays,
|
||||
@@ -75,16 +74,18 @@ export {
|
||||
export { extractID } from '../utilities/extractID.js'
|
||||
|
||||
export { fieldSchemaToJSON } from '../utilities/fieldSchemaToJSON.js'
|
||||
|
||||
export { flattenAllFields } from '../utilities/flattenAllFields.js'
|
||||
export { default as flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js'
|
||||
|
||||
export { formatAdminURL } from '../utilities/formatAdminURL.js'
|
||||
|
||||
export { formatLabels, toWords } from '../utilities/formatLabels.js'
|
||||
export { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js'
|
||||
export { getDataByPath } from '../utilities/getDataByPath.js'
|
||||
|
||||
export { getFieldPermissions } from '../utilities/getFieldPermissions.js'
|
||||
|
||||
export { getSafeRedirect } from '../utilities/getSafeRedirect.js'
|
||||
|
||||
export { getSelectMode } from '../utilities/getSelectMode.js'
|
||||
|
||||
export { getSiblingData } from '../utilities/getSiblingData.js'
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('getSafeRedirect', () => {
|
||||
'should allow safe relative path: %s',
|
||||
(input) => {
|
||||
// If the input is a clean relative path, it should be returned as-is
|
||||
expect(getSafeRedirect(input, fallback)).toBe(input)
|
||||
expect(getSafeRedirect({ redirectTo: input, fallbackTo: fallback })).toBe(input)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('getSafeRedirect', () => {
|
||||
'should fallback on invalid or non-string input: %s',
|
||||
(input) => {
|
||||
// If the input is not a valid string, it should return the fallback
|
||||
expect(getSafeRedirect(input as any, fallback)).toBe(fallback)
|
||||
expect(getSafeRedirect({ redirectTo: input as any, fallbackTo: fallback })).toBe(fallback)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -36,20 +36,24 @@ describe('getSafeRedirect', () => {
|
||||
'%2Fjavascript:alert(1)', // encoded JavaScript scheme
|
||||
])('should block unsafe redirect: %s', (input) => {
|
||||
// All of these should return the fallback because they’re unsafe
|
||||
expect(getSafeRedirect(input, fallback)).toBe(fallback)
|
||||
expect(getSafeRedirect({ redirectTo: input, fallbackTo: fallback })).toBe(fallback)
|
||||
})
|
||||
|
||||
// Input with extra spaces should still be properly handled
|
||||
it('should trim whitespace before evaluating', () => {
|
||||
// A valid path with surrounding spaces should still be accepted
|
||||
expect(getSafeRedirect(' /dashboard ', fallback)).toBe('/dashboard')
|
||||
expect(getSafeRedirect({ redirectTo: ' /dashboard ', fallbackTo: fallback })).toBe(
|
||||
'/dashboard',
|
||||
)
|
||||
|
||||
// An unsafe path with spaces should still be rejected
|
||||
expect(getSafeRedirect(' //example.com ', fallback)).toBe(fallback)
|
||||
expect(getSafeRedirect({ redirectTo: ' //example.com ', fallbackTo: fallback })).toBe(
|
||||
fallback,
|
||||
)
|
||||
})
|
||||
|
||||
// If decoding the input fails (e.g., invalid percent encoding), it should not crash
|
||||
it('should return fallback on invalid encoding', () => {
|
||||
expect(getSafeRedirect('%E0%A4%A', fallback)).toBe(fallback)
|
||||
expect(getSafeRedirect({ redirectTo: '%E0%A4%A', fallbackTo: fallback })).toBe(fallback)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,22 @@
|
||||
export const getSafeRedirect = (
|
||||
redirectParam: string | string[],
|
||||
fallback: string = '/',
|
||||
): string => {
|
||||
if (typeof redirectParam !== 'string') {
|
||||
return fallback
|
||||
export const getSafeRedirect = ({
|
||||
allowAbsoluteUrls = false,
|
||||
fallbackTo = '/',
|
||||
redirectTo,
|
||||
}: {
|
||||
allowAbsoluteUrls?: boolean
|
||||
fallbackTo?: string
|
||||
redirectTo: string | string[]
|
||||
}): string => {
|
||||
if (typeof redirectTo !== 'string') {
|
||||
return fallbackTo
|
||||
}
|
||||
|
||||
// Normalize and decode the path
|
||||
let redirectPath: string
|
||||
try {
|
||||
redirectPath = decodeURIComponent(redirectParam.trim())
|
||||
redirectPath = decodeURIComponent(redirectTo.trim())
|
||||
} catch {
|
||||
return fallback // invalid encoding
|
||||
return fallbackTo // invalid encoding
|
||||
}
|
||||
|
||||
const isSafeRedirect =
|
||||
@@ -30,5 +35,10 @@ export const getSafeRedirect = (
|
||||
// Prevent attempts to redirect to full URLs using "/http:" or "/https:"
|
||||
!redirectPath.toLowerCase().startsWith('/http')
|
||||
|
||||
return isSafeRedirect ? redirectPath : fallback
|
||||
const isAbsoluteSafeRedirect =
|
||||
allowAbsoluteUrls &&
|
||||
// Must be a valid absolute URL with http or https
|
||||
/^https?:\/\/\S+$/i.test(redirectPath)
|
||||
|
||||
return isSafeRedirect || isAbsoluteSafeRedirect ? redirectPath : fallbackTo
|
||||
}
|
||||
Reference in New Issue
Block a user