Skip to content

Commit a269062

Browse files
committed
fix(editor,credential-group): mask secrets outside short-input and stop a per-option abort from failing a shared query
config.password only reached the short-input renderer, so eight credential fields rendered in plaintext: private keys on ssh/sftp/pi/kalshi, the Secrets Manager payload, the STS web-identity and SAML assertions, and the Browser Use variables table. long-input, code, and table now honor the flag. Code fields mask through the highlighter because react-simple-code-editor paints its textarea transparent; the table masks every column but the first so key/value rows stay distinguishable. A registry-walking audit test fails both on a password flag sitting on a type that cannot honor it and on any of the eight fields losing its flag. credential-group threaded a per-option AbortSignal into the fetch registered under the workspace-wide credential group list key, so closing one option panel rejected every co-observer with an AbortError that is not a React Query cancellation. The shared fetch now runs on its own lifecycle signal.
1 parent e959809 commit a269062

17 files changed

Lines changed: 984 additions & 29 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
9+
10+
/** jsdom ships no ResizeObserver; the editor observes its container to size the gutter. */
11+
globalThis.ResizeObserver = class {
12+
observe() {}
13+
unobserve() {}
14+
disconnect() {}
15+
} as unknown as typeof ResizeObserver
16+
17+
const { SECRET } = vi.hoisted(() => ({
18+
SECRET: '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjE\n-----END-----',
19+
}))
20+
21+
vi.mock('@sim/emcn', () => ({
22+
CODE_LINE_HEIGHT_PX: 21,
23+
Code: {
24+
Container: ({ children }: { children: ReactNode }) => <div>{children}</div>,
25+
Gutter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
26+
Content: ({
27+
children,
28+
editorRef,
29+
}: {
30+
children: ReactNode
31+
editorRef?: React.RefObject<HTMLDivElement | null>
32+
}) => <div ref={editorRef}>{children}</div>,
33+
Placeholder: ({ children, show }: { children: ReactNode; show: boolean }) =>
34+
show ? <div>{children}</div> : null,
35+
},
36+
calculateGutterWidth: () => 24,
37+
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
38+
Duplicate: () => null,
39+
getCodeEditorProps: () => ({}),
40+
highlight: (code: string) => code,
41+
languages: { javascript: {}, python: {}, bash: {} },
42+
}))
43+
44+
vi.mock('@sim/emcn/icons', () => ({
45+
Check: () => null,
46+
Wand: () => null,
47+
}))
48+
49+
vi.mock('react-simple-code-editor', () => ({
50+
default: ({
51+
value,
52+
highlight,
53+
onFocus,
54+
onBlur,
55+
}: {
56+
value: string
57+
highlight: (code: string) => string
58+
onFocus: () => void
59+
onBlur: () => void
60+
}) => (
61+
<>
62+
<textarea
63+
data-testid='code-textarea'
64+
value={value}
65+
readOnly
66+
onFocus={onFocus}
67+
onBlur={onBlur}
68+
/>
69+
<pre data-testid='code-highlight' dangerouslySetInnerHTML={{ __html: highlight(value) }} />
70+
</>
71+
),
72+
}))
73+
74+
vi.mock('@/components/ui/button', () => ({
75+
Button: ({ children }: { children?: ReactNode }) => <button type='button'>{children}</button>,
76+
}))
77+
78+
vi.mock('next/navigation', () => ({
79+
useParams: () => ({ workspaceId: 'workspace-1' }),
80+
}))
81+
82+
vi.mock(
83+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown',
84+
() => ({
85+
EnvVarDropdown: () => null,
86+
checkEnvVarTrigger: () => ({ show: false, searchTerm: '' }),
87+
})
88+
)
89+
90+
vi.mock(
91+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown',
92+
() => ({
93+
TagDropdown: () => null,
94+
checkTagTrigger: () => ({ show: false }),
95+
})
96+
)
97+
98+
vi.mock(
99+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value',
100+
() => ({
101+
useSubBlockValue: (_blockId: string, subBlockId: string) => [
102+
subBlockId === 'privateKey' ? SECRET : undefined,
103+
() => {},
104+
],
105+
})
106+
)
107+
108+
vi.mock(
109+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider',
110+
() => ({ useActiveSearchTarget: () => null })
111+
)
112+
113+
vi.mock(
114+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar',
115+
() => ({
116+
WandPromptBar: () => null,
117+
})
118+
)
119+
120+
vi.mock(
121+
'@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes',
122+
() => ({ useAccessibleReferencePrefixes: () => undefined })
123+
)
124+
125+
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand', () => ({
126+
useWand: () => ({
127+
isLoading: false,
128+
isStreaming: false,
129+
isPromptVisible: false,
130+
promptInputValue: '',
131+
generateStream: () => {},
132+
cancelGeneration: () => {},
133+
showPromptInline: () => {},
134+
hidePromptInline: () => {},
135+
updatePromptValue: () => {},
136+
}),
137+
}))
138+
139+
vi.mock('@/hooks/kb/use-tag-selection', () => ({ useTagSelection: () => () => {} }))
140+
141+
vi.mock('@/hooks/use-available-env-vars', () => ({
142+
useAvailableEnvVarKeys: () => [],
143+
createShouldHighlightEnvVar: () => () => false,
144+
}))
145+
146+
vi.mock('@/hooks/use-code-undo-redo', () => ({
147+
useCodeUndoRedo: () => ({
148+
recordChange: () => {},
149+
recordReplace: () => {},
150+
flushPending: () => {},
151+
startSession: () => {},
152+
undo: () => {},
153+
redo: () => {},
154+
}),
155+
}))
156+
157+
vi.mock('@/stores/workflows/workflow/store', () => ({
158+
useWorkflowStore: (selector: (state: { blocks: Record<string, unknown> }) => unknown) =>
159+
selector({ blocks: {} }),
160+
}))
161+
162+
import { Code } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code'
163+
164+
let container: HTMLDivElement
165+
let root: Root
166+
167+
beforeEach(() => {
168+
container = document.createElement('div')
169+
document.body.appendChild(container)
170+
root = createRoot(container)
171+
})
172+
173+
afterEach(() => {
174+
act(() => root.unmount())
175+
container.remove()
176+
})
177+
178+
function mount(password: boolean) {
179+
act(() => {
180+
root.render(
181+
<Code
182+
blockId='block-1'
183+
subBlockId='privateKey'
184+
password={password}
185+
wandConfig={{ enabled: false, prompt: '' }}
186+
/>
187+
)
188+
})
189+
}
190+
191+
const highlighted = () => container.querySelector('[data-testid="code-highlight"]')?.innerHTML ?? ''
192+
193+
describe('Code password masking', () => {
194+
it('conceals the editor contents while unfocused', () => {
195+
mount(true)
196+
197+
expect(highlighted()).not.toContain('BEGIN OPENSSH PRIVATE KEY')
198+
expect(highlighted()).not.toContain('b3BlbnNzaC1rZXktdjE')
199+
expect(highlighted()).toContain('•')
200+
})
201+
202+
it('reveals the contents once the editor takes focus and re-masks on blur', () => {
203+
mount(true)
204+
205+
const textarea = container.querySelector('[data-testid="code-textarea"]') as HTMLTextAreaElement
206+
act(() => {
207+
textarea.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
208+
})
209+
expect(highlighted()).toContain('BEGIN OPENSSH PRIVATE KEY')
210+
211+
act(() => {
212+
textarea.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
213+
})
214+
expect(highlighted()).not.toContain('BEGIN OPENSSH PRIVATE KEY')
215+
})
216+
217+
it('leaves a non-password code field in plaintext', () => {
218+
mount(false)
219+
220+
expect(highlighted()).toContain('BEGIN OPENSSH PRIVATE KEY')
221+
expect(highlighted()).not.toContain('•')
222+
})
223+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
getValidWorkflowSearchRange,
3131
type WorkflowSearchTextHighlight,
3232
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
33+
import { maskSecretText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
3334
import {
3435
checkTagTrigger,
3536
TagDropdown,
@@ -137,6 +138,21 @@ const escapeHtml = (value: string): string =>
137138
.replaceAll('"', '&quot;')
138139
.replaceAll("'", '&#39;')
139140

141+
/**
142+
* Highlighter that conceals the editor's contents.
143+
*
144+
* @remarks
145+
* `react-simple-code-editor` paints its textarea with a transparent text fill
146+
* and shows the markup returned by its `highlight` prop instead, so swapping the
147+
* highlighter is what actually hides a secret — the textarea's own value is
148+
* never visible.
149+
*
150+
* @param codeToHighlight - The plaintext editor contents
151+
* @returns Escaped markup with every character replaced by a mask glyph
152+
*/
153+
export const highlightMaskedCode = (codeToHighlight: string): string =>
154+
escapeHtml(maskSecretText(codeToHighlight))
155+
140156
/**
141157
* Type definition for code placeholders during syntax highlighting.
142158
*/
@@ -234,6 +250,8 @@ interface CodeProps {
234250
blockId: string
235251
subBlockId: string
236252
placeholder?: string
253+
/** Whether to conceal the value until the editor is focused */
254+
password?: boolean
237255
language?: 'javascript' | 'json' | 'python' | 'shell'
238256
generationType?: GenerationType
239257
value?: string
@@ -263,6 +281,7 @@ export const Code = memo(function Code({
263281
blockId,
264282
subBlockId,
265283
placeholder = 'Write JavaScript...',
284+
password = false,
266285
language = 'javascript',
267286
generationType = 'javascript-function-body',
268287
value: propValue,
@@ -290,6 +309,7 @@ export const Code = memo(function Code({
290309
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
291310
const [activeLineNumber, setActiveLineNumber] = useState(1)
292311
const [copied, setCopied] = useState(false)
312+
const [isFocused, setIsFocused] = useState(false)
293313

294314
const editorRef = useRef<HTMLDivElement>(null)
295315
const handleStreamStartRef = useRef<() => void>(() => {})
@@ -812,6 +832,7 @@ export const Code = memo(function Code({
812832
)
813833

814834
const handleEditorFocus = useCallback(() => {
835+
setIsFocused(true)
815836
startSession(codeRef.current)
816837
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
817838
setShowTags(true)
@@ -820,6 +841,7 @@ export const Code = memo(function Code({
820841
}, [disabled, isPreview, readOnly, startSession])
821842

822843
const handleEditorBlur = useCallback(() => {
844+
setIsFocused(false)
823845
flushPending()
824846
}, [flushPending])
825847

@@ -942,7 +964,7 @@ export const Code = memo(function Code({
942964
onKeyDown={handleKeyDown}
943965
onFocus={handleEditorFocus}
944966
onBlur={handleEditorBlur}
945-
highlight={highlightCode}
967+
highlight={password && !isFocused ? highlightMaskedCode : highlightCode}
946968
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
947969
/>
948970

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export { LongInput } from './long-input'
1818
export { McpDynamicArgs } from './mcp-dynamic-args'
1919
export { McpServerSelector, McpToolSelector } from './mcp-server-modal'
2020
export { MessagesInput } from './messages-input'
21+
export { maskSecretText, PASSWORD_MASKED_SUBBLOCK_TYPES } from './password-mask'
2122
export { ResponseFormat } from './response'
2223
export { ScheduleInfo } from './schedule-info'
2324
export { SelectorInput, type SelectorOverrides } from './selector-input'

0 commit comments

Comments
 (0)