Nuxt: `__nuxt_island` endpoint does not bind responses to request props, enabling shared-cache poisoning
Description
Summary
The /__nuxt_island/* endpoint accepts attacker-controlled props query/body parameters and renders any island component without verifying that the URL-resident hash (_.json) was actually issued for those inputs by ``. The hash is computed and embedded client-side but never validated server-side, so the same path can return materially different responses depending on the query.
Island components are documented as rendering independently of route context - page middleware does not apply to them, and they are intentionally cacheable as a function of their props. This advisory does not treat that contract as a vulnerability. It treats the absence of a binding between the URL the cache keys on and the response served at that URL as one.
Impact
In applications where a CDN or reverse-proxy in front of the app caches /__nuxt_island/* keyed by path only (ignoring query) - a documented misconfiguration class, see GHSA-jvhm-gjrh-3h93 - an attacker can prime the cache for a path with their own choice of props, and subsequent users requesting the same path receive the attacker's rendered HTML rather than the response intended for them. The cache entry persists until normal expiry.
Where the affected island has any prop flowing into an unsafe HTML sink in application code (v-html, innerHTML, a third-party renderer treating a prop as HTML), this becomes stored XSS in the embedding page's origin until the cache entry expires. HttpOnly cookies remain out of reach but anything else in the origin (other cookies, in-origin requests, DOM state) is reachable by the injected script.
Preconditions:
experimental.componentIslandsenabled (or the default'auto'with at least one server / island component in the app).- A shared intermediary cache (CDN, reverse-proxy, edge cache) keyed on path only.
- For the XSS pivot specifically: an application-authored island that puts a prop through an unsafe HTML sink.
Without the second precondition, the response shape is per-request and unaffected. Without the third, the worst case is content-swap / inert HTML injection rather than script execution.
Patches
Patched in nuxt@4.4.6 and nuxt@3.21.6 by #35077. The island handler now recomputes the expected hashId from (name, props, context) using the same ohash function `` already uses to embed the hash in the URL, and rejects requests (HTTP 400) whose URL-resident hash does not match. The response is now a pure function of the request path: a path-keyed shared cache returns the correct response to every requester for that path, and an attacker cannot synthesise a path whose hash matches arbitrary props.
Workarounds
For users unable to upgrade immediately:
- Ensure any intermediary cache keys
/__nuxt_island/*on the full query string, not on the path alone. This is the recommended configuration regardless. - Audit application-authored islands for props flowing into
v-html/innerHTML/ similar HTML sinks; treat island props as untrusted user input.
Note on island authentication
> [!IMPORTANT] > It's important to remember that route middleware does not run when rendering island components, and islands cannot rely on routing-layer auth. Applications gating sensitive data behind page middleware should enforce that auth inside the island's own data layer (server-only routes, useRequestEvent + manual session checks, etc.) rather than relying on the embedding page's middleware - this was true before this advisory and remains true after it.
A separate advisory addresses *.server.vue *pages* registered as page_ islands, where the documented "middleware doesn't run for islands" contract collides with the page's own definePageMeta({ middleware }) declaration in a way that constitutes a genuine bug rather than documented behaviour.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Nuxt island component endpoint accepts unverified props, allowing cache poisoning and potential stored XSS via CDN/proxy.
Vulnerability
The __nuxt_island/* endpoint in Nuxt accepts attacker-controlled props query or body parameters and renders any island component without validating that the URL-resident hash (_.json) was actually issued for those inputs by ` [1][2]. The hash is computed and embedded client-side but never validated server-side, so the same path returns different responses based on the props supplied. This affects Nuxt 3.x versions with experimental.componentIslands enabled (or the default 'auto'` when at least one server/island component is present) [1][2].
Exploitation
An attacker only needs the ability to make HTTP requests to the Nuxt application; no authentication is required. The attacker sends a crafted request to a specific /__nuxt_island/_.json path including arbitrary props. If a CDN or reverse-proxy in front of the application caches these paths keyed only by the URL (ignoring the query string), the attacker's malicious response is cached [1][2]. Subsequent users requesting the same path will receive the attacker-controlled rendered HTML from the cache until expiration [1][2]. For stored XSS, the island must have a prop that flows into an unsafe HTML sink like v-html or innerHTML [1][2].
Impact
The primary impact is cache poisoning: an attacker primes the cache with arbitrary props, causing other users to see attacker-controlled island content. This can range from UI defacement to, if the island uses an unsafe HTML sink, stored cross-site scripting (XSS) in the embedding page's origin [1][2]. The injected script can access cookies (except HttpOnly), in-origin requests, and DOM state. Responses remain per-request and unaffected when no shared cache keyed on path only is present [1][2].
Mitigation
A fix has been implemented in pull request #35077, which validates the island request hash against the provided props server-side [4]. The fix is available in Nuxt versions published after May 15, 2026 [4]. Users should update to the latest Nuxt version containing this patch (see Nuxt GitHub repository for specific version numbers [3]). As a workaround, ensure any CDN or reverse-proxy is configured to include the query string (or full hash) in the cache key for /__nuxt_island/* endpoints [1][2]. If no fix can be applied and the preconditions are met, consider disabling experimental.componentIslands if island components are not required.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
2e9cddf4c7981fix(nitro): validate island request hash matches props (#35077)
5 files changed · +199 −25
packages/nitro-server/src/runtime/handlers/island.ts+28 −7 modified@@ -6,7 +6,8 @@ import type { EventHandler, H3Event } from 'h3' import { createError, defineEventHandler, getQuery, readBody, setResponseHeaders } from 'h3' import { resolveUnrefHeadInput } from '@unhead/vue' import { getRequestDependencies } from 'vue-bundle-renderer/runtime' -import { getQuery as getURLQuery } from 'ufo' +import { getQuery as getURLQuery, withoutQuery } from 'ufo' +import { computeIslandHash, filterIslandProps } from '#app/island-hash' import type { NuxtIslandContext, NuxtIslandResponse } from 'nuxt/app' import { islandCache, islandPropCache } from '../utils/cache' import { createSSRContext } from '../utils/renderer/app' @@ -116,8 +117,9 @@ const VALID_COMPONENT_NAME_RE = /^[a-z][\w.-]*$/i async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> { let url = event.path || '' if (import.meta.prerender && event.path && await islandPropCache!.hasItem(event.path)) { - // rehydrate props from cache so we can rerender island if cache does not have it any more - url = await islandPropCache!.getItem(event.path) as string + // for prerender, the original request URL (with query) is rehydrated from cache + // so that re-renders of the same island path use the original props + url = await islandPropCache!.getItem(withoutQuery(event.path)) as string } if (!url.startsWith(ISLAND_PATH_PREFIX)) { @@ -132,14 +134,33 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> { throw createError({ statusCode: 400, statusMessage: 'Invalid island component name' }) } - const context = event.method === 'GET' ? getQuery(event) : await readBody(event) + const rawContext = event.method === 'GET' ? getQuery<NuxtIslandContext>(event) : await readBody<NuxtIslandContext>(event) + const rawProps = destr<Record<string, any> | null | undefined>(rawContext?.props) || {} + const filteredProps = filterIslandProps(rawProps) + + // Reconstruct the `context` object as the client computed its hash over. + // `<NuxtIsland>` sends `{ ...props.context, props: JSON.stringify(props.props) }` + const clientContext: Record<string, any> = {} + if (rawContext && typeof rawContext === 'object') { + for (const key in rawContext) { + if (key !== 'props') { + clientContext[key] = (rawContext as Record<string, any>)[key] + } + } + } + + // Bind the response to the URL: a request whose URL-resident `hashId` does not match + // the actual (name, props, context) is rejected. + const expectedHash = computeIslandHash(componentName, filteredProps, clientContext, undefined) + if (!hashId || hashId !== expectedHash) { + throw createError({ status: 400, statusMessage: 'Invalid island request hash' }) + } - // Only extract known context fields to prevent arbitrary data injection return { - url: typeof context?.url === 'string' ? context.url : '/', + url: typeof rawContext?.url === 'string' ? rawContext.url : '/', id: hashId, name: componentName, - props: destr(context.props) || {}, + props: rawProps, slots: {}, components: {}, }
packages/nuxt/src/app/components/nuxt-island.ts+3 −3 modified@@ -1,7 +1,6 @@ import type { Component, PropType, RendererNode, VNode } from 'vue' import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, toRaw, watch, withMemo } from 'vue' import { debounce } from 'perfect-debounce' -import { hash } from 'ohash' import { appendResponseHeader } from '@nuxt/nitro-server/h3' import type { ActiveHeadEntry, SerializableHead } from '@unhead/vue' import { randomUUID } from 'uncrypto' @@ -13,6 +12,7 @@ import { createError } from '../composables/error' import { prerenderRoutes, useRequestEvent } from '../composables/ssr' import { injectHead } from '../composables/head' import { getFragmentHTML, isEndFragment, isStartFragment } from './utils' +import { computeIslandHash, filterIslandProps } from '../island-hash' // @ts-expect-error virtual file import { appBaseURL, remoteComponentIslands, selectiveClient } from '#build/nuxt.config.mjs' @@ -86,8 +86,8 @@ export default defineComponent({ const error = ref<unknown>(null) const config = useRuntimeConfig() const nuxtApp = useNuxtApp() - const filteredProps = computed(() => props.props ? Object.fromEntries(Object.entries(props.props).filter(([key]) => !key.startsWith('data-v-'))) : {}) - const hashId = computed(() => hash([props.name, filteredProps.value, props.context, props.source]).replace(/[-_]/g, '')) + const filteredProps = computed(() => filterIslandProps(props.props)) + const hashId = computed(() => computeIslandHash(props.name, filteredProps.value, props.context, props.source)) const instance = getCurrentInstance()! const event = useRequestEvent()
packages/nuxt/src/app/island-hash.ts+39 −0 added@@ -0,0 +1,39 @@ +import { hash } from 'ohash' + +/** + * Strip Vue scoped-style attributes (`data-v-*`) from island props before hashing + * or rendering. Scoped-id markers leak in from parent components and are not part + * of the logical island input. + * + * Used by both `<NuxtIsland>` (client) and the `/__nuxt_island/*` handler (server) + * to derive the URL-resident `hashId`. + * + * @internal + */ +export function filterIslandProps (props: Record<string, any> | null | undefined): Record<string, any> { + if (!props) { return {} } + const out: Record<string, any> = {} + for (const key in props) { + if (!key.startsWith('data-v-')) { + out[key] = props[key] + } + } + return out +} + +/** + * Compute the `hashId` segment embedded in an island URL (`/__nuxt_island/<Name>_<hashId>.json`). + * + * The hash binds the response to the requested `(name, props, context, source)` tuple, + * so the server can reject requests whose URL hash does not match the supplied query/body. + * + * @internal + */ +export function computeIslandHash ( + name: string, + filteredProps: Record<string, any>, + context: Record<string, any>, + source: string | undefined, +): string { + return hash([name, filteredProps, context, source]).replace(/[-_]/g, '') +}
packages/nuxt/test/island-hash.test.ts+73 −0 added@@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { hash } from 'ohash' +import { computeIslandHash, filterIslandProps } from '#app/island-hash' + +describe('filterIslandProps', () => { + it('returns an empty object for nullish input', () => { + expect(filterIslandProps(undefined)).toEqual({}) + expect(filterIslandProps(null)).toEqual({}) + }) + + it('passes through ordinary props', () => { + expect(filterIslandProps({ a: 1, b: 'x', c: { nested: true } })).toEqual({ + a: 1, + b: 'x', + c: { nested: true }, + }) + }) + + it('strips Vue scoped-style markers', () => { + expect(filterIslandProps({ + 'data-v-abc123': '', + 'data-v-def456': '', + 'count': 3, + 'label': 'hi', + })).toEqual({ count: 3, label: 'hi' }) + }) + + it('preserves keys that merely contain "data-v-"', () => { + // Only the prefix is stripped — keys like `extra-data-v-x` are legitimate. + expect(filterIslandProps({ 'extra-data-v-x': 1, 'data-v-x': 2 })).toEqual({ 'extra-data-v-x': 1 }) + }) +}) + +describe('computeIslandHash', () => { + it('matches the ohash-based shape the client embeds in the URL', () => { + const name = 'PureComponent' + const props = { count: 3, label: 'hi' } + const context = { url: '/foo' } + const expected = hash([name, props, context, undefined]).replace(/[-_]/g, '') + expect(computeIslandHash(name, props, context, undefined)).toBe(expected) + }) + + it('changes when props change', () => { + const a = computeIslandHash('X', { n: 1 }, {}, undefined) + const b = computeIslandHash('X', { n: 2 }, {}, undefined) + expect(a).not.toBe(b) + }) + + it('changes when context changes', () => { + const a = computeIslandHash('X', {}, { url: '/a' }, undefined) + const b = computeIslandHash('X', {}, { url: '/b' }, undefined) + expect(a).not.toBe(b) + }) + + it('changes when name changes', () => { + const a = computeIslandHash('A', {}, {}, undefined) + const b = computeIslandHash('B', {}, {}, undefined) + expect(a).not.toBe(b) + }) + + it('changes when source changes', () => { + const a = computeIslandHash('X', {}, {}, undefined) + const b = computeIslandHash('X', {}, {}, 'https://remote.example') + expect(a).not.toBe(b) + }) + + it('produces URL-safe output (no - or _)', () => { + for (let i = 0; i < 20; i++) { + const h = computeIslandHash('Comp', { i, salt: `${i}-${i}` }, {}, undefined) + expect(h).not.toMatch(/[-_]/) + } + }) +})
test/server-components.test.ts+56 −15 modified@@ -5,10 +5,20 @@ import { isWindows } from 'std-env' import { normalize } from 'pathe' import { $fetch, setup } from '@nuxt/test-utils/e2e' import type { NuxtIslandResponse } from 'nuxt/app' +import { computeIslandHash, filterIslandProps } from '../packages/nuxt/src/app/island-hash' import { isDev, isWebpack } from './matrix' import { renderPage } from './utils' +function islandURL (name: string, opts: { props?: Record<string, any>, context?: Record<string, any> } = {}) { + const filtered = filterIslandProps(opts.props ?? {}) + const ctx = opts.context ?? {} + const hashId = computeIslandHash(name, filtered, ctx, undefined) + const query: Record<string, any> = { ...ctx } + if (opts.props) { query.props = JSON.stringify(opts.props) } + return withQuery(`/__nuxt_island/${name}_${hashId}.json`, query) +} + await setup({ rootDir: fileURLToPath(new URL('./fixtures/server-components', import.meta.url)), dev: isDev, @@ -171,7 +181,7 @@ describe('server components/islands', () => { describe('component islands', () => { it('renders components with route', async () => { - const result = await $fetch<NuxtIslandResponse>('/__nuxt_island/RouteComponent.json?url=/foo') + const result = await $fetch<NuxtIslandResponse>(islandURL('RouteComponent', { context: { url: '/foo' } })) result.html = result.html.replace(/ data-island-uid="[^"]*"/g, '') if (isDev) { @@ -180,6 +190,7 @@ describe('component islands', () => { result.head.link ||= [] result.head.style ||= [] + delete result.id expect(result).toMatchInlineSnapshot(` { @@ -194,18 +205,15 @@ describe('component islands', () => { }) it('render async component', async () => { - const result = await $fetch<NuxtIslandResponse>(withQuery('/__nuxt_island/LongAsyncComponent.json', { - props: JSON.stringify({ - count: 3, - }), - })) + const result = await $fetch<NuxtIslandResponse>(islandURL('LongAsyncComponent', { props: { count: 3 } })) if (isDev) { result.head.link = result.head.link?.filter(l => typeof l.href !== 'string' || (!l.href.includes('_nuxt/components/islands/LongAsyncComponent') && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */)) } result.head.link ||= [] result.head.style ||= [] result.html = result.html.replaceAll(/ (?:data-island-uid|data-island-component)="[^"]*"/g, '') + delete result.id expect(result).toMatchInlineSnapshot(` { "head": { @@ -255,11 +263,7 @@ describe('component islands', () => { }) it('render .server async component', async () => { - const result = await $fetch<NuxtIslandResponse>(withQuery('/__nuxt_island/AsyncServerComponent.json', { - props: JSON.stringify({ - count: 2, - }), - })) + const result = await $fetch<NuxtIslandResponse>(islandURL('AsyncServerComponent', { props: { count: 2 } })) if (isDev) { result.head.link = result.head.link?.filter(l => typeof l.href === 'string' && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */ && (!l.href.startsWith('_nuxt/components/islands/') || l.href.includes('AsyncServerComponent'))) } @@ -270,6 +274,7 @@ describe('component islands', () => { result.components = {} result.slots = {} result.html = result.html.replaceAll(/ (?:data-island-uid|data-island-component)="[^"]*"/g, '') + delete result.id expect(result).toMatchInlineSnapshot(` { @@ -287,7 +292,7 @@ describe('component islands', () => { if (!isWebpack) { it('render server component with selective client hydration', async () => { - const result = await $fetch<NuxtIslandResponse>('/__nuxt_island/ServerWithClient') + const result = await $fetch<NuxtIslandResponse>(islandURL('ServerWithClient')) if (isDev) { result.head.link = result.head.link?.filter(l => typeof l.href !== 'string' || (!l.href.includes('_nuxt/components/islands/LongAsyncComponent') && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */)) @@ -304,6 +309,7 @@ describe('component islands', () => { result.head.link ||= [] result.head.style ||= [] + delete result.id expect(result).toMatchInlineSnapshot(` { @@ -327,13 +333,13 @@ describe('component islands', () => { } it('renders pure components', async () => { - const result = await $fetch<NuxtIslandResponse>(withQuery('/__nuxt_island/PureComponent.json', { - props: JSON.stringify({ + const result = await $fetch<NuxtIslandResponse>(islandURL('PureComponent', { + props: { bool: false, number: 3487, str: 'something', obj: { foo: 42, bar: false, me: 'hi' }, - }), + }, })) result.html = result.html.replace(/ data-island-uid="[^"]*"/g, '') @@ -448,6 +454,41 @@ describe('component islands', () => { }) }) +describe('hash binding', () => { + it('accepts a request whose URL hash matches the props', async () => { + const res = await fetch(islandURL('PureComponent', { + props: { bool: false, number: 1, str: 's', obj: {} }, + })) + expect(res.status).toBe(200) + }) + + it('rejects a request whose URL hash was computed over different props', async () => { + // Compute a valid hash for one set of props, then swap the actual query props. + const url = islandURL('PureComponent', { + props: { bool: false, number: 1, str: 's', obj: {} }, + }) + const tampered = url.replace(/props=[^&]+/, 'props=' + encodeURIComponent(JSON.stringify({ + bool: true, number: 999, str: '<script>x</script>', obj: { evil: true }, + }))) + const res = await fetch(tampered) + expect(res.status).toBe(400) + }) + + it('rejects a request with a fabricated hash', async () => { + const res = await fetch(withQuery('/__nuxt_island/PureComponent_deadbeefcafef00d.json', { + props: JSON.stringify({ bool: false, number: 1, str: 's', obj: {} }), + })) + expect(res.status).toBe(400) + }) + + it('rejects a request with no hash segment in the URL', async () => { + const res = await fetch(withQuery('/__nuxt_island/PureComponent.json', { + props: JSON.stringify({ bool: false, number: 1, str: 's', obj: {} }), + })) + expect(res.status).toBe(400) + }) +}) + describe.skipIf(isDev || isWebpack)('regressions', () => { // https://github.com/nuxt/nuxt/issues/26527 it.fails('renders <Counter nuxt-client /> when nested two levels deep in server components', async () => {
21c110acf66dfix(nitro): validate island request hash matches props (#35077)
5 files changed · +199 −25
packages/nitro-server/src/runtime/handlers/island.ts+28 −7 modified@@ -5,7 +5,8 @@ import type { EventHandler, H3Event } from 'h3' import { createError, defineEventHandler, getQuery, readBody, setResponseHeaders } from 'h3' import { resolveUnrefHeadInput } from '@unhead/vue' import { getRequestDependencies } from 'vue-bundle-renderer/runtime' -import { getQuery as getURLQuery } from 'ufo' +import { getQuery as getURLQuery, withoutQuery } from 'ufo' +import { computeIslandHash, filterIslandProps } from '#app/island-hash' import type { NuxtIslandContext, NuxtIslandResponse } from 'nuxt/app' import { useNitroApp } from 'nitropack/runtime/app' @@ -121,8 +122,9 @@ const VALID_COMPONENT_NAME_RE = /^[a-z][\w.-]*$/i async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> { let url = event.path || '' if (import.meta.prerender && event.path && await islandPropCache!.hasItem(event.path)) { - // rehydrate props from cache so we can rerender island if cache does not have it any more - url = await islandPropCache!.getItem(event.path) as string + // for prerender, the original request URL (with query) is rehydrated from cache + // so that re-renders of the same island path use the original props + url = await islandPropCache!.getItem(withoutQuery(event.path)) as string } if (!url.startsWith(ISLAND_PATH_PREFIX)) { @@ -137,14 +139,33 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> { throw createError({ statusCode: 400, statusMessage: 'Invalid island component name' }) } - const context = event.method === 'GET' ? getQuery(event) : await readBody(event) + const rawContext = event.method === 'GET' ? getQuery<NuxtIslandContext>(event) : await readBody<NuxtIslandContext>(event) + const rawProps = destr<Record<string, any> | null | undefined>(rawContext?.props) || {} + const filteredProps = filterIslandProps(rawProps) + + // Reconstruct the `context` object as the client computed its hash over. + // `<NuxtIsland>` sends `{ ...props.context, props: JSON.stringify(props.props) }` + const clientContext: Record<string, any> = {} + if (rawContext && typeof rawContext === 'object') { + for (const key in rawContext) { + if (key !== 'props') { + clientContext[key] = (rawContext as Record<string, any>)[key] + } + } + } + + // Bind the response to the URL: a request whose URL-resident `hashId` does not match + // the actual (name, props, context) is rejected. + const expectedHash = computeIslandHash(componentName, filteredProps, clientContext, undefined) + if (!hashId || hashId !== expectedHash) { + throw createError({ status: 400, statusMessage: 'Invalid island request hash' }) + } - // Only extract known context fields to prevent arbitrary data injection return { - url: typeof context?.url === 'string' ? context.url : '/', + url: typeof rawContext?.url === 'string' ? rawContext.url : '/', id: hashId, name: componentName, - props: destr(context.props) || {}, + props: rawProps, slots: {}, components: {}, }
packages/nuxt/src/app/components/nuxt-island.ts+3 −3 modified@@ -1,7 +1,6 @@ import type { Component, PropType, RendererNode, VNode } from 'vue' import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, toRaw, watch, withMemo } from 'vue' import { debounce } from 'perfect-debounce' -import { hash } from 'ohash' import { appendResponseHeader } from 'h3' import type { ActiveHeadEntry, SerializableHead } from '@unhead/vue' import { randomUUID } from 'uncrypto' @@ -13,6 +12,7 @@ import { createError } from '../composables/error' import { prerenderRoutes, useRequestEvent } from '../composables/ssr' import { injectHead } from '../composables/head' import { getFragmentHTML, isEndFragment, isStartFragment } from './utils' +import { computeIslandHash, filterIslandProps } from '../island-hash' // @ts-expect-error virtual file import { appBaseURL, remoteComponentIslands, selectiveClient } from '#build/nuxt.config.mjs' @@ -86,8 +86,8 @@ export default defineComponent({ const error = ref<unknown>(null) const config = useRuntimeConfig() const nuxtApp = useNuxtApp() - const filteredProps = computed(() => props.props ? Object.fromEntries(Object.entries(props.props).filter(([key]) => !key.startsWith('data-v-'))) : {}) - const hashId = computed(() => hash([props.name, filteredProps.value, props.context, props.source]).replace(/[-_]/g, '')) + const filteredProps = computed(() => filterIslandProps(props.props)) + const hashId = computed(() => computeIslandHash(props.name, filteredProps.value, props.context, props.source)) const instance = getCurrentInstance()! const event = useRequestEvent()
packages/nuxt/src/app/island-hash.ts+39 −0 added@@ -0,0 +1,39 @@ +import { hash } from 'ohash' + +/** + * Strip Vue scoped-style attributes (`data-v-*`) from island props before hashing + * or rendering. Scoped-id markers leak in from parent components and are not part + * of the logical island input. + * + * Used by both `<NuxtIsland>` (client) and the `/__nuxt_island/*` handler (server) + * to derive the URL-resident `hashId`. + * + * @internal + */ +export function filterIslandProps (props: Record<string, any> | null | undefined): Record<string, any> { + if (!props) { return {} } + const out: Record<string, any> = {} + for (const key in props) { + if (!key.startsWith('data-v-')) { + out[key] = props[key] + } + } + return out +} + +/** + * Compute the `hashId` segment embedded in an island URL (`/__nuxt_island/<Name>_<hashId>.json`). + * + * The hash binds the response to the requested `(name, props, context, source)` tuple, + * so the server can reject requests whose URL hash does not match the supplied query/body. + * + * @internal + */ +export function computeIslandHash ( + name: string, + filteredProps: Record<string, any>, + context: Record<string, any>, + source: string | undefined, +): string { + return hash([name, filteredProps, context, source]).replace(/[-_]/g, '') +}
packages/nuxt/test/island-hash.test.ts+73 −0 added@@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { hash } from 'ohash' +import { computeIslandHash, filterIslandProps } from '#app/island-hash' + +describe('filterIslandProps', () => { + it('returns an empty object for nullish input', () => { + expect(filterIslandProps(undefined)).toEqual({}) + expect(filterIslandProps(null)).toEqual({}) + }) + + it('passes through ordinary props', () => { + expect(filterIslandProps({ a: 1, b: 'x', c: { nested: true } })).toEqual({ + a: 1, + b: 'x', + c: { nested: true }, + }) + }) + + it('strips Vue scoped-style markers', () => { + expect(filterIslandProps({ + 'data-v-abc123': '', + 'data-v-def456': '', + 'count': 3, + 'label': 'hi', + })).toEqual({ count: 3, label: 'hi' }) + }) + + it('preserves keys that merely contain "data-v-"', () => { + // Only the prefix is stripped — keys like `extra-data-v-x` are legitimate. + expect(filterIslandProps({ 'extra-data-v-x': 1, 'data-v-x': 2 })).toEqual({ 'extra-data-v-x': 1 }) + }) +}) + +describe('computeIslandHash', () => { + it('matches the ohash-based shape the client embeds in the URL', () => { + const name = 'PureComponent' + const props = { count: 3, label: 'hi' } + const context = { url: '/foo' } + const expected = hash([name, props, context, undefined]).replace(/[-_]/g, '') + expect(computeIslandHash(name, props, context, undefined)).toBe(expected) + }) + + it('changes when props change', () => { + const a = computeIslandHash('X', { n: 1 }, {}, undefined) + const b = computeIslandHash('X', { n: 2 }, {}, undefined) + expect(a).not.toBe(b) + }) + + it('changes when context changes', () => { + const a = computeIslandHash('X', {}, { url: '/a' }, undefined) + const b = computeIslandHash('X', {}, { url: '/b' }, undefined) + expect(a).not.toBe(b) + }) + + it('changes when name changes', () => { + const a = computeIslandHash('A', {}, {}, undefined) + const b = computeIslandHash('B', {}, {}, undefined) + expect(a).not.toBe(b) + }) + + it('changes when source changes', () => { + const a = computeIslandHash('X', {}, {}, undefined) + const b = computeIslandHash('X', {}, {}, 'https://remote.example') + expect(a).not.toBe(b) + }) + + it('produces URL-safe output (no - or _)', () => { + for (let i = 0; i < 20; i++) { + const h = computeIslandHash('Comp', { i, salt: `${i}-${i}` }, {}, undefined) + expect(h).not.toMatch(/[-_]/) + } + }) +})
test/server-components.test.ts+56 −15 modified@@ -5,10 +5,20 @@ import { isWindows } from 'std-env' import { normalize } from 'pathe' import { $fetch, fetch, setup, startServer } from '@nuxt/test-utils/e2e' import type { NuxtIslandResponse } from 'nuxt/app' +import { computeIslandHash, filterIslandProps } from '../packages/nuxt/src/app/island-hash' import { isDev, isWebpack } from './matrix' import { renderPage } from './utils' +function islandURL (name: string, opts: { props?: Record<string, any>, context?: Record<string, any> } = {}) { + const filtered = filterIslandProps(opts.props ?? {}) + const ctx = opts.context ?? {} + const hashId = computeIslandHash(name, filtered, ctx, undefined) + const query: Record<string, any> = { ...ctx } + if (opts.props) { query.props = JSON.stringify(opts.props) } + return withQuery(`/__nuxt_island/${name}_${hashId}.json`, query) +} + await setup({ rootDir: fileURLToPath(new URL('./fixtures/server-components', import.meta.url)), dev: isDev, @@ -171,7 +181,7 @@ describe('server components/islands', () => { describe('component islands', () => { it('renders components with route', async () => { - const result = await $fetch<NuxtIslandResponse>('/__nuxt_island/RouteComponent.json?url=/foo') + const result = await $fetch<NuxtIslandResponse>(islandURL('RouteComponent', { context: { url: '/foo' } })) result.html = result.html.replace(/ data-island-uid="[^"]*"/g, '') if (isDev) { @@ -180,6 +190,7 @@ describe('component islands', () => { result.head.link ||= [] result.head.style ||= [] + delete result.id expect(result).toMatchInlineSnapshot(` { @@ -194,18 +205,15 @@ describe('component islands', () => { }) it('render async component', async () => { - const result = await $fetch<NuxtIslandResponse>(withQuery('/__nuxt_island/LongAsyncComponent.json', { - props: JSON.stringify({ - count: 3, - }), - })) + const result = await $fetch<NuxtIslandResponse>(islandURL('LongAsyncComponent', { props: { count: 3 } })) if (isDev) { result.head.link = result.head.link?.filter(l => typeof l.href !== 'string' || (!l.href.includes('_nuxt/components/islands/LongAsyncComponent') && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */)) } result.head.link ||= [] result.head.style ||= [] result.html = result.html.replaceAll(/ (?:data-island-uid|data-island-component)="[^"]*"/g, '') + delete result.id expect(result).toMatchInlineSnapshot(` { "head": { @@ -255,11 +263,7 @@ describe('component islands', () => { }) it('render .server async component', async () => { - const result = await $fetch<NuxtIslandResponse>(withQuery('/__nuxt_island/AsyncServerComponent.json', { - props: JSON.stringify({ - count: 2, - }), - })) + const result = await $fetch<NuxtIslandResponse>(islandURL('AsyncServerComponent', { props: { count: 2 } })) if (isDev) { result.head.link = result.head.link?.filter(l => typeof l.href === 'string' && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */ && (!l.href.startsWith('_nuxt/components/islands/') || l.href.includes('AsyncServerComponent'))) } @@ -270,6 +274,7 @@ describe('component islands', () => { result.components = {} result.slots = {} result.html = result.html.replaceAll(/ (?:data-island-uid|data-island-component)="[^"]*"/g, '') + delete result.id expect(result).toMatchInlineSnapshot(` { @@ -287,7 +292,7 @@ describe('component islands', () => { if (!isWebpack) { it('render server component with selective client hydration', async () => { - const result = await $fetch<NuxtIslandResponse>('/__nuxt_island/ServerWithClient') + const result = await $fetch<NuxtIslandResponse>(islandURL('ServerWithClient')) if (isDev) { result.head.link = result.head.link?.filter(l => typeof l.href !== 'string' || (!l.href.includes('_nuxt/components/islands/LongAsyncComponent') && !l.href.includes('PureComponent') /* TODO: fix dev bug triggered by previous fetch of /islands */)) @@ -304,6 +309,7 @@ describe('component islands', () => { result.head.link ||= [] result.head.style ||= [] + delete result.id expect(result).toMatchInlineSnapshot(` { @@ -327,13 +333,13 @@ describe('component islands', () => { } it('renders pure components', async () => { - const result = await $fetch<NuxtIslandResponse>(withQuery('/__nuxt_island/PureComponent.json', { - props: JSON.stringify({ + const result = await $fetch<NuxtIslandResponse>(islandURL('PureComponent', { + props: { bool: false, number: 3487, str: 'something', obj: { foo: 42, bar: false, me: 'hi' }, - }), + }, })) result.html = result.html.replace(/ data-island-uid="[^"]*"/g, '') @@ -463,6 +469,41 @@ describe('component islands', () => { }) }) +describe('hash binding', () => { + it('accepts a request whose URL hash matches the props', async () => { + const res = await fetch(islandURL('PureComponent', { + props: { bool: false, number: 1, str: 's', obj: {} }, + })) + expect(res.status).toBe(200) + }) + + it('rejects a request whose URL hash was computed over different props', async () => { + // Compute a valid hash for one set of props, then swap the actual query props. + const url = islandURL('PureComponent', { + props: { bool: false, number: 1, str: 's', obj: {} }, + }) + const tampered = url.replace(/props=[^&]+/, 'props=' + encodeURIComponent(JSON.stringify({ + bool: true, number: 999, str: '<script>x</script>', obj: { evil: true }, + }))) + const res = await fetch(tampered) + expect(res.status).toBe(400) + }) + + it('rejects a request with a fabricated hash', async () => { + const res = await fetch(withQuery('/__nuxt_island/PureComponent_deadbeefcafef00d.json', { + props: JSON.stringify({ bool: false, number: 1, str: 's', obj: {} }), + })) + expect(res.status).toBe(400) + }) + + it('rejects a request with no hash segment in the URL', async () => { + const res = await fetch(withQuery('/__nuxt_island/PureComponent.json', { + props: JSON.stringify({ bool: false, number: 1, str: 's', obj: {} }), + })) + expect(res.status).toBe(400) + }) +}) + describe.skipIf(isDev || isWebpack)('regressions', () => { // https://github.com/nuxt/nuxt/issues/26527 it.fails('renders <Counter nuxt-client /> when nested two levels deep in server components', async () => {
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
3News mentions
0No linked articles in our index yet.