From 8c79be415a5026ead66dd794ac922168aed915bf Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Wed, 1 Jul 2026 11:52:36 -0500 Subject: [PATCH] fix(internals): add url length validation for url generation - Introduced a maximum URL length constant and validation in the createPlaygroundURL function to ensure URLs do not exceed 32,768 characters. - Added tests to verify that errors are thrown when the URL length limit is exceeded, ensuring robust error handling in service and utils. Signed-off-by: Cory Rylan --- .github/workflows/ci.yml | 1 + .../tools/src/playground/service.test.ts | 26 ++++++++++- .../tools/src/playground/utils.test.ts | 44 ++++++++++++++++++- .../internals/tools/src/playground/utils.ts | 37 +++++++++++++--- 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38b9e74be7..17942bb6ab 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 66e4ea0092..bcc6c243c3 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 95f90b8916..d9017d42e7 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'; @@ -558,7 +560,7 @@ 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 + // Test the effect because createPlaygroundURL invokes serialize internally. const result = createPlaygroundURL('', [], {}); if (hasPlaygroundBaseURL) { @@ -569,6 +571,46 @@ describe('serialize function behavior', () => { expect(result).toBe(''); } }); + + it('should reject playground URLs that exceed the supported length', () => { + // Use enough source bytes to produce compressed data above V8's argument-count limit. + const bytes = new Uint8Array(200_000); + // Use a fixed seed so the generated high-entropy test data is reproducible. + let state = 0x12345678; + + // Fill the array with deterministic pseudorandom bytes that resist gzip compression. + for (let index = 0; index < bytes.length; index += 1) { + // Apply the three mixing steps of the xorshift32 pseudorandom number generator. + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + bytes[index] = state; + } + + // Base64 creates a large value using only printable characters. + const base64 = Buffer.from(bytes).toString('base64'); + // Replace base64 punctuation so the value remains safe in an unquoted URL and an HTML attribute. + const attributeValue = 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 3c07ade9e8..d3b80bf353 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() { @@ -193,9 +205,22 @@ nve-logo.large { } function serialize(data: Record, compress = true) { + // Turn the playground file map into the byte sequence that the URL encoder uses. const encoded = new TextEncoder().encode(JSON.stringify(data)); const array = compress ? gzipSync(encoded) : encoded; - const base64 = globalThis.btoa(String.fromCharCode(...array)); + // Limit each function call to 32,768 arguments, safely below engine limits. + const chunkSize = 0x8000; + let binary = ''; + + // Convert every byte without spreading the entire, potentially large array into one call. + for (let offset = 0; offset < array.length; offset += chunkSize) { + // subarray creates a lightweight view containing no more than chunkSize bytes. + binary += String.fromCharCode(...array.subarray(offset, offset + chunkSize)); + } + + // Encode the complete binary string using the browser-compatible base64 API. + const base64 = globalThis.btoa(binary); + // Escape base64 characters that have special meaning inside a URL query parameter. return encodeURIComponent(base64); }