Describe the Bug
isURLAllowed translates an upload allow-list pathname pattern into a regular expression by string-replacing the * / ** wildcards, but it interpolates the rest of the pattern into new RegExp() without escaping regex metacharacters. Two problems result:
- Over-matching (fail-open). Metacharacters in a configured pattern - most notably
. - are interpreted as regex syntax, so the allow-list matches more URLs than configured. Pattern /files/report.json also matches /files/reportXjson.
- Broken double-star translation (fail-closed). The
** token is converted to .* before * is converted to [^/]*, so the * inside that newly inserted .* gets rewritten too. /uploads/** compiles to /uploads/.[^/]*, which does not match /uploads/nested/file.png as intended.
Why it matters: isURLAllowed is the gate that decides whether an upload-related fetch skips SSRF protection (safeFetch). In packages/payload/src/uploads/endpoints/getFileFromURL.ts, an allow-list match makes the handler use a plain fetch instead of safeFetch (comment: "Allow-listed URLs bypass SSRF filtering (e.g. internal/localhost CDNs)."); the same applies to pasteURL.allowList and skipSafeFetch in packages/payload/src/uploads/getExternalFile.ts. So over-broad pathname matching widens the set of URLs the server will fetch without SSRF protection.
Affected code (two copies, both affected):
packages/payload/src/utilities/isURLAllowed.ts (server-side - authoritative)
packages/ui/src/utilities/isURLAllowed.ts (admin UI - client-side pre-check)
The relevant block:
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
.replace(/\/$/, '(/)?') // Allow optional trailing slash
const regex = new RegExp(`^${regexPattern}$`)
return regex.test(parsedUrl.pathname)
}
Severity: Low–Medium (defense-in-depth / least-privilege). The hostname field is required and compared exactly, so this is not a full cross-host SSRF bypass - an attacker cannot redirect the allow-list to an arbitrary host. The realistic impact is over-broad path matching on an already-trusted (often internal) host. The ** issue is a fail-closed correctness regression in the same code.
Suggested fix: escape the pattern first (via the existing escapeRegExp utility, as wordBoundariesRegex.ts already does), then restore the now-escaped wildcards - which also resolves the ordering bug:
const regexPattern = escapeRegExp(value)
.replace(/\\\*\\\*/g, '.*') // `**` → match any path
.replace(/\\\*/g, '[^/]*') // `*` → match any part of a path segment
.replace(/\/$/, '(/)?') // Allow optional trailing slash
Link to the code that reproduces this issue
ciechanowiec@c905a16
Reproduction Steps
isURLAllowed is a pure utility, so the reproduction needs no extra collections/globals - it adds two assertions to test/_community/int.spec.ts.
-
Check out the reproduction (the linked commit on the repro/isURLAllowed-allowlist-bug branch of the fork):
git clone https://github.com/ciechanowiec/payload.git
cd payload
git checkout c905a16d056a93fb7664c6469fb4aa88fb588041
pnpm install
-
Run the _community integration test (the harness boots Payload, so a DB is required - SQLite needs no Docker):
PAYLOAD_DATABASE=sqlite pnpm test:int _community
-
Observe that the two added cases fail. The added test is:
describe('isURLAllowed allow-list pathname matching', () => {
it('should treat a literal dot in the pattern literally, not as a wildcard', () => {
const allowList = [{ hostname: 'cdn.example.com', pathname: '/files/report.json' }]
expect(isURLAllowed('https://cdn.example.com/files/report.json', allowList)).toBe(true)
expect(isURLAllowed('https://cdn.example.com/files/reportXjson', allowList)).toBe(false)
})
it('should match across path segments with `**`', () => {
const allowList = [{ hostname: 'cdn.example.com', pathname: '/uploads/**' }]
expect(isURLAllowed('https://cdn.example.com/uploads/nested/photo.png', allowList)).toBe(
true,
)
})
})
Actual result (unpatched): both assertions fail -
isURLAllowed('https://cdn.example.com/files/reportXjson', …) returns true (expected false) - the unescaped . matched any character.
isURLAllowed('https://cdn.example.com/uploads/nested/photo.png', …) returns false (expected true) - ** compiled to /uploads/.[^/]*.
Expected result: pathname patterns match metacharacters literally; ** matches across path segments (including /) and * matches within a single segment.
Optional - same logic with no build, in any Node REPL:
const oldMatch = (value, pathname) => {
const regexPattern = value.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\/$/, '(/)?')
return { regexPattern, matched: new RegExp(`^${regexPattern}$`).test(pathname) }
}
oldMatch('/files/report.json', '/files/reportXjson').matched
// => true (should be false)
oldMatch('/uploads/**', '/uploads/nested/photo.png')
// => { regexPattern: '/uploads/.[^/]*', matched: false } (should match -> true)
Which area(s) are affected?
area: core
Environment Info
Payload: 4.0.0-beta.0 (monorepo, commit c905a16d056a93fb7664c6469fb4aa88fb588041)
Next.js: 16.2.7
React: 19.2.6
Node.js: v26.0.0 (repo requires >=24.15.0)
pnpm: 10.27.0
OS: macOS (Darwin 25.5.0)
Database: reproduced with sqlite (PAYLOAD_DATABASE=sqlite); not DB-dependent
Describe the Bug
isURLAllowedtranslates an upload allow-listpathnamepattern into a regular expression by string-replacing the*/**wildcards, but it interpolates the rest of the pattern intonew RegExp()without escaping regex metacharacters. Two problems result:.- are interpreted as regex syntax, so the allow-list matches more URLs than configured. Pattern/files/report.jsonalso matches/files/reportXjson.**token is converted to.*before*is converted to[^/]*, so the*inside that newly inserted.*gets rewritten too./uploads/**compiles to/uploads/.[^/]*, which does not match/uploads/nested/file.pngas intended.Why it matters:
isURLAllowedis the gate that decides whether an upload-related fetch skips SSRF protection (safeFetch). Inpackages/payload/src/uploads/endpoints/getFileFromURL.ts, an allow-list match makes the handler use a plainfetchinstead ofsafeFetch(comment: "Allow-listed URLs bypass SSRF filtering (e.g. internal/localhost CDNs)."); the same applies topasteURL.allowListandskipSafeFetchinpackages/payload/src/uploads/getExternalFile.ts. So over-broadpathnamematching widens the set of URLs the server will fetch without SSRF protection.Affected code (two copies, both affected):
packages/payload/src/utilities/isURLAllowed.ts(server-side - authoritative)packages/ui/src/utilities/isURLAllowed.ts(admin UI - client-side pre-check)The relevant block:
Severity: Low–Medium (defense-in-depth / least-privilege). The
hostnamefield is required and compared exactly, so this is not a full cross-host SSRF bypass - an attacker cannot redirect the allow-list to an arbitrary host. The realistic impact is over-broad path matching on an already-trusted (often internal) host. The**issue is a fail-closed correctness regression in the same code.Suggested fix: escape the pattern first (via the existing
escapeRegExputility, aswordBoundariesRegex.tsalready does), then restore the now-escaped wildcards - which also resolves the ordering bug:Link to the code that reproduces this issue
ciechanowiec@c905a16
Reproduction Steps
isURLAllowedis a pure utility, so the reproduction needs no extra collections/globals - it adds two assertions totest/_community/int.spec.ts.Check out the reproduction (the linked commit on the
repro/isURLAllowed-allowlist-bugbranch of the fork):git clone https://github.com/ciechanowiec/payload.git cd payload git checkout c905a16d056a93fb7664c6469fb4aa88fb588041 pnpm installRun the
_communityintegration test (the harness boots Payload, so a DB is required - SQLite needs no Docker):Observe that the two added cases fail. The added test is:
Actual result (unpatched): both assertions fail -
isURLAllowed('https://cdn.example.com/files/reportXjson', …)returnstrue(expectedfalse) - the unescaped.matched any character.isURLAllowed('https://cdn.example.com/uploads/nested/photo.png', …)returnsfalse(expectedtrue) -**compiled to/uploads/.[^/]*.Expected result:
pathnamepatterns match metacharacters literally;**matches across path segments (including/) and*matches within a single segment.Optional - same logic with no build, in any Node REPL:
Which area(s) are affected?
area: core
Environment Info