Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/payload/src/utilities/isURLAllowed.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'

import type { AllowList } from '../uploads/types.js'

import { isURLAllowed } from './isURLAllowed.js'

describe('isURLAllowed', () => {
describe('hostname matching', () => {
const allowList: AllowList = [{ hostname: 'cdn.example.com' }]

it('should allow an exactly matching hostname', () => {
expect(isURLAllowed('https://cdn.example.com/file.png', allowList)).toBe(true)
})

it('should deny a different hostname', () => {
expect(isURLAllowed('https://attacker.com/file.png', allowList)).toBe(false)
})

it('should deny a userinfo `@` trick (hostname is the real authority)', () => {
expect(isURLAllowed('https://cdn.example.com@attacker.com/file.png', allowList)).toBe(false)
})

it('should deny an invalid URL', () => {
expect(isURLAllowed('not a url', allowList)).toBe(false)
})
})

describe('pathname matching', () => {
it('should treat a literal dot as a literal, not a wildcard', () => {
const allowList: AllowList = [{ hostname: 'cdn.example.com', pathname: '/files/report.json' }]

expect(isURLAllowed('https://cdn.example.com/files/report.json', allowList)).toBe(true)
// Previously the unescaped `.` matched any character, widening the allow-list.
expect(isURLAllowed('https://cdn.example.com/files/reportXjson', allowList)).toBe(false)
})

it('should not let other regex metacharacters broaden the match', () => {
const allowList: AllowList = [{ hostname: 'cdn.example.com', pathname: '/a+b/(c)' }]

expect(isURLAllowed('https://cdn.example.com/a+b/(c)', allowList)).toBe(true)
expect(isURLAllowed('https://cdn.example.com/aaab/c', allowList)).toBe(false)
})

it('should match a single segment with `*` but not across slashes', () => {
const allowList: AllowList = [{ hostname: 'cdn.example.com', pathname: '/uploads/*' }]

expect(isURLAllowed('https://cdn.example.com/uploads/photo.png', allowList)).toBe(true)
expect(isURLAllowed('https://cdn.example.com/uploads/nested/photo.png', allowList)).toBe(
false,
)
})

it('should match across slashes with `**`', () => {
const allowList: AllowList = [{ hostname: 'cdn.example.com', pathname: '/uploads/**' }]

expect(isURLAllowed('https://cdn.example.com/uploads/photo.png', allowList)).toBe(true)
expect(isURLAllowed('https://cdn.example.com/uploads/nested/photo.png', allowList)).toBe(true)
})

it('should allow an optional trailing slash', () => {
const allowList: AllowList = [{ hostname: 'cdn.example.com', pathname: '/assets/' }]

expect(isURLAllowed('https://cdn.example.com/assets', allowList)).toBe(true)
expect(isURLAllowed('https://cdn.example.com/assets/', allowList)).toBe(true)
})
})
})
15 changes: 11 additions & 4 deletions packages/payload/src/utilities/isURLAllowed.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { AllowList } from '../uploads/types.js'

import { escapeRegExp } from './escapeRegExp.js'

export const isURLAllowed = (url: string, allowList: AllowList): boolean => {
try {
const parsedUrl = new URL(url)
Expand All @@ -16,10 +18,15 @@ export const isURLAllowed = (url: string, allowList: AllowList): boolean => {
}

if (key === 'pathname') {
// Convert wildcards to a regex
const regexPattern = value
.replace(/\*\*/g, '.*') // Match any path
.replace(/\*/g, '[^/]*') // Match any part of a path segment
// Translate a small glob syntax to a regex. The pattern is escaped
// first so that metacharacters in the configured value (e.g. `.`)
// match literally and cannot broaden what the allow-list accepts.
// Wildcards become `\*` once escaped, so they are restored afterwards
// — translating `**` before `*` is safe because the resulting `.*`
// no longer contains an escaped `\*` for the next replace to match.
const regexPattern = escapeRegExp(value)
.replace(/\\\*\\\*/g, '.*') // `**` → match any path
.replace(/\\\*/g, '[^/]*') // `*` → match any part of a path segment
.replace(/\/$/, '(/)?') // Allow optional trailing slash
const regex = new RegExp(`^${regexPattern}$`)
return regex.test(parsedUrl.pathname)
Expand Down
12 changes: 9 additions & 3 deletions packages/ui/src/utilities/isURLAllowed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ export const isURLAllowed = (url: string, allowList: AllowList): boolean => {
}

if (key === 'pathname') {
// Convert wildcards to a regex
// Translate a small glob syntax to a regex. The pattern is escaped
// first so that metacharacters in the configured value (e.g. `.`)
// match literally and cannot broaden what the allow-list accepts.
// Wildcards become `\*` once escaped, so they are restored afterwards
// — translating `**` before `*` is safe because the resulting `.*`
// no longer contains an escaped `\*` for the next replace to match.
const regexPattern = value
.replace(/\*\*/g, '.*') // Match any path
.replace(/\*/g, '[^/]*') // Match any part of a path segment
.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&') // Escape regex metacharacters
.replace(/\\\*\\\*/g, '.*') // `**` → match any path
.replace(/\\\*/g, '[^/]*') // `*` → match any part of a path segment
const regex = new RegExp(`^${regexPattern}$`)
return regex.test(parsedUrl.pathname)
}
Expand Down
Loading