Skip to content

Vercel: every deployed file is served at its repo path, bypassing the app (source disclosure) #141

Description

@moazzyadah

On Vercel, vercel.json routes with rewrites. Vercel evaluates a request in the order
redirects → headers → static file match → rewrites, so the catch-all
/(.*) → /api/vercel.js is only reached for paths that did not already match a file in
the deployment. Every file that ships is therefore served as raw source at its repository
path, and api/vercel.js never runs.

/wp-config.php correctly 404s. /wp/wp-config.php does not — which is why this is easy
to miss.

Reproduction

curl -s https://<site>/wp/wp-includes/version.php
curl -s https://<site>/wp/wp-config.php
curl -s https://<site>/wp/wp-content/plugins/<plugin>/<file>.php

200 with the file's raw contents. Vercel serves .php as application/x-httpd-php with
content-disposition: inline, so a browser renders the source rather than downloading it.

Impact

Confidentiality only — static assets answer GET/HEAD, and a stock wp-config.php reads
its credentials from the environment. Still exposed: the exact WordPress version (turning
CVE matching into a lookup), the full source of every plugin, theme and mu-plugin including
custom code, and anything a site owner has added to wp-config.php themselves — API keys
and custom constants are common there, and nothing signals that the file is public.

The obvious fix silently fails

{ "src": "^/wp/.*", "status": 404 }

In legacy routes, a rule with status and no dest sets the response code and keeps
routing
. The file is still served, wearing a 404 label. A status-code assertion passes
while the body is the full source — compare bodies, not status codes.

Fix

routes is mutually exclusive with rewrites/redirects/headers, and only routes can
place a rule before {"handle": "filesystem"}. Use dest, which terminates routing:

{
  "routes": [
    { "src": "^/(?:wp|util|test|node_modules|\\.github)/.*", "dest": "/api/vercel.js" },
    { "handle": "filesystem" },
    { "src": "/(.*)", "dest": "/api/vercel.js" }
  ]
}

Root-level config files (package.json, vercel.json, serverless.yml, .nvmrc, …) need
the same treatment. Keep the existing static-asset rules after the deny: /wp-content/x.css
does not match ^/wp/ (hyphen, not slash), so it still resolves normally.

.vercelignore cannot solve this — functions["api/vercel.js"].includeFiles: "wp/**"
requires those files to be uploaded.

Scope

Confirmed on two production deployments built from this template, and confirmed fixed by the
routing change above. I have not deployed a pristine clone, so please verify against a stock
deployment before judging severity — one curl of /wp/wp-includes/version.php should
settle it. I have not looked at the Netlify or AWS Lambda targets; their static-file handling
differs and would need checking separately.

Happy to open a PR if this matches how you'd want it structured.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions