From 10b0c2d8cf6b082cf9c3fe6c36a29bbca9b20a0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 02:46:31 +0000 Subject: [PATCH 1/8] Initial plan From 09d1297c0a1976a7467c1bacbc4fe9b833e73be9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 02:53:22 +0000 Subject: [PATCH 2/8] Add nested property support for Visual Editor with dot notation Co-authored-by: curran <68416+curran@users.noreply.github.com> --- .../VZSidebar/VisualEditor/VisualEditor.tsx | 124 ++++++++++++------ src/client/VZSidebar/VisualEditor/utils.ts | 48 +++++++ .../visualEditor/config.json | 14 +- 3 files changed, 139 insertions(+), 47 deletions(-) diff --git a/src/client/VZSidebar/VisualEditor/VisualEditor.tsx b/src/client/VZSidebar/VisualEditor/VisualEditor.tsx index 43c66f48..60977d32 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) { @@ -415,7 +438,10 @@ export const VisualEditor = () => { // Use local value if available, otherwise fall back to config value const currentValue = localValues[widgetConfig.property] ?? - configData[widgetConfig.property]; + getNestedProperty( + configData, + widgetConfig.property, + ); return ( { // Use local value if available, otherwise fall back to config value const currentValue = localValues[widgetConfig.property] ?? - configData[widgetConfig.property]; + getNestedProperty( + configData, + widgetConfig.property, + ); return ( { // Use local value if available, otherwise fall back to config value const currentValue = localValues[widgetConfig.property] ?? - configData[widgetConfig.property]; + getNestedProperty( + configData, + widgetConfig.property, + ); return ( { // Use local value if available, otherwise fall back to config value const currentValue = localValues[widgetConfig.property] ?? - configData[widgetConfig.property]; + getNestedProperty( + configData, + widgetConfig.property, + ); const isOpen = openDropdown === widgetConfig.property; @@ -496,7 +531,10 @@ export const VisualEditor = () => { // Use local value if available, otherwise fall back to config value const currentHex = localValues[widgetConfig.property] ?? - configData[widgetConfig.property] ?? + 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..24e29a3d 100644 --- a/src/client/VZSidebar/VisualEditor/utils.ts +++ b/src/client/VZSidebar/VisualEditor/utils.ts @@ -100,3 +100,51 @@ export const renderSliderBackground = ( ctx.putImageData(imageData, 0, 0); }; + +// Helper function to get nested property value using dot notation +export const getNestedProperty = ( + obj: any, + path: string, +): any => { + const keys = path.split('.'); + let value = obj; + for (const key of keys) { + if (value === null || value === undefined) { + return undefined; + } + value = value[key]; + } + return value; +}; + +// Helper function to set nested property value using dot notation +export const setNestedProperty = ( + obj: any, + path: string, + value: any, +): any => { + const keys = path.split('.'); + const newObj = { ...obj }; + let current = newObj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + // Create nested object if it doesn't exist + if ( + !current[key] || + typeof current[key] !== 'object' || + Array.isArray(current[key]) + ) { + current[key] = {}; + } else { + // Clone the nested object to avoid mutation + current[key] = { ...current[key] }; + } + current = current[key]; + } + + // Set the final value + current[keys[keys.length - 1]] = value; + + return newObj; +}; 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", From 612582ffba7061af13aa74240bafaf29f33ad5b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 02:57:50 +0000 Subject: [PATCH 3/8] Add setSidebarView call when opening Visual Editor Co-authored-by: curran <68416+curran@users.noreply.github.com> --- src/client/VZSidebar/index.tsx | 1 + 1 file changed, 1 insertion(+) 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 }} > From dcf0598e5864c594e082ad97064d9c09d9483ea9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:01:02 +0000 Subject: [PATCH 4/8] Improve TypeScript type safety for nested property utilities Co-authored-by: curran <68416+curran@users.noreply.github.com> --- .../VZSidebar/VisualEditor/VisualEditor.tsx | 54 ++++++++++--------- src/client/VZSidebar/VisualEditor/utils.ts | 30 ++++++----- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/client/VZSidebar/VisualEditor/VisualEditor.tsx b/src/client/VZSidebar/VisualEditor/VisualEditor.tsx index 60977d32..1efbf0a1 100644 --- a/src/client/VZSidebar/VisualEditor/VisualEditor.tsx +++ b/src/client/VZSidebar/VisualEditor/VisualEditor.tsx @@ -233,9 +233,10 @@ export const VisualEditor = () => { ); // Get current hex color from config using nested property access - const currentHex = - getNestedProperty(configData, property) || - '#000000'; + const currentHex = (getNestedProperty( + configData, + property, + ) || '#000000') as string; // Convert current hex to LCH let hclFromRGB: HCLColor; @@ -356,7 +357,7 @@ export const VisualEditor = () => { ); // Convert hex to LCH for internal state - const hexColor = getNestedProperty( + const hexColor = getNestedProperty( configData, widget.property, ); @@ -436,12 +437,13 @@ export const VisualEditor = () => { {visualEditorWidgets.map((widgetConfig, _index) => { if (widgetConfig.type === 'slider') { // Use local value if available, otherwise fall back to config value - const currentValue = - localValues[widgetConfig.property] ?? - getNestedProperty( + const currentValue = (localValues[ + widgetConfig.property + ] ?? + getNestedProperty( configData, widgetConfig.property, - ); + )) as number; return ( { ); } else if (widgetConfig.type === 'checkbox') { // Use local value if available, otherwise fall back to config value - const currentValue = - localValues[widgetConfig.property] ?? - getNestedProperty( + const currentValue = (localValues[ + widgetConfig.property + ] ?? + getNestedProperty( configData, widgetConfig.property, - ); + )) as boolean; return ( { ); } else if (widgetConfig.type === 'textInput') { // Use local value if available, otherwise fall back to config value - const currentValue = - localValues[widgetConfig.property] ?? - getNestedProperty( + const currentValue = (localValues[ + widgetConfig.property + ] ?? + getNestedProperty( configData, widgetConfig.property, - ); + )) as string; return ( { ); } else if (widgetConfig.type === 'dropdown') { // Use local value if available, otherwise fall back to config value - const currentValue = - localValues[widgetConfig.property] ?? - getNestedProperty( + const currentValue = (localValues[ + widgetConfig.property + ] ?? + getNestedProperty( configData, widgetConfig.property, - ); + )) as string; const isOpen = openDropdown === widgetConfig.property; @@ -529,13 +534,14 @@ export const VisualEditor = () => { ); } else if (widgetConfig.type === 'color') { // Use local value if available, otherwise fall back to config value - const currentHex = - localValues[widgetConfig.property] ?? - getNestedProperty( + const currentHex = (localValues[ + widgetConfig.property + ] ?? + getNestedProperty( configData, widgetConfig.property, ) ?? - '#000000'; + '#000000') as string; // Convert hex to LCH for slider values let hclColor; diff --git a/src/client/VZSidebar/VisualEditor/utils.ts b/src/client/VZSidebar/VisualEditor/utils.ts index 24e29a3d..2cf6409e 100644 --- a/src/client/VZSidebar/VisualEditor/utils.ts +++ b/src/client/VZSidebar/VisualEditor/utils.ts @@ -102,30 +102,32 @@ export const renderSliderBackground = ( }; // Helper function to get nested property value using dot notation -export const getNestedProperty = ( - obj: any, +export const getNestedProperty = ( + obj: Record, path: string, -): any => { +): T | undefined => { const keys = path.split('.'); - let value = obj; + let value: unknown = obj; for (const key of keys) { if (value === null || value === undefined) { return undefined; } - value = value[key]; + value = (value as Record)[key]; } - return value; + return value as T | undefined; }; // Helper function to set nested property value using dot notation -export const setNestedProperty = ( - obj: any, +export const setNestedProperty = < + T extends Record, +>( + obj: T, path: string, - value: any, -): any => { + value: unknown, +): T => { const keys = path.split('.'); const newObj = { ...obj }; - let current = newObj; + let current: Record = newObj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; @@ -138,9 +140,11 @@ export const setNestedProperty = ( current[key] = {}; } else { // Clone the nested object to avoid mutation - current[key] = { ...current[key] }; + current[key] = { + ...(current[key] as Record), + }; } - current = current[key]; + current = current[key] as Record; } // Set the final value From d03777b22a3effbff241bf6ba1ed3530135aa420 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:04:24 +0000 Subject: [PATCH 5/8] Add explicit undefined handling and improve type safety Co-authored-by: curran <68416+curran@users.noreply.github.com> --- .../VZSidebar/VisualEditor/VisualEditor.tsx | 57 +++++++++++-------- src/client/VZSidebar/VisualEditor/utils.ts | 4 ++ 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/client/VZSidebar/VisualEditor/VisualEditor.tsx b/src/client/VZSidebar/VisualEditor/VisualEditor.tsx index 1efbf0a1..517cccc4 100644 --- a/src/client/VZSidebar/VisualEditor/VisualEditor.tsx +++ b/src/client/VZSidebar/VisualEditor/VisualEditor.tsx @@ -233,10 +233,9 @@ export const VisualEditor = () => { ); // Get current hex color from config using nested property access - const currentHex = (getNestedProperty( - configData, - property, - ) || '#000000') as string; + const currentHex = + getNestedProperty(configData, property) ?? + '#000000'; // Convert current hex to LCH let hclFromRGB: HCLColor; @@ -437,13 +436,15 @@ export const VisualEditor = () => { {visualEditorWidgets.map((widgetConfig, _index) => { if (widgetConfig.type === 'slider') { // Use local value if available, otherwise fall back to config value - const currentValue = (localValues[ - widgetConfig.property - ] ?? + const currentValue = + (localValues[ + widgetConfig.property + ] as number) ?? getNestedProperty( configData, widgetConfig.property, - )) as number; + ) ?? + widgetConfig.min; return ( { ); } else if (widgetConfig.type === 'checkbox') { // Use local value if available, otherwise fall back to config value - const currentValue = (localValues[ - widgetConfig.property - ] ?? + const currentValue = + (localValues[ + widgetConfig.property + ] as boolean) ?? getNestedProperty( configData, widgetConfig.property, - )) as boolean; + ) ?? + false; return ( { ); } else if (widgetConfig.type === 'textInput') { // Use local value if available, otherwise fall back to config value - const currentValue = (localValues[ - widgetConfig.property - ] ?? + const currentValue = + (localValues[ + widgetConfig.property + ] as string) ?? getNestedProperty( configData, widgetConfig.property, - )) as string; + ) ?? + ''; return ( { ); } else if (widgetConfig.type === 'dropdown') { // Use local value if available, otherwise fall back to config value - const currentValue = (localValues[ - widgetConfig.property - ] ?? + const currentValue = + (localValues[ + widgetConfig.property + ] as string) ?? getNestedProperty( configData, widgetConfig.property, - )) as string; + ) ?? + widgetConfig.options?.[0] ?? + ''; const isOpen = openDropdown === widgetConfig.property; @@ -534,14 +542,15 @@ export const VisualEditor = () => { ); } else if (widgetConfig.type === 'color') { // Use local value if available, otherwise fall back to config value - const currentHex = (localValues[ - widgetConfig.property - ] ?? + const currentHex = + (localValues[ + widgetConfig.property + ] as string) ?? getNestedProperty( configData, widgetConfig.property, ) ?? - '#000000') as string; + '#000000'; // Convert hex to LCH for slider values let hclColor; diff --git a/src/client/VZSidebar/VisualEditor/utils.ts b/src/client/VZSidebar/VisualEditor/utils.ts index 2cf6409e..d95a139a 100644 --- a/src/client/VZSidebar/VisualEditor/utils.ts +++ b/src/client/VZSidebar/VisualEditor/utils.ts @@ -112,6 +112,10 @@ export const getNestedProperty = ( 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; From c80daaeb404fe3bf4e5ba30018d7bf2675ac2265 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:07:46 +0000 Subject: [PATCH 6/8] Fix prototype pollution vulnerability in setNestedProperty Co-authored-by: curran <68416+curran@users.noreply.github.com> --- src/client/VZSidebar/VisualEditor/utils.ts | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/client/VZSidebar/VisualEditor/utils.ts b/src/client/VZSidebar/VisualEditor/utils.ts index d95a139a..14cc23ca 100644 --- a/src/client/VZSidebar/VisualEditor/utils.ts +++ b/src/client/VZSidebar/VisualEditor/utils.ts @@ -133,8 +133,23 @@ export const setNestedProperty = < 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 if ( !current[key] || @@ -151,8 +166,16 @@ export const setNestedProperty = < 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[keys[keys.length - 1]] = value; + current[finalKey] = value; return newObj; }; From 18cdee57aae7bd402b6de120feecea2a94deef02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:11:57 +0000 Subject: [PATCH 7/8] Add path validation to prevent malicious input Co-authored-by: curran <68416+curran@users.noreply.github.com> --- src/client/VZSidebar/VisualEditor/utils.ts | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/client/VZSidebar/VisualEditor/utils.ts b/src/client/VZSidebar/VisualEditor/utils.ts index 14cc23ca..20780d86 100644 --- a/src/client/VZSidebar/VisualEditor/utils.ts +++ b/src/client/VZSidebar/VisualEditor/utils.ts @@ -106,9 +106,23 @@ 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; } @@ -129,7 +143,27 @@ export const setNestedProperty = < 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; From 49fdf06c3cede971fc50c95f78dc2d0f2e818c48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:14:02 +0000 Subject: [PATCH 8/8] Fix falsy value handling in setNestedProperty Co-authored-by: curran <68416+curran@users.noreply.github.com> --- src/client/VZSidebar/VisualEditor/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/VZSidebar/VisualEditor/utils.ts b/src/client/VZSidebar/VisualEditor/utils.ts index 20780d86..a5e0dc62 100644 --- a/src/client/VZSidebar/VisualEditor/utils.ts +++ b/src/client/VZSidebar/VisualEditor/utils.ts @@ -184,9 +184,9 @@ export const setNestedProperty = < ); } - // Create nested object if it doesn't exist + // Create nested object if it doesn't exist or isn't a plain object if ( - !current[key] || + current[key] == null || typeof current[key] !== 'object' || Array.isArray(current[key]) ) {