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.
On Vercel,
vercel.jsonroutes withrewrites. Vercel evaluates a request in the orderredirects → headers → static file match → rewrites, so the catch-all/(.*) → /api/vercel.jsis only reached for paths that did not already match a file inthe deployment. Every file that ships is therefore served as raw source at its repository
path, and
api/vercel.jsnever runs./wp-config.phpcorrectly 404s./wp/wp-config.phpdoes not — which is why this is easyto miss.
Reproduction
200with the file's raw contents. Vercel serves.phpasapplication/x-httpd-phpwithcontent-disposition: inline, so a browser renders the source rather than downloading it.Impact
Confidentiality only — static assets answer
GET/HEAD, and a stockwp-config.phpreadsits 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.phpthemselves — API keysand 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 withstatusand nodestsets the response code and keepsrouting. 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
routesis mutually exclusive withrewrites/redirects/headers, and onlyroutescanplace a rule before
{"handle": "filesystem"}. Usedest, 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, …) needthe same treatment. Keep the existing static-asset rules after the deny:
/wp-content/x.cssdoes not match
^/wp/(hyphen, not slash), so it still resolves normally..vercelignorecannot 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
curlof/wp/wp-includes/version.phpshouldsettle 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.