diff --git a/src/client/VZSidebar/VisualEditor/VisualEditor.tsx b/src/client/VZSidebar/VisualEditor/VisualEditor.tsx index 43c66f48..517cccc4 100644 --- a/src/client/VZSidebar/VisualEditor/VisualEditor.tsx +++ b/src/client/VZSidebar/VisualEditor/VisualEditor.tsx @@ -16,6 +16,10 @@ import { CheckboxWidget } from './CheckboxWidget'; import { TextInputWidget } from './TextInputWidget'; import { DropdownWidget } from './DropdownWidget'; import { ColorWidget } from './ColorWidget'; +import { + getNestedProperty, + setNestedProperty, +} from './utils'; export const VisualEditor = () => { const { files, submitOperation } = @@ -66,11 +70,12 @@ export const VisualEditor = () => { [property]: newValue, })); - // Update config.json - const newConfigData = { - ...configData, - [property]: newValue, - }; + // Update config.json with support for nested properties + const newConfigData = setNestedProperty( + configData, + property, + newValue, + ); submitOperation((document: VizContent) => ({ ...document, @@ -97,11 +102,12 @@ export const VisualEditor = () => { [property]: newValue, })); - // Update config.json - const newConfigData = { - ...configData, - [property]: newValue, - }; + // Update config.json with support for nested properties + const newConfigData = setNestedProperty( + configData, + property, + newValue, + ); submitOperation((document: VizContent) => ({ ...document, @@ -128,11 +134,12 @@ export const VisualEditor = () => { [property]: newValue, })); - // Update config.json - const newConfigData = { - ...configData, - [property]: newValue, - }; + // Update config.json with support for nested properties + const newConfigData = setNestedProperty( + configData, + property, + newValue, + ); submitOperation((document: VizContent) => ({ ...document, @@ -156,11 +163,12 @@ export const VisualEditor = () => { [property]: newValue, })); - // Update config.json - const newConfigData = { - ...configData, - [property]: newValue, - }; + // Update config.json with support for nested properties + const newConfigData = setNestedProperty( + configData, + property, + newValue, + ); submitOperation((document: VizContent) => ({ ...document, @@ -224,9 +232,10 @@ export const VisualEditor = () => { event.currentTarget.value, ); - // Get current hex color from config + // Get current hex color from config using nested property access const currentHex = - configData[property] || '#000000'; + getNestedProperty(configData, property) ?? + '#000000'; // Convert current hex to LCH let hclFromRGB: HCLColor; @@ -281,11 +290,12 @@ export const VisualEditor = () => { [`${property}_l`]: newhcl[2], })); - // Update config.json with hex value - const newConfigData = { - ...configData, - [property]: newHex, - }; + // Update config.json with hex value using nested property access + const newConfigData = setNestedProperty( + configData, + property, + newHex, + ); submitOperation((document: VizContent) => ({ ...document, @@ -320,23 +330,36 @@ export const VisualEditor = () => { } = {}; visualEditorWidgets.forEach((widget) => { if (widget.type === 'slider') { - newLocalValues[widget.property] = - configData[widget.property]; + newLocalValues[widget.property] = getNestedProperty( + configData, + widget.property, + ); } else if (widget.type === 'checkbox') { - newLocalValues[widget.property] = - configData[widget.property]; + newLocalValues[widget.property] = getNestedProperty( + configData, + widget.property, + ); } else if (widget.type === 'textInput') { - newLocalValues[widget.property] = - configData[widget.property]; + newLocalValues[widget.property] = getNestedProperty( + configData, + widget.property, + ); } else if (widget.type === 'dropdown') { - newLocalValues[widget.property] = - configData[widget.property]; + newLocalValues[widget.property] = getNestedProperty( + configData, + widget.property, + ); } else if (widget.type === 'color') { - newLocalValues[widget.property] = - configData[widget.property]; + newLocalValues[widget.property] = getNestedProperty( + configData, + widget.property, + ); // Convert hex to LCH for internal state - const hexColor = configData[widget.property]; + const hexColor = getNestedProperty( + configData, + widget.property, + ); if (localValues[widget.property] !== hexColor) { if (hexColor) { @@ -414,8 +437,14 @@ export const VisualEditor = () => { if (widgetConfig.type === 'slider') { // Use local value if available, otherwise fall back to config value const currentValue = - localValues[widgetConfig.property] ?? - configData[widgetConfig.property]; + (localValues[ + widgetConfig.property + ] as number) ?? + getNestedProperty( + configData, + widgetConfig.property, + ) ?? + widgetConfig.min; return ( { } else if (widgetConfig.type === 'checkbox') { // Use local value if available, otherwise fall back to config value const currentValue = - localValues[widgetConfig.property] ?? - configData[widgetConfig.property]; + (localValues[ + widgetConfig.property + ] as boolean) ?? + getNestedProperty( + configData, + widgetConfig.property, + ) ?? + false; return ( { } else if (widgetConfig.type === 'textInput') { // Use local value if available, otherwise fall back to config value const currentValue = - localValues[widgetConfig.property] ?? - configData[widgetConfig.property]; + (localValues[ + widgetConfig.property + ] as string) ?? + getNestedProperty( + configData, + widgetConfig.property, + ) ?? + ''; return ( { } else if (widgetConfig.type === 'dropdown') { // Use local value if available, otherwise fall back to config value const currentValue = - localValues[widgetConfig.property] ?? - configData[widgetConfig.property]; + (localValues[ + widgetConfig.property + ] as string) ?? + getNestedProperty( + configData, + widgetConfig.property, + ) ?? + widgetConfig.options?.[0] ?? + ''; const isOpen = openDropdown === widgetConfig.property; @@ -495,8 +543,13 @@ export const VisualEditor = () => { } else if (widgetConfig.type === 'color') { // Use local value if available, otherwise fall back to config value const currentHex = - localValues[widgetConfig.property] ?? - configData[widgetConfig.property] ?? + (localValues[ + widgetConfig.property + ] as string) ?? + getNestedProperty( + configData, + widgetConfig.property, + ) ?? '#000000'; // Convert hex to LCH for slider values diff --git a/src/client/VZSidebar/VisualEditor/utils.ts b/src/client/VZSidebar/VisualEditor/utils.ts index a10a7cb3..a5e0dc62 100644 --- a/src/client/VZSidebar/VisualEditor/utils.ts +++ b/src/client/VZSidebar/VisualEditor/utils.ts @@ -100,3 +100,116 @@ export const renderSliderBackground = ( ctx.putImageData(imageData, 0, 0); }; + +// Helper function to get nested property value using dot notation +export const getNestedProperty = ( + obj: Record, + path: string, +): T | undefined => { + // Validate path to prevent empty strings, consecutive dots, or leading/trailing dots + if ( + !path || + path.includes('..') || + path.startsWith('.') || + path.endsWith('.') + ) { + return undefined; + } + + const keys = path.split('.'); + let value: unknown = obj; + for (const key of keys) { + // Skip empty keys that might result from splitting + if (!key) { + return undefined; + } + if (value === null || value === undefined) { + return undefined; + } + // Type guard to ensure value is an object before accessing property + if (typeof value !== 'object') { + return undefined; + } + value = (value as Record)[key]; + } + return value as T | undefined; +}; + +// Helper function to set nested property value using dot notation +export const setNestedProperty = < + T extends Record, +>( + obj: T, + path: string, + value: unknown, +): T => { + // Validate path to prevent empty strings, consecutive dots, or leading/trailing dots + if ( + !path || + path.includes('..') || + path.startsWith('.') || + path.endsWith('.') + ) { + throw new Error( + `Invalid property path "${path}" - path cannot be empty, contain consecutive dots, or have leading/trailing dots`, + ); + } + + const keys = path.split('.'); + + // Check for empty keys + if (keys.some((key) => !key)) { + throw new Error( + `Invalid property path "${path}" - path contains empty segments`, + ); + } + + const newObj = { ...obj }; + let current: Record = newObj; + + // List of dangerous keys that could lead to prototype pollution + const dangerousKeys = [ + '__proto__', + 'constructor', + 'prototype', + ]; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + + // Guard against prototype pollution + if (dangerousKeys.includes(key)) { + throw new Error( + `Cannot set property "${key}" - potential prototype pollution`, + ); + } + + // Create nested object if it doesn't exist or isn't a plain object + if ( + current[key] == null || + typeof current[key] !== 'object' || + Array.isArray(current[key]) + ) { + current[key] = {}; + } else { + // Clone the nested object to avoid mutation + current[key] = { + ...(current[key] as Record), + }; + } + current = current[key] as Record; + } + + // Guard against prototype pollution for the final key + const finalKey = keys[keys.length - 1]; + if (dangerousKeys.includes(finalKey)) { + throw new Error( + `Cannot set property "${finalKey}" - potential prototype pollution`, + ); + } + + // Set the final value + current[finalKey] = value; + + return newObj; +}; diff --git a/src/client/VZSidebar/index.tsx b/src/client/VZSidebar/index.tsx index b9df19d9..43af594c 100644 --- a/src/client/VZSidebar/index.tsx +++ b/src/client/VZSidebar/index.tsx @@ -411,6 +411,7 @@ export const VZSidebar = ({ setIsVisualEditorOpen(true); setIsAIChatOpen(false); setIsSearchOpen(false); + setSidebarView(false); // Switch to files view width for visual editor }} > diff --git a/test/sampleDirectories/visualEditor/config.json b/test/sampleDirectories/visualEditor/config.json index 552f66ff..8f7d077f 100644 --- a/test/sampleDirectories/visualEditor/config.json +++ b/test/sampleDirectories/visualEditor/config.json @@ -38,11 +38,17 @@ "visualEditorWidgets": [ { "type": "slider", - "label": "Point Radius (when not using size)", - "property": "Point Radius", + "label": "Point Radius", + "property": "pointRadius", "min": 1, - "max": 30, - "step": 2.5 + "max": 30 + }, + { + "type": "slider", + "label": "Left Margin", + "property": "margin.left", + "min": 0, + "max": 200 }, { "type": "slider",