Skip to content

Commit f30d34f

Browse files
authored
fix: improved request origin retrieval (#15919)
Adds a new function `getRequestOrigin` that properly retrieves the origin of the request, either from `serverURL` or a valid `host` header
1 parent 93b90da commit f30d34f

File tree

8 files changed

+205
-19
lines changed

8 files changed

+205
-19
lines changed

packages/graphql/src/resolvers/auth/forgotPassword.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ export function forgotPassword(collection: Collection): any {
1212
email: args.email,
1313
username: args.username,
1414
},
15-
disableEmail: args.disableEmail,
16-
expiration: args.expiration,
1715
req: isolateObjectProperty(context.req, 'transactionID'),
1816
}
1917

packages/graphql/src/schema/initCollections.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,8 +550,6 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
550550
graphqlResult.Mutation.fields[`forgotPassword${singularName}`] = {
551551
type: new GraphQLNonNull(GraphQLBoolean),
552552
args: {
553-
disableEmail: { type: GraphQLBoolean },
554-
expiration: { type: GraphQLInt },
555553
...authArgs,
556554
},
557555
resolve: forgotPassword(collection),

packages/payload/src/auth/endpoints/forgotPassword.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ export const forgotPasswordHandler: PayloadHandler = async (req) => {
2323
await forgotPasswordOperation({
2424
collection,
2525
data: authData,
26-
disableEmail: Boolean(req.data?.disableEmail),
27-
expiration: typeof req.data?.expiration === 'number' ? req.data.expiration : undefined,
2826
req,
2927
})
3028

packages/payload/src/auth/operations/forgotPassword.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import crypto from 'crypto'
22
import { status as httpStatus } from 'http-status'
3-
import { URL } from 'url'
43

54
import type {
65
AuthOperationsFromCollectionSlug,
@@ -16,6 +15,7 @@ import { Forbidden } from '../../index.js'
1615
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
1716
import { commitTransaction } from '../../utilities/commitTransaction.js'
1817
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
18+
import { getRequestOrigin } from '../../utilities/getRequestOrigin.js'
1919
import { initTransaction } from '../../utilities/initTransaction.js'
2020
import { killTransaction } from '../../utilities/killTransaction.js'
2121
import { getLoginOptions } from '../getLoginOptions.js'
@@ -155,11 +155,7 @@ export const forgotPasswordOperation = async <TSlug extends AuthCollectionSlug>(
155155
})
156156

157157
if (!disableEmail && user.email) {
158-
const protocol = new URL(req.url!).protocol // includes the final :
159-
const serverURL =
160-
config.serverURL !== null && config.serverURL !== ''
161-
? config.serverURL
162-
: `${protocol}//${req.headers.get('host')}`
158+
const serverURL = getRequestOrigin({ config, req })
163159
const forgotURL = formatAdminURL({
164160
adminRoute: config.routes.admin,
165161
path: `${config.admin.routes.reset}/${token}`,

packages/payload/src/auth/sendVerificationEmail.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { URL } from 'url'
2-
31
import type { Collection } from '../collections/config/types.js'
42
import type { SanitizedConfig } from '../config/types.js'
53
import type { InitializedEmailAdapter } from '../email/types.js'
@@ -8,6 +6,7 @@ import type { PayloadRequest } from '../types/index.js'
86
import type { VerifyConfig } from './types.js'
97

108
import { formatAdminURL } from '../utilities/formatAdminURL.js'
9+
import { getRequestOrigin } from '../utilities/getRequestOrigin.js'
1110

1211
type Args = {
1312
collection: Collection
@@ -32,11 +31,7 @@ export async function sendVerificationEmail(args: Args): Promise<void> {
3231
} = args
3332

3433
if (!disableEmail) {
35-
const protocol = new URL(req.url!).protocol // includes the final :
36-
const serverURL =
37-
config.serverURL !== null && config.serverURL !== ''
38-
? config.serverURL
39-
: `${protocol}//${req.headers.get('host')}`
34+
const serverURL = getRequestOrigin({ config, req })
4035

4136
const verificationURL = formatAdminURL({
4237
adminRoute: config.routes.admin,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { PayloadRequest } from '../types/index.js'
2+
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { getRequestOrigin } from './getRequestOrigin'
6+
7+
type MinimalReq = Pick<PayloadRequest, 'headers' | 'payload' | 'url'>
8+
9+
const makeReq = (url: string, hostOverride?: string): MinimalReq => {
10+
let host = hostOverride
11+
if (host === undefined) {
12+
try {
13+
host = new URL(url).host
14+
} catch {
15+
host = undefined
16+
}
17+
}
18+
return {
19+
url,
20+
headers: new Headers(host !== undefined ? { host } : {}),
21+
payload: {
22+
logger: {
23+
warn: () => {},
24+
},
25+
},
26+
} as unknown as MinimalReq
27+
}
28+
29+
describe('getRequestOrigin', () => {
30+
describe('when config.serverURL is set', () => {
31+
it('should return serverURL regardless of Host header', () => {
32+
const req = makeReq('https://ignored.com/api/forgot-password', 'attacker.com')
33+
const result = getRequestOrigin({
34+
config: { serverURL: 'https://myapp.com', cors: '*', csrf: [] },
35+
req,
36+
})
37+
expect(result).toBe('https://myapp.com')
38+
})
39+
})
40+
41+
describe('when config.serverURL is not set', () => {
42+
describe('CORS allowlist validation', () => {
43+
it('should return origin when Host header matches a CORS string array entry', () => {
44+
const req = makeReq('https://myapp.com/api/forgot-password')
45+
const result = getRequestOrigin({
46+
config: { serverURL: '', cors: ['https://myapp.com', 'https://other.com'], csrf: [] },
47+
req,
48+
})
49+
expect(result).toBe('https://myapp.com')
50+
})
51+
52+
it('should return origin when Host header matches a CORSConfig origins entry', () => {
53+
const req = makeReq('https://myapp.com/api/verify')
54+
const result = getRequestOrigin({
55+
config: {
56+
serverURL: '',
57+
cors: { headers: [], origins: ['https://myapp.com'] },
58+
csrf: [],
59+
},
60+
req,
61+
})
62+
expect(result).toBe('https://myapp.com')
63+
})
64+
65+
it('should return empty string when Host header is forged and not in CORS allowlist', () => {
66+
const req = makeReq('https://myapp.com/api/forgot-password', 'attacker.com')
67+
const result = getRequestOrigin({
68+
config: { serverURL: '', cors: ['https://myapp.com'], csrf: [] },
69+
req,
70+
})
71+
expect(result).toBe('')
72+
})
73+
})
74+
75+
describe('CSRF allowlist validation', () => {
76+
it('should return origin when Host header matches a CSRF entry', () => {
77+
const req = makeReq('https://myapp.com/api/verify')
78+
const result = getRequestOrigin({
79+
config: { serverURL: '', cors: [], csrf: ['https://myapp.com'] },
80+
req,
81+
})
82+
expect(result).toBe('https://myapp.com')
83+
})
84+
85+
it('should return origin when Host header is in CSRF but not in CORS', () => {
86+
const req = makeReq('https://myapp.com/api/verify')
87+
const result = getRequestOrigin({
88+
config: {
89+
serverURL: '',
90+
cors: ['https://other.com'],
91+
csrf: ['https://myapp.com'],
92+
},
93+
req,
94+
})
95+
expect(result).toBe('https://myapp.com')
96+
})
97+
})
98+
99+
describe('malformed or missing Host header', () => {
100+
it('should return empty string when req.url is not a valid URL', () => {
101+
const req = makeReq('not-a-url')
102+
const result = getRequestOrigin({
103+
config: { serverURL: '', cors: [], csrf: [] },
104+
req,
105+
})
106+
expect(result).toBe('')
107+
})
108+
109+
it('should return empty string when Host header is absent', () => {
110+
const req = makeReq('https://myapp.com/api/forgot-password', '')
111+
const result = getRequestOrigin({
112+
config: { serverURL: '', cors: ['https://myapp.com'], csrf: [] },
113+
req,
114+
})
115+
expect(result).toBe('')
116+
})
117+
})
118+
})
119+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { CORSConfig, SanitizedConfig } from '../config/types.js'
2+
import type { PayloadRequest } from '../types/index.js'
3+
4+
const getTrustedOrigins = (config: Pick<SanitizedConfig, 'cors' | 'csrf'>): null | string[] => {
5+
const origins = new Set<string>()
6+
7+
const { cors, csrf } = config
8+
9+
if (cors === '*') {
10+
return null
11+
}
12+
13+
if (Array.isArray(cors)) {
14+
cors.forEach((o) => origins.add(o))
15+
} else if (cors && typeof cors === 'object') {
16+
const corsOrigins = (cors as CORSConfig).origins
17+
if (corsOrigins === '*') {
18+
return null
19+
}
20+
if (Array.isArray(corsOrigins)) {
21+
corsOrigins.forEach((o) => origins.add(o))
22+
}
23+
}
24+
25+
if (Array.isArray(csrf)) {
26+
csrf.forEach((o) => origins.add(o))
27+
}
28+
29+
return [...origins]
30+
}
31+
32+
/**
33+
* Returns a trusted request origin
34+
*/
35+
export const getRequestOrigin = ({
36+
config,
37+
req,
38+
}: {
39+
config: Pick<SanitizedConfig, 'cors' | 'csrf' | 'serverURL'>
40+
req: Pick<PayloadRequest, 'headers' | 'payload' | 'url'>
41+
}): string => {
42+
if (config.serverURL !== null && config.serverURL !== '') {
43+
return config.serverURL
44+
}
45+
46+
let origin = ''
47+
try {
48+
const protocol = new URL(req.url!).protocol
49+
const host = req.headers?.get('host')
50+
if (host) {
51+
origin = `${protocol}//${host}`
52+
}
53+
} catch {
54+
// req.url is malformed; origin stays empty
55+
}
56+
57+
const trustedOrigins = getTrustedOrigins(config)
58+
59+
if (trustedOrigins !== null && origin && trustedOrigins.includes(origin)) {
60+
// Host header value is explicitly listed in the CORS/CSRF allowlist — safe to use.
61+
return origin
62+
}
63+
64+
req.payload.logger.warn(
65+
`Request origin "${origin}" is not in the CORS/CSRF allowlist. Falling back to empty string as request origin. It is recommended to explicitly set the serverURL in the config to avoid this warning and ensure correct request origin is used.`,
66+
)
67+
68+
return ''
69+
}

test/_community/payload-types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ export interface Config {
9696
menu: MenuSelect<false> | MenuSelect<true>;
9797
};
9898
locale: null;
99+
widgets: {
100+
collections: CollectionsWidget;
101+
};
99102
user: User;
100103
jobs: {
101104
tasks: unknown;
@@ -435,6 +438,16 @@ export interface MenuSelect<T extends boolean = true> {
435438
createdAt?: T;
436439
globalType?: T;
437440
}
441+
/**
442+
* This interface was referenced by `Config`'s JSON-Schema
443+
* via the `definition` "collections_widget".
444+
*/
445+
export interface CollectionsWidget {
446+
data?: {
447+
[k: string]: unknown;
448+
};
449+
width: 'full';
450+
}
438451
/**
439452
* This interface was referenced by `Config`'s JSON-Schema
440453
* via the `definition` "auth".

0 commit comments

Comments
 (0)