# CVE-2025-30208 — Vite dev server `@fs` access-control bypass via crafted query strings

## Summary

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`).

## Impact

- **Package / component:** `npm:vite` — dev server `@fs` file-serving middleware
  (`packages/vite/src/node/server/middlewares/transform.ts`, interaction with
  `middlewares/static.ts` `ensureServingAccess`).
- **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 via `host: 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, body
  `export default "<SECRET>\n"`), while the non-crafted request to the same
  `/@fs/` path was correctly denied with HTTP 403. The patched `vite@6.2.3`
  returns 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:

```ts
// vulnerable
if (
  (rawRE.test(url) || urlRE.test(url)) &&
  !ensureServingAccess(url, server, res, next)
) {
  return
}
```

with (in `utils.ts`):

```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??`:

1. `rawRE.test(url)` → looks for `?raw` / `&raw` followed by `&` or `$`. The
   `&raw` token is followed by `?` (the trailing `??`), which is neither `&` nor
   end-of-string → **false**. `urlRE.test(url)` → **false**.
2. The whole `if` condition is therefore **false** (short-circuits), so
   `ensureServingAccess(...)` — the only place that enforces the allow list for
   this code path — is **never invoked**, and the guard does **not** `return`.
3. Execution falls through to the transform entry condition:
   ```ts
   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&` (the `import` token *is* followed by
   `&`), so the request enters the transform block. `removeImportQuery(url)`
   strips the `import` query, leaving `?raw`, and `transformRequest` loads the
   file via the `vite:raw` plugin (`export default <JSON.stringify(content)>`).
   The raw plugin's `load` reads 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)):

```ts
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

1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent).
2. **What it does:**
   - Reads `bundle/project_cache_context.json` and uses the durable project cache
     dir (`prepared=true`) for the Vite projects; falls back to
     `bundle/artifacts` otherwise.
   - Creates two minimal Vite projects (`index.html`, `src/main.js`,
     `vite.config.js` with `server.host:true`, `strictPort:true`,
     `fs:{strict:true, allow:[cwd]}`) and installs the **vulnerable** `vite@6.2.2`
     and the **patched** `vite@6.2.3` respectively via `npm 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.3` dev server, and sends the
     same crafted request → expects **403** with no secret (negative control).
   - Writes `bundle/repro/runtime_manifest.json` (via `jq`) and a result summary.
     Exits 0 only when: vuln normal=403, vuln crafted=200 + leaked, fixed
     crafted=403 + not leaked.
3. **Expected evidence of reproduction:** HTTP 403 for the plain `/@fs/` request
   and HTTP 200 whose body is `export default "<SECRET_TOKEN>\n"` for the crafted
   `?import&raw??` request on `vite@6.2.2`; HTTP 403 (no secret) for the same
   crafted request on `vite@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**, body
    `export default "TOP_SECRET_VITE_BYPASS_TOKEN_<rand>_CANARY\n"` (secret
    leaked through the `@fs` converter).
  - `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 under `evidence`.
- **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) and
  `vite@6.2.3` (fixed) installed from npm; dev server bound to `0.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
  upstream `fs-serve` playground tests added by the fix.
- **Defense-in-depth:** the raw/transform plugins should not rely solely on the
  middleware guard; consider also enforcing `isFileServingAllowed` inside 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-vuln` and `.../vite-repro-fixed`); on re-runs
  `npm install` reports `up 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 and
  `stop_server` reliably terminates it (with `pkill -P` for 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:** on `vite@6.2.2` a bare
  `?raw??` does not enter the transform block for a non-JS file unless a
  `Sec-Fetch-Dest: script` header / JS-like target makes `isJSRequest`/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??`.
