diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38b9e74be..17942bb6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,7 @@ jobs: uses: actions/upload-pages-artifact@v5 with: path: projects/pages/dist + include-hidden-files: true lighthouse: runs-on: ubuntu-latest diff --git a/projects/internals/tools/src/playground/service.test.ts b/projects/internals/tools/src/playground/service.test.ts index 66e4ea009..bcc6c243c 100644 --- a/projects/internals/tools/src/playground/service.test.ts +++ b/projects/internals/tools/src/playground/service.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { loadTools, type ToolMethod, type ToolOutput } from '../internal/tools.js'; import { PlaygroundService } from './service.js'; -import { createPlaygroundURL } from './utils.js'; +import { createPlaygroundURL, MAX_PLAYGROUND_URL_LENGTH } from './utils.js'; // when ELEMENTS_PLAYGROUND_BASE_URL is not configured, createPlaygroundURL returns '' const hasPlaygroundBaseURL = createPlaygroundURL('test', []).length > 0; @@ -148,6 +148,30 @@ describe('PlaygroundService', () => { ); }); + it('should handle content that would exceed the supported playground URL length', async () => { + process.env.ELEMENTS_ENV = 'browser'; + const tools = loadTools(PlaygroundService); + const createTool = tools.find(tool => tool.metadata.name === 'create'); + + const result = (await createTool?.({ + template: 'valid', + name: 'x'.repeat(MAX_PLAYGROUND_URL_LENGTH), + start: false + })) as ToolOutput; + + if (!hasPlaygroundBaseURL) { + expect(result.status).toBe('complete'); + expect(result.result).toBe(''); + return; + } + + expect(result.status).toBe('error'); + expect(result.message).toBe( + `Playground content produces a URL that exceeds the ${MAX_PLAYGROUND_URL_LENGTH}-character limit.` + ); + expect(result.result).toBeUndefined(); + }); + it('should skip validation and return URL when not in mcp or cli environment', async () => { process.env.ELEMENTS_ENV = 'browser'; const result = await PlaygroundService.create({ diff --git a/projects/internals/tools/src/playground/utils.test.ts b/projects/internals/tools/src/playground/utils.test.ts index 95f90b891..5c327049f 100644 --- a/projects/internals/tools/src/playground/utils.test.ts +++ b/projects/internals/tools/src/playground/utils.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import type { ProjectElement } from '@internals/metadata'; +import { ToolError } from '../internal/tools.js'; import { createPlaygroundURL, createAngularFiles, @@ -12,6 +13,7 @@ import { createVueFiles, createDefaultFiles, formatTemplate, + MAX_PLAYGROUND_URL_LENGTH, playgroundTypes } from './utils.js'; @@ -26,6 +28,20 @@ function expectURL(result: string, expected: string) { } } +function createDeterministicPseudorandomBytes(length: number) { + const bytes = new Uint8Array(length); + let state = 0x12345678; + + for (let index = 0; index < bytes.length; index += 1) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + bytes[index] = state; + } + + return bytes; +} + describe('createPlaygroundURL', () => { const elements: ProjectElement[] = [ { @@ -558,17 +574,39 @@ describe('createImportMap with different frameworks', () => { describe('serialize function behavior', () => { it('should compress and encode data by default', () => { - // The serialize function is called internally, so we test its effect const result = createPlaygroundURL('', [], {}); if (hasPlaygroundBaseURL) { - // Should contain encoded files data expect(result).toContain('&files='); expect(result.length).toBeGreaterThan(100); // Should be reasonably long due to compression } else { expect(result).toBe(''); } }); + + it('should reject playground URLs that exceed the supported V8 argument-count limit', () => { + const bytes = createDeterministicPseudorandomBytes(200_000); + const attributeValue = Buffer.from(bytes).toString('base64').replace(/[+/=]/g, 'A'); + const template = `
content
`; + + if (!hasPlaygroundBaseURL) { + expect(createPlaygroundURL(template, [])).toBe(''); + return; + } + + let thrown: unknown; + try { + createPlaygroundURL(template, []); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(ToolError); + expect(thrown).toHaveProperty( + 'message', + `Playground content produces a URL that exceeds the ${MAX_PLAYGROUND_URL_LENGTH}-character limit.` + ); + }); }); describe('Edge cases and error handling', () => { diff --git a/projects/internals/tools/src/playground/utils.ts b/projects/internals/tools/src/playground/utils.ts index 3c07ade9e..949a12669 100644 --- a/projects/internals/tools/src/playground/utils.ts +++ b/projects/internals/tools/src/playground/utils.ts @@ -4,6 +4,7 @@ import { gzipSync } from 'fflate'; import format from 'html-format'; import type { Element } from '@internals/metadata'; +import { ToolError } from '../internal/tools.js'; import { getElementImports } from '../internal/utils.js'; import { validateTemplate } from '../internal/validate.js'; @@ -11,6 +12,7 @@ declare const __ELEMENTS_ESM_CDN_BASE_URL__: string; const ELEMENTS_PLAYGROUND_BASE_URL = process.env.ELEMENTS_PLAYGROUND_BASE_URL ?? ''; const ELEMENTS_ESM_CDN_BASE_URL = __ELEMENTS_ESM_CDN_BASE_URL__; +export const MAX_PLAYGROUND_URL_LENGTH = 32_768; interface PlaygroundOptions { type?: PlaygroundType; @@ -147,12 +149,22 @@ export function createVueFiles(content: string, elements: Element[], options: Pl } function createURL(files: string, options: PlaygroundOptions) { + if (ELEMENTS_PLAYGROUND_BASE_URL.length === 0) { + return ''; + } + const defaultOptions = { openFile: 'index.html', ...options }; - return ELEMENTS_PLAYGROUND_BASE_URL.length > 0 - ? encodeURI( - `${ELEMENTS_PLAYGROUND_BASE_URL}/?version=1&layout=vertical-split${defaultOptions.name ? `&name=${defaultOptions.name.trim()}` : ''}${defaultOptions.theme ? `&theme=${defaultOptions.theme}` : ''}&file=${defaultOptions.openFile}${defaultOptions.referer ? `&ref=${defaultOptions.referer}` : ''}&files=${files}` - ) - : ''; + const url = encodeURI( + `${ELEMENTS_PLAYGROUND_BASE_URL}/?version=1&layout=vertical-split${defaultOptions.name ? `&name=${defaultOptions.name.trim()}` : ''}${defaultOptions.theme ? `&theme=${defaultOptions.theme}` : ''}&file=${defaultOptions.openFile}${defaultOptions.referer ? `&ref=${defaultOptions.referer}` : ''}&files=${files}` + ); + + if (url.length > MAX_PLAYGROUND_URL_LENGTH) { + throw new ToolError( + `Playground content produces a URL that exceeds the ${MAX_PLAYGROUND_URL_LENGTH}-character limit.` + ); + } + + return url; } function createLayoutStyles() { @@ -195,8 +207,15 @@ nve-logo.large { function serialize(data: Record, compress = true) { const encoded = new TextEncoder().encode(JSON.stringify(data)); const array = compress ? gzipSync(encoded) : encoded; - const base64 = globalThis.btoa(String.fromCharCode(...array)); - return encodeURIComponent(base64); + // Limit each function call to 32,768 arguments, safely below engine limits. + const chunkSize = 0x8000; + let binary = ''; + + for (let offset = 0; offset < array.length; offset += chunkSize) { + binary += String.fromCharCode(...array.subarray(offset, offset + chunkSize)); + } + + return encodeURIComponent(globalThis.btoa(binary)); } function createIndexHTML(content: string, options: PlaygroundOptions) {