Summary
When Vite's HTML transformation is invoked manually via server.transformIndexHtml
, the original request URL is passed in unmodified, and the html
being transformed contains inline module scripts (<script type="module">...</script>
), it is possible to inject arbitrary HTML into the transformed output by supplying a malicious URL query string to server.transformIndexHtml
.
Impact
Only apps using appType: 'custom'
and using the default Vite HTML middleware are affected. The HTML entry must also contain an inline script. The attack requires a user to click on a malicious URL while running the dev server. Restricted files aren't exposed to the attacker.
Patches
Fixed in vite@5.0.5, vite@4.5.1, vite@4.4.12
Details
Suppose index.html
contains an inline module script:
<script type="module">
// Inline script
</script>
This script is transformed into a proxy script like
<script type="module" src="/index.html?html-proxy&index=0.js"></script>
due to Vite's HTML plugin:
|
if (isModule) { |
|
inlineModuleIndex++ |
|
if (url && !isExcludedUrl(url) && !isPublicFile) { |
|
// <script type="module" src="..."/> |
|
// add it as an import |
|
js += `\nimport ${JSON.stringify(url)}` |
|
shouldRemove = true |
|
} else if (node.childNodes.length) { |
|
const scriptNode = |
|
node.childNodes.pop() as DefaultTreeAdapterMap['textNode'] |
|
const contents = scriptNode.value |
|
// <script type="module">...</script> |
|
const filePath = id.replace(normalizePath(config.root), '') |
|
addToHTMLProxyCache(config, filePath, inlineModuleIndex, { |
|
code: contents, |
|
}) |
|
js += `\nimport "${id}?html-proxy&index=${inlineModuleIndex}.js"` |
|
shouldRemove = true |
|
} |
|
|
|
everyScriptIsAsync &&= isAsync |
|
someScriptsAreAsync ||= isAsync |
|
someScriptsAreDefer ||= !isAsync |
|
} else if (url && !isPublicFile) { |
|
if (!isExcludedUrl(url)) { |
|
config.logger.warn( |
|
`<script src="${url}"> in "${publicPath}" can't be bundled without type="module" attribute`, |
|
) |
|
} |
|
} else if (node.childNodes.length) { |
|
const scriptNode = |
|
node.childNodes.pop() as DefaultTreeAdapterMap['textNode'] |
|
scriptUrls.push( |
|
...extractImportExpressionFromClassicScript(scriptNode), |
|
) |
|
} |
|
} |
When appType: 'spa' | 'mpa'
, Vite serves HTML itself, and htmlFallbackMiddleware
rewrites req.url
to the canonical path of index.html
,
|
if (fs.existsSync(filePath)) { |
|
const newUrl = url + 'index.html' |
|
debug?.(`Rewriting ${req.method} ${req.url} to ${newUrl}`) |
|
req.url = newUrl |
so the url
passed to server.transformIndexHtml
is /index.html
.
However, if appType: 'custom'
, HTML is served manually, and if server.transformIndexHtml
is called with the unmodified request URL (as the SSR docs suggest), then the path of the transformed html-proxy
script varies with the request URL. For example, a request with path /
produces
<script type="module" src="/@id/__x00__/index.html?html-proxy&index=0.js"></script>
It is possible to abuse this behavior by crafting a request URL to contain a malicious payload like
"></script><script>alert('boom')</script>
so a request to http://localhost:5173/?%22%3E%3C/script%3E%3Cscript%3Ealert(%27boom%27)%3C/script%3E produces HTML output like
<script type="module" src="/@id/__x00__/?"></script><script>alert("boom")</script>?html-proxy&index=0.js"></script>
which demonstrates XSS.
PoC
- Example 1. Serving HTML from
vite dev
middleware with appType: 'custom'
- Example 2. Serving HTML from SSR-style Express server (Vite dev server runs in middleware mode):
- Example 3. Plain
vite dev
(this shows that vanilla vite dev
is not vulnerable, provided htmlFallbackMiddleware
is used)
Detailed Impact
This will probably predominantly affect development-mode SSR, where vite.transformHtml
is called using the original req.url
, per the docs:
|
const url = req.originalUrl |
|
|
|
try { |
|
// 1. Read index.html |
|
let template = fs.readFileSync( |
|
path.resolve(__dirname, 'index.html'), |
|
'utf-8', |
|
) |
|
|
|
// 2. Apply Vite HTML transforms. This injects the Vite HMR client, |
|
// and also applies HTML transforms from Vite plugins, e.g. global |
|
// preambles from @vitejs/plugin-react |
|
template = await vite.transformIndexHtml(url, template) |
However, since this vulnerability affects server.transformIndexHtml
, the scope of impact may be higher to also include other ad-hoc calls to server.transformIndexHtml
from outside of Vite's own codebase.
My best guess at bisecting which versions are vulnerable involves the following test script
import fs from 'node:fs/promises';
import * as vite from 'vite';
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<script type="module">
// Inline script
</script>
</body>
</html>
`;
const server = await vite.createServer({ appType: 'custom' });
const transformed = await server.transformIndexHtml('/?%22%3E%3C/script%3E%3Cscript%3Ealert(%27boom%27)%3C/script%3E', html);
console.log(transformed);
await server.close();
and using it I was able to narrow down to #13581. If this is correct, then vulnerable Vite versions are 4.4.0-beta.2 and higher (which includes 4.4.0).
Summary
When Vite's HTML transformation is invoked manually via
server.transformIndexHtml
, the original request URL is passed in unmodified, and thehtml
being transformed contains inline module scripts (<script type="module">...</script>
), it is possible to inject arbitrary HTML into the transformed output by supplying a malicious URL query string toserver.transformIndexHtml
.Impact
Only apps using
appType: 'custom'
and using the default Vite HTML middleware are affected. The HTML entry must also contain an inline script. The attack requires a user to click on a malicious URL while running the dev server. Restricted files aren't exposed to the attacker.Patches
Fixed in vite@5.0.5, vite@4.5.1, vite@4.4.12
Details
Suppose
index.html
contains an inline module script:This script is transformed into a proxy script like
due to Vite's HTML plugin:
vite/packages/vite/src/node/plugins/html.ts
Lines 429 to 465 in 7fd7c6c
When
appType: 'spa' | 'mpa'
, Vite serves HTML itself, andhtmlFallbackMiddleware
rewritesreq.url
to the canonical path ofindex.html
,vite/packages/vite/src/node/server/middlewares/htmlFallback.ts
Lines 44 to 47 in 73ef074
so the
url
passed toserver.transformIndexHtml
is/index.html
.However, if
appType: 'custom'
, HTML is served manually, and ifserver.transformIndexHtml
is called with the unmodified request URL (as the SSR docs suggest), then the path of the transformedhtml-proxy
script varies with the request URL. For example, a request with path/
producesIt is possible to abuse this behavior by crafting a request URL to contain a malicious payload like
so a request to http://localhost:5173/?%22%3E%3C/script%3E%3Cscript%3Ealert(%27boom%27)%3C/script%3E produces HTML output like
which demonstrates XSS.
PoC
vite dev
middleware withappType: 'custom'
?%22%3E%3C/script%3E%3Cscript%3Ealert(%27boom%27)%3C/script%3E
and navigatevite dev
(this shows that vanillavite dev
is not vulnerable, providedhtmlFallbackMiddleware
is used)Detailed Impact
This will probably predominantly affect development-mode SSR, where
vite.transformHtml
is called using the originalreq.url
, per the docs:vite/docs/guide/ssr.md
Lines 114 to 126 in 7fd7c6c
However, since this vulnerability affects
server.transformIndexHtml
, the scope of impact may be higher to also include other ad-hoc calls toserver.transformIndexHtml
from outside of Vite's own codebase.My best guess at bisecting which versions are vulnerable involves the following test script
and using it I was able to narrow down to #13581. If this is correct, then vulnerable Vite versions are 4.4.0-beta.2 and higher (which includes 4.4.0).