Skip to content

Commit 2566d20

Browse files
authored
fix(nuxt): add validation for nuxt island reviver key (#33069)
1 parent 35c5f9f commit 2566d20

File tree

4 files changed

+93
-2
lines changed

4 files changed

+93
-2
lines changed

packages/nuxt/src/app/plugins/revive-payload.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { defineNuxtPlugin } from '../nuxt'
55

66
// @ts-expect-error Virtual file.
77
import { componentIslands } from '#build/nuxt.config.mjs'
8+
import { isValidIslandKey } from './utils'
89

910
const reducers: [string, (data: any) => any][] = [
1011
['NuxtError', data => isNuxtError(data) && data.toJSON()],
@@ -17,7 +18,7 @@ const reducers: [string, (data: any) => any][] = [
1718
]
1819

1920
if (componentIslands) {
20-
reducers.push(['Island', data => data && data?.__nuxt_island])
21+
reducers.push(['Island', data => data && data?.__nuxt_island && isValidIslandKey(data.__nuxt_island.key) && data.__nuxt_island])
2122
}
2223

2324
export default defineNuxtPlugin({
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const VALID_ISLAND_KEY_RE = /^[a-z][a-z\d-]*_[a-z\d]+$/i
2+
/* @__PURE__ */
3+
export function isValidIslandKey (key: string): boolean {
4+
return typeof key === 'string' && VALID_ISLAND_KEY_RE.test(key) && key.length <= 100
5+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { isValidIslandKey } from '#app/plugins/utils'
2+
import { describe, expect, it } from 'vitest'
3+
import { hash } from 'ohash'
4+
import { randomUUID } from 'node:crypto'
5+
6+
describe('isValidIslandKey util', () => {
7+
it('should accept valid island keys', () => {
8+
// Valid keys following the componentName_hashId pattern
9+
expect(isValidIslandKey('MyComponent_abc123')).toBe(true)
10+
expect(isValidIslandKey('UserCard_def456')).toBe(true)
11+
expect(isValidIslandKey('NavBar_xyz789')).toBe(true)
12+
expect(isValidIslandKey('A_1')).toBe(true)
13+
expect(isValidIslandKey('Component123_hash456')).toBe(true)
14+
expect(isValidIslandKey('my-component_hash123')).toBe(true)
15+
expect(isValidIslandKey('Component-Name_hash123')).toBe(true)
16+
const sampleHash = hash({ props: randomUUID() }).replace(/[-_]/g, '')
17+
expect(isValidIslandKey('ComponentName_' + sampleHash)).toBe(true)
18+
})
19+
20+
it('should reject invalid island keys', () => {
21+
// Empty or null/undefined
22+
expect(isValidIslandKey('')).toBe(false)
23+
expect(isValidIslandKey(null as any)).toBe(false)
24+
expect(isValidIslandKey(undefined as any)).toBe(false)
25+
26+
// Missing underscore separator
27+
expect(isValidIslandKey('ComponentName')).toBe(false)
28+
expect(isValidIslandKey('hash123')).toBe(false)
29+
30+
// Invalid characters
31+
expect(isValidIslandKey('Component/Name_hash123')).toBe(false)
32+
expect(isValidIslandKey('Component\\Name_hash123')).toBe(false)
33+
expect(isValidIslandKey('Component..Name_hash123')).toBe(false)
34+
expect(isValidIslandKey('Component Name_hash123')).toBe(false)
35+
expect(isValidIslandKey('Component<script>_hash123')).toBe(false)
36+
expect(isValidIslandKey('Component"_hash123')).toBe(false)
37+
expect(isValidIslandKey('Component\'_hash123')).toBe(false)
38+
39+
// Starting with invalid characters
40+
expect(isValidIslandKey('123Component_hash123')).toBe(false)
41+
expect(isValidIslandKey('_Component_hash123')).toBe(false)
42+
expect(isValidIslandKey('-Component_hash123')).toBe(false)
43+
44+
// Path traversal attempts
45+
expect(isValidIslandKey('../Component_hash123')).toBe(false)
46+
expect(isValidIslandKey('../../Component_hash123')).toBe(false)
47+
expect(isValidIslandKey('Component_../hash123')).toBe(false)
48+
expect(isValidIslandKey('Component_../../hash123')).toBe(false)
49+
50+
// URL/protocol attempts
51+
expect(isValidIslandKey('http://evil.com_hash123')).toBe(false)
52+
expect(isValidIslandKey('file:///etc/passwd_hash123')).toBe(false)
53+
expect(isValidIslandKey('Component_http://evil.com')).toBe(false)
54+
55+
const longKey = 'A'.repeat(95) + '_' + 'B'.repeat(10)
56+
expect(isValidIslandKey(longKey)).toBe(false)
57+
58+
expect(isValidIslandKey('Component_Name_hash123')).toBe(false)
59+
expect(isValidIslandKey('Component__hash123')).toBe(false)
60+
})
61+
62+
it('should handle edge cases', () => {
63+
// Maximum allowed length (100 chars)
64+
const maxLengthKey = 'A'.repeat(94) + '_' + 'B'.repeat(5) // 100 chars total
65+
expect(isValidIslandKey(maxLengthKey)).toBe(true)
66+
67+
// Just over maximum length
68+
const overLengthKey = 'A'.repeat(95) + '_' + 'B'.repeat(6) // 102 chars total
69+
expect(isValidIslandKey(overLengthKey)).toBe(false)
70+
71+
// Minimum valid length
72+
expect(isValidIslandKey('A_1')).toBe(true)
73+
74+
// Single character component name with long hash
75+
expect(isValidIslandKey('A_' + 'B'.repeat(97))).toBe(true) // 100 chars total
76+
})
77+
78+
it('should reject non-string inputs', () => {
79+
expect(isValidIslandKey(123 as any)).toBe(false)
80+
expect(isValidIslandKey({} as any)).toBe(false)
81+
expect(isValidIslandKey([] as any)).toBe(false)
82+
expect(isValidIslandKey(true as any)).toBe(false)
83+
expect(isValidIslandKey(Symbol('test') as any)).toBe(false)
84+
})
85+
})

test/bundle.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
117117
const serverDir = join(pagesRootDir, '.output/server')
118118

119119
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
120-
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"288k"`)
120+
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"289k"`)
121121

122122
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
123123
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1420k"`)

0 commit comments

Comments
 (0)