fix(front): guard undefined nested output schema traversal

https://sonarly.com/issue/38306?type=bug

Opening a specific workflow record crashes variable-chip rendering in the workflow UI with a handled React TypeError. The page can become unusable for that workflow because variable lookup throws during render instead of returning “Not Found”.

Fix: Implemented a defensive traversal fix in workflow variable base-schema search so malformed non-leaf nodes no longer throw during render.

### What changed
- In `searchVariableThroughBaseOutputSchema.ts`, `navigateToTargetField` now validates that a non-leaf node has an object-like `value` before traversing deeper.
- If an intermediate node is malformed (`undefined`, non-object, or array), traversal now returns `undefined` variable metadata instead of throwing a `TypeError`.
- This preserves intended UX behavior (“not found” fallback) and prevents workflow-page render crashes.

### Why this fixes the incident
The crash happened when traversal assigned `currentSchema = field.value` where `field.value` was invalid, then accessed `currentSchema[nextSegment]`. The new guard blocks that invalid descent path.

Authored by Sonarly by autonomous analysis (run 43714).
This commit is contained in:
sonarly-bot
2026-05-18 09:00:52 +00:00
parent 6b3064e2ba
commit 34e6fcde9a
4 changed files with 135 additions and 9 deletions
@@ -1,7 +1,19 @@
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
import { reportInvalidVariableSchemaTraversal } from '@/workflow/workflow-variables/utils/reportInvalidVariableSchemaTraversal';
import type { BaseOutputSchemaV2 } from 'twenty-shared/workflow';
jest.mock(
'@/workflow/workflow-variables/utils/reportInvalidVariableSchemaTraversal',
() => ({
reportInvalidVariableSchemaTraversal: jest.fn(),
}),
);
describe('searchVariableThroughBaseOutputSchema', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockBaseSchema: BaseOutputSchemaV2 = {
message: {
isLeaf: true,
@@ -328,4 +340,32 @@ describe('searchVariableThroughBaseOutputSchema', () => {
variableType: 'unknown',
});
});
it('should return undefined and report malformed nested schema nodes', () => {
const malformedSchema: BaseOutputSchemaV2 = {
user: {
isLeaf: false,
type: 'object',
label: 'User Information',
value: undefined as unknown as BaseOutputSchemaV2,
},
};
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: malformedSchema,
rawVariableName: '{{step1.user.phone}}',
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
variableType: undefined,
});
expect(reportInvalidVariableSchemaTraversal).toHaveBeenCalledWith({
stepName: 'HTTP Request',
rawVariableName: '{{step1.user.phone}}',
pathSegment: 'user',
});
});
});
@@ -0,0 +1,40 @@
const reportedInvalidVariableSchemaTraversalKeys = new Set<string>();
export const reportInvalidVariableSchemaTraversal = ({
stepName,
rawVariableName,
pathSegment,
}: {
stepName: string;
rawVariableName: string;
pathSegment: string;
}) => {
const issueKey = `${stepName}:${rawVariableName}:${pathSegment}`;
if (reportedInvalidVariableSchemaTraversalKeys.has(issueKey)) {
return;
}
reportedInvalidVariableSchemaTraversalKeys.add(issueKey);
void import('@sentry/react').then(({ captureMessage }) => {
captureMessage('Workflow variable schema traversal skipped malformed node', {
level: 'warning',
tags: {
area: 'workflow-variables',
},
contexts: {
workflowVariableSchemaTraversal: {
stepName,
rawVariableName,
pathSegment,
},
},
fingerprint: [
'workflow-variable-schema-traversal-malformed-node',
stepName,
pathSegment,
],
});
});
};
@@ -1,4 +1,5 @@
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import { reportInvalidVariableSchemaTraversal } from '@/workflow/workflow-variables/utils/reportInvalidVariableSchemaTraversal';
import { isDefined } from 'twenty-shared/utils';
import {
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
@@ -26,10 +27,17 @@ const parseVariableName = (rawVariableName: string) => {
};
};
const navigateToTargetField = (
startingSchema: BaseOutputSchemaV2,
pathSegments: string[],
): { schema: BaseOutputSchemaV2; pathLabels: string[] } | null => {
const navigateToTargetField = ({
startingSchema,
pathSegments,
stepName,
rawVariableName,
}: {
startingSchema: BaseOutputSchemaV2;
pathSegments: string[];
stepName: string;
rawVariableName: string;
}): { schema: BaseOutputSchemaV2; pathLabels: string[] } | null => {
let currentSchema: BaseOutputSchemaV2 = startingSchema;
const pathLabels: string[] = [];
@@ -40,6 +48,20 @@ const navigateToTargetField = (
return null;
}
if (
!isDefined(field.value) ||
typeof field.value !== 'object' ||
Array.isArray(field.value)
) {
reportInvalidVariableSchemaTraversal({
stepName,
rawVariableName,
pathSegment,
});
return null;
}
pathLabels.push(field.label);
currentSchema = field.value;
}
@@ -78,13 +100,21 @@ export const searchBaseOutputSchema = ({
baseOutputSchema,
path,
selectedField,
rawVariableName,
}: {
stepName: string;
baseOutputSchema: BaseOutputSchemaV2;
path: string[];
selectedField: string;
rawVariableName?: string;
}): VariableSearchResult => {
const navigationResult = navigateToTargetField(baseOutputSchema, path);
const navigationResult = navigateToTargetField({
startingSchema: baseOutputSchema,
pathSegments: path,
stepName,
rawVariableName:
rawVariableName ?? `${stepName}.${path.join('.')}.${selectedField}`,
});
if (!navigationResult) {
return {
@@ -137,10 +167,25 @@ export const searchVariableThroughBaseOutputSchema = ({
};
}
return searchBaseOutputSchema({
const navigationResult = navigateToTargetField({
startingSchema: baseOutputSchema,
pathSegments,
stepName,
baseOutputSchema,
path: pathSegments,
selectedField: targetFieldName,
rawVariableName,
});
if (!navigationResult) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
variableType: undefined,
};
}
return buildVariableResult(
stepName,
navigationResult.pathLabels,
navigationResult.schema,
targetFieldName,
);
};
@@ -109,6 +109,7 @@ export const searchVariableThroughIteratorOutputSchema = ({
baseOutputSchema: schema,
path: pathSegments,
selectedField: fieldName,
rawVariableName,
});
}