Skip to content

isURLAllowed does not escape regex metacharacters in pathname patterns, widening upload allow-list matching #16996

Description

@ciechanowiec

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:

  1. 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.
  2. 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.

  1. 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
  2. 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
  3. 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

Metadata

Metadata

Assignees

Labels

area: coreCore Payload functionalityv3

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions