fix(nitro): apply `useHeadSafe` rules to server island head · nuxt/nuxt@493565e · GitHub
Skip to content

Commit 493565e

Browse files
committed
fix(nitro): apply useHeadSafe rules to server island head
1 parent c65cef3 commit 493565e

7 files changed

Lines changed: 174 additions & 4 deletions

File tree

‎packages/nitro-server/src/runtime/handlers/island.ts‎

Lines changed: 3 additions & 1 deletion
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { HeadTag, SerializableHead } from '@unhead/vue/types'
2+
import { SafeInputPlugin } from '@unhead/vue/plugins'
3+
import { createHead } from '@unhead/vue/server'
4+
import { resolveTags } from '@unhead/vue/utils'
5+
6+
const TAG_CONFIG_KEYS = ['key', 'tagPosition', 'tagPriority'] as const
7+
8+
/**
9+
* Apply the `useHeadSafe` allowlist to resolved head input, returning input that renders
10+
* the same tags when pushed without it.
11+
*/
12+
export function toSafeHeadInput (input: SerializableHead): SerializableHead {
13+
const { titleTemplate, ...rest } = input as SerializableHead & { titleTemplate?: unknown }
14+
const head = createHead({ disableDefaults: true, plugins: [SafeInputPlugin] })
15+
head.push(rest, { _safe: true })
16+
17+
const safe: Record<string, any> = {}
18+
if (typeof titleTemplate === 'string' || titleTemplate === null) {
19+
safe.titleTemplate = titleTemplate
20+
}
21+
for (const tag of resolveTags(head)) {
22+
if (tag.tag === 'title') {
23+
safe.title = tag.textContent
24+
continue
25+
}
26+
const value = toInputValue(tag)
27+
if (tag.tag === 'htmlAttrs' || tag.tag === 'bodyAttrs' || tag.tag === 'base') {
28+
safe[tag.tag] = value
29+
} else {
30+
(safe[tag.tag] ||= []).push(value)
31+
}
32+
}
33+
return safe
34+
}
35+
36+
function toInputValue (tag: HeadTag): Record<string, unknown> {
37+
const value: Record<string, unknown> = {}
38+
for (const [name, prop] of Object.entries(tag.props as Record<string, unknown>)) {
39+
value[name] = prop instanceof Set
40+
? [...prop].join(' ')
41+
: prop instanceof Map
42+
? [...prop].map(([property, propertyValue]) => `${property}:${propertyValue}`).join(';')
43+
: prop
44+
}
45+
if (tag.textContent !== undefined) {
46+
value.textContent = tag.textContent
47+
}
48+
for (const key of TAG_CONFIG_KEYS) {
49+
if (tag[key] !== undefined) {
50+
value[key] = tag[key]
51+
}
52+
}
53+
return value
54+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { SerializableHead } from '@unhead/vue/types'
3+
4+
import { toSafeHeadInput } from '../src/runtime/utils/island-head'
5+
6+
describe('toSafeHeadInput', () => {
7+
it('should drop input that useHeadSafe does not allow', () => {
8+
const safe = toSafeHeadInput({
9+
htmlAttrs: { lang: 'en', onclick: 'run()' },
10+
bodyAttrs: { class: 'page', onload: 'run()' },
11+
base: { href: 'https://example.com/' },
12+
link: [
13+
{ rel: 'stylesheet', href: 'javascript:run()' },
14+
{ rel: 'preload', href: '/asset.js' },
15+
],
16+
script: [{ textContent: 'run()' }, { src: 'https://example.com/run.js' }],
17+
style: [{ textContent: 'body { color: red }' }],
18+
noscript: [{ textContent: '<img src="x" onerror="run()">' }],
19+
} as SerializableHead)
20+
21+
expect(safe).toEqual({
22+
htmlAttrs: { lang: 'en' },
23+
bodyAttrs: { class: 'page' },
24+
})
25+
})
26+
27+
it('should keep input that useHeadSafe allows', () => {
28+
const safe = toSafeHeadInput({
29+
title: 'Title <b>',
30+
titleTemplate: '%s - Site',
31+
htmlAttrs: { class: 'a b', dir: 'ltr' },
32+
bodyAttrs: { 'style': 'color:red;margin:0', 'data-theme': 'dark' },
33+
meta: [{ name: 'description', content: 'Description' }],
34+
link: [{ rel: 'icon', href: '/favicon.ico', tagPriority: 'high' }],
35+
script: [{ type: 'application/ld+json', textContent: { '@type': 'Person', 'name': '</script>' } }],
36+
} as SerializableHead)
37+
38+
expect(safe).toEqual({
39+
title: 'Title <b>',
40+
titleTemplate: '%s - Site',
41+
htmlAttrs: { class: 'a b', dir: 'ltr' },
42+
bodyAttrs: { 'style': 'color:red;margin:0', 'data-theme': 'dark' },
43+
meta: [{ name: 'description', content: 'Description' }],
44+
link: [{ rel: 'icon', href: '/favicon.ico', tagPriority: 'high' }],
45+
script: [{ type: 'application/ld+json', textContent: '{"@type":"Person","name":"\\u003C/script>"}' }],
46+
})
47+
})
48+
})

‎test/bundle.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
7676
const serverDir = join(rootDir, '.output/server')
7777

7878
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir, rootDir)
79-
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"124k"`)
79+
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"125k"`)
8080

8181
const modules = await analyzeSizes(['node_modules/**/*'], serverDir, rootDir)
8282
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1642k"`)
@@ -115,7 +115,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
115115
const serverDir = join(rootDir, '.output-inline/server')
116116

117117
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir, rootDir)
118-
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"300k"`)
118+
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"301k"`)
119119

120120
const modules = await analyzeSizes(['node_modules/**/*'], serverDir, rootDir)
121121
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"262k"`)
@@ -138,7 +138,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
138138
const serverDir = join(pagesRootDir, '.output/server')
139139

140140
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir, pagesRootDir)
141-
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"158k"`)
141+
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"159k"`)
142142

143143
const modules = await analyzeSizes(['node_modules/**/*'], serverDir, pagesRootDir)
144144
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1766k"`)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script setup lang="ts">
2+
useHead({
3+
meta: [{ name: 'island-trusted-meta', content: 'trusted' }],
4+
})
5+
useHeadSafe({
6+
meta: [{ name: 'island-safe-meta', content: 'safe' }],
7+
bodyAttrs: {
8+
class: 'island-safe-body',
9+
onload: 'window.__islandUnsafe = "onload"',
10+
},
11+
script: [{ textContent: 'window.__islandUnsafe = "script"' }],
12+
})
13+
</script>
14+
15+
<template>
16+
<div id="safe-head-island">
17+
safe head
18+
</div>
19+
</template>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<div>
3+
<NuxtIsland name="SafeHead" />
4+
</div>
5+
</template>

‎test/server-components.test.ts‎

Lines changed: 42 additions & 0 deletions

0 commit comments

Comments
 (0)