Skip to content

Commit aab4bd9

Browse files
committed
fix(mssql,editor): measure the response cap in UTF-8 and stop search from unmasking secrets
capRecordset sized rows with JSON.stringify(row).length, which counts UTF-16 code units while the emitted body carries raw UTF-8. CJK is the worst case at 3 bytes per unit, so a recordset admitted as 10 MB serialized to 28 MB. Rows are now measured with Buffer.byteLength, serialized once each, with array punctuation charged exactly and a reserve held back for the response envelope. Workflow search revealed masked credentials without the user touching the field: the search panel keeps focus in its own input and only scrolls the match into view, so typing a guess painted a private key on screen. The index is built client-side from values already in page memory, so this was never a privilege boundary, but masking exists to prevent incidental display and a screenshare-visible reveal defeats it. Focus is now the only reveal, applied through one shared policy across all four renderers.
1 parent a269062 commit aab4bd9

13 files changed

Lines changed: 372 additions & 60 deletions

File tree

apps/sim/app/api/tools/mssql/utils.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,50 @@ describe('executeQuery result caps', () => {
558558
expect(result.truncated).toBe(true)
559559
expect(result.truncationReason).toMatch(/exceeds the 10 MB response ceiling/)
560560
})
561+
562+
/**
563+
* `String.length` counts UTF-16 code units and the response is emitted as
564+
* UTF-8, so a CJK recordset costs three bytes for every unit the old
565+
* accounting charged one for. Measured with `length` these rows fit; measured
566+
* as the bytes that actually go on the wire they are ~3x over.
567+
*/
568+
it('bounds a multibyte recordset by UTF-8 bytes, not UTF-16 code units', async () => {
569+
const cjk = Array.from({ length: 20 }, () => ({ blob: '世'.repeat(1024 * 1024) }))
570+
const result = await executeQuery(makeCapPool(cjk), 'SELECT 1')
571+
572+
expect(Buffer.byteLength(JSON.stringify(result.rows), 'utf8')).toBeLessThanOrEqual(
573+
10 * 1024 * 1024
574+
)
575+
expect(result.rows.length).toBeGreaterThan(0)
576+
expect(result.truncated).toBe(true)
577+
})
578+
579+
/** Emoji are 4 UTF-8 bytes across 2 surrogate code units — a 2:1 undercount. */
580+
it('bounds an astral-plane recordset by UTF-8 bytes', async () => {
581+
const emoji = Array.from({ length: 20 }, () => ({ blob: '😀'.repeat(1024 * 1024) }))
582+
const result = await executeQuery(makeCapPool(emoji), 'SELECT 1')
583+
584+
expect(Buffer.byteLength(JSON.stringify(result.rows), 'utf8')).toBeLessThanOrEqual(
585+
10 * 1024 * 1024
586+
)
587+
expect(result.truncated).toBe(true)
588+
})
589+
590+
/**
591+
* Rows sized to divide the ceiling exactly, so an accounting that ignores the
592+
* array's commas and the fields around it lands precisely on the limit and the
593+
* body it emits is over by the punctuation and the envelope.
594+
*/
595+
it('keeps the emitted body inside the ceiling once array and envelope overhead is counted', async () => {
596+
const rowPayload = 'x'.repeat(2048 - '{"blob":""}'.length)
597+
const packed = Array.from({ length: 6000 }, () => ({ blob: rowPayload }))
598+
const result = await executeQuery(makeCapPool(packed), 'SELECT 1')
599+
600+
const body = toRowsResponseBody(result, 'Query executed successfully. rows returned.')
601+
602+
expect(result.truncated).toBe(true)
603+
expect(Buffer.byteLength(JSON.stringify(body), 'utf8')).toBeLessThanOrEqual(10 * 1024 * 1024)
604+
})
561605
})
562606

563607
describe('toRowsResponseBody truncation disclosure', () => {

apps/sim/app/api/tools/mssql/utils.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,44 @@ function toBindableValue(value: unknown): unknown {
161161
const MSSQL_MAX_RESULT_ROWS = 10_000
162162
const MSSQL_MAX_RESULT_BYTES = 10 * 1024 * 1024
163163

164+
/**
165+
* Bytes held back from {@link MSSQL_MAX_RESULT_BYTES} for the part of the
166+
* response body that is not a row.
167+
*
168+
* {@link toRowsResponseBody} wraps `rows` in `message`, `rowCount`, and — when
169+
* the recordset was capped — `truncated` and `truncationReason`, none of which
170+
* the per-row accounting can see. Those are a few hundred bytes at their
171+
* longest (the truncation prose is the bulk of it), so the reserve is set an
172+
* order of magnitude above the worst case and costs 0.04% of the ceiling. The
173+
* alternative, serializing the assembled body to check it, would re-serialize
174+
* the whole recordset a second time for no useful precision.
175+
*/
176+
const MSSQL_RESPONSE_ENVELOPE_BYTES = 4096
177+
178+
/** What the serialized `rows` array itself may occupy. */
179+
const MSSQL_MAX_ROWS_BYTES = MSSQL_MAX_RESULT_BYTES - MSSQL_RESPONSE_ENVELOPE_BYTES
180+
164181
/**
165182
* Truncates a recordset to the row and byte ceilings.
166183
*
167-
* Measures with `JSON.stringify` on each row because that is what the route will
168-
* do anyway, so the number bounds the response the caller actually receives
169-
* rather than an in-memory estimate that does not correspond to it.
184+
* Measures each row with `JSON.stringify` because that is what the route will do
185+
* anyway, so the number bounds the response the caller actually receives rather
186+
* than an in-memory estimate that does not correspond to it. Each row is
187+
* serialized exactly once and its cost accumulated, rather than re-serializing
188+
* the growing array per row, which would be quadratic on a large recordset.
189+
*
190+
* The size is `Buffer.byteLength(..., 'utf8')`, not `String.length`. `length`
191+
* counts UTF-16 code units while `NextResponse.json` emits UTF-8, and every
192+
* character above U+007F costs more bytes than code units — worst case 3:1, for
193+
* the U+0800–U+FFFF range that holds CJK, so a recordset of Chinese text passed
194+
* a 10 MB `length` budget while serializing to nearly 30 MB. (Astral characters
195+
* such as emoji are only 2:1: 4 bytes across 2 surrogate code units.)
196+
*
197+
* The array's own punctuation is counted too — one byte per row covers the
198+
* opening `[` for the first row and the separating `,` for each one after it,
199+
* with the leading byte standing in for the closing `]` — and
200+
* {@link MSSQL_RESPONSE_ENVELOPE_BYTES} covers the fields around it. Without
201+
* both, a result packed exactly to the ceiling still emitted a body over it.
170202
*
171203
* A row is admitted only when it still fits, so a single row larger than the
172204
* byte ceiling is dropped rather than admitted as a lone exception — otherwise
@@ -179,13 +211,19 @@ function capRecordset(rows: unknown[]): { rows: unknown[]; truncated: boolean }
179211
if (rows.length === 0) return { rows, truncated: false }
180212

181213
const capped: unknown[] = []
182-
let bytes = 0
214+
/** The closing `]`; each row below pays for its own `[` or `,`. */
215+
let bytes = 1
183216

184217
for (const row of rows) {
185218
if (capped.length >= MSSQL_MAX_RESULT_ROWS) break
186-
const rowBytes = JSON.stringify(row)?.length ?? 0
187-
if (bytes + rowBytes > MSSQL_MAX_RESULT_BYTES) break
188-
bytes += rowBytes
219+
const serialized = JSON.stringify(row)
220+
/**
221+
* `JSON.stringify` answers `undefined` for a value it cannot represent, but
222+
* an array element in that position serializes as the four bytes of `null`.
223+
*/
224+
const rowBytes = serialized === undefined ? 4 : Buffer.byteLength(serialized, 'utf8')
225+
if (bytes + rowBytes + 1 > MSSQL_MAX_ROWS_BYTES) break
226+
bytes += rowBytes + 1
189227
capped.push(row)
190228
}
191229

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

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ globalThis.ResizeObserver = class {
1414
disconnect() {}
1515
} as unknown as typeof ResizeObserver
1616

17-
const { SECRET } = vi.hoisted(() => ({
17+
const { SECRET, searchTargetRef } = vi.hoisted(() => ({
1818
SECRET: '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjE\n-----END-----',
19+
searchTargetRef: { current: null as Record<string, unknown> | null },
1920
}))
2021

2122
vi.mock('@sim/emcn', () => ({
@@ -107,7 +108,7 @@ vi.mock(
107108

108109
vi.mock(
109110
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider',
110-
() => ({ useActiveSearchTarget: () => null })
111+
() => ({ useActiveSearchTarget: () => searchTargetRef.current })
111112
)
112113

113114
vi.mock(
@@ -165,6 +166,7 @@ let container: HTMLDivElement
165166
let root: Root
166167

167168
beforeEach(() => {
169+
searchTargetRef.current = null
168170
container = document.createElement('div')
169171
document.body.appendChild(container)
170172
root = createRoot(container)
@@ -190,6 +192,18 @@ function mount(password: boolean) {
190192

191193
const highlighted = () => container.querySelector('[data-testid="code-highlight"]')?.innerHTML ?? ''
192194

195+
/** A live workflow-search hit on the base64 body of the secret. */
196+
const SECRET_MATCH = 'b3BlbnNzaC1rZXktdjE'
197+
const SECRET_MATCH_START = SECRET.indexOf(SECRET_MATCH)
198+
const SECRET_SEARCH_TARGET = {
199+
subBlockId: 'privateKey',
200+
targetKind: 'subblock',
201+
valuePath: [],
202+
query: SECRET_MATCH,
203+
rawValue: SECRET_MATCH,
204+
range: { start: SECRET_MATCH_START, end: SECRET_MATCH_START + SECRET_MATCH.length },
205+
}
206+
193207
describe('Code password masking', () => {
194208
it('conceals the editor contents while unfocused', () => {
195209
mount(true)
@@ -220,4 +234,23 @@ describe('Code password masking', () => {
220234
expect(highlighted()).toContain('BEGIN OPENSSH PRIVATE KEY')
221235
expect(highlighted()).not.toContain('•')
222236
})
237+
238+
it('stays concealed while workflow search targets a match inside the secret', () => {
239+
searchTargetRef.current = SECRET_SEARCH_TARGET
240+
241+
mount(true)
242+
243+
expect(highlighted()).not.toContain('b3BlbnNzaC1rZXktdjE')
244+
expect(highlighted()).not.toContain('BEGIN OPENSSH PRIVATE KEY')
245+
expect(highlighted()).toContain('•')
246+
})
247+
248+
it('highlights a targeted match when the field holds no secret', () => {
249+
searchTargetRef.current = SECRET_SEARCH_TARGET
250+
251+
mount(false)
252+
253+
expect(highlighted()).toContain('<mark')
254+
expect(highlighted()).toContain('b3BlbnNzaC1rZXktdjE')
255+
})
223256
})

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ 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'
33+
import {
34+
maskSecretText,
35+
shouldMaskSecretValue,
36+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
3437
import {
3538
checkTagTrigger,
3639
TagDropdown,
@@ -250,7 +253,7 @@ interface CodeProps {
250253
blockId: string
251254
subBlockId: string
252255
placeholder?: string
253-
/** Whether to conceal the value until the editor is focused */
256+
/** Whether to conceal the value except while the editor is focused */
254257
password?: boolean
255258
language?: 'javascript' | 'json' | 'python' | 'shell'
256259
generationType?: GenerationType
@@ -763,6 +766,8 @@ export const Code = memo(function Code({
763766
valuePath: [],
764767
})
765768

769+
const shouldMask = shouldMaskSecretValue({ password, isFocused })
770+
766771
const highlightCode = useMemo(
767772
() =>
768773
createHighlightFunction(
@@ -964,7 +969,7 @@ export const Code = memo(function Code({
964969
onKeyDown={handleKeyDown}
965970
onFocus={handleEditorFocus}
966971
onBlur={handleEditorBlur}
967-
highlight={password && !isFocused ? highlightMaskedCode : highlightCode}
972+
highlight={shouldMask ? highlightMaskedCode : highlightCode}
968973
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
969974
/>
970975

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

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
* @vitest-environment node
33
*/
44
import { renderToStaticMarkup } from 'react-dom/server'
5-
import { describe, expect, it, vi } from 'vitest'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { SECRET } = vi.hoisted(() => ({
7+
const { SECRET, searchTargetRef } = vi.hoisted(() => ({
88
SECRET: '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk\n-----END-----',
9+
searchTargetRef: { current: null as Record<string, unknown> | null },
910
}))
1011

1112
vi.mock('@sim/emcn', () => ({
@@ -60,7 +61,7 @@ vi.mock(
6061
vi.mock(
6162
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider',
6263
() => ({
63-
useActiveSearchTarget: () => null,
64+
useActiveSearchTarget: () => searchTargetRef.current,
6465
})
6566
)
6667

@@ -97,13 +98,20 @@ import type { SubBlockConfig } from '@/blocks/types'
9798

9899
const config: SubBlockConfig = { id: 'privateKey', type: 'long-input', password: true }
99100

101+
/** A live workflow-search hit on the base64 body of the secret. */
102+
const MATCH_START = SECRET.indexOf('b3BlbnNzaC1rZXk')
103+
100104
function render(password: boolean) {
101105
return renderToStaticMarkup(
102106
<LongInput blockId='block-1' subBlockId='privateKey' config={config} password={password} />
103107
)
104108
}
105109

106110
describe('LongInput password masking', () => {
111+
beforeEach(() => {
112+
searchTargetRef.current = null
113+
})
114+
107115
it('conceals the value in both the textarea and the overlay when unfocused', () => {
108116
const html = render(true)
109117

@@ -118,4 +126,39 @@ describe('LongInput password masking', () => {
118126
expect(html).toContain('BEGIN OPENSSH PRIVATE KEY')
119127
expect(html).not.toContain('•')
120128
})
129+
130+
it('stays concealed while workflow search targets a match inside the secret', () => {
131+
searchTargetRef.current = {
132+
blockId: 'block-1',
133+
subBlockId: 'privateKey',
134+
targetKind: 'subblock',
135+
valuePath: [],
136+
query: 'b3BlbnNzaC1rZXk',
137+
rawValue: 'b3BlbnNzaC1rZXk',
138+
range: { start: MATCH_START, end: MATCH_START + 'b3BlbnNzaC1rZXk'.length },
139+
}
140+
141+
const html = render(true)
142+
143+
expect(html).not.toContain('b3BlbnNzaC1rZXk')
144+
expect(html).not.toContain('BEGIN OPENSSH PRIVATE KEY')
145+
expect(html).toContain('•')
146+
})
147+
148+
it('highlights a workflow-search match when the field holds no secret', () => {
149+
searchTargetRef.current = {
150+
blockId: 'block-1',
151+
subBlockId: 'privateKey',
152+
targetKind: 'subblock',
153+
valuePath: [],
154+
query: 'b3BlbnNzaC1rZXk',
155+
rawValue: 'b3BlbnNzaC1rZXk',
156+
range: { start: MATCH_START, end: MATCH_START + 'b3BlbnNzaC1rZXk'.length },
157+
}
158+
159+
const html = render(false)
160+
161+
expect(html).toContain('<mark')
162+
expect(html).toContain('b3BlbnNzaC1rZXk')
163+
})
121164
})

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

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import { cn, Textarea } from '@sim/emcn'
1111
import { ChevronsUpDown, Wand } from '@sim/emcn/icons'
1212
import { createLogger } from '@sim/logger'
1313
import { Button } from '@/components/ui/button'
14+
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
1415
import {
15-
formatDisplayText,
16-
getValidWorkflowSearchRange,
17-
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
18-
import { maskSecretText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
16+
maskSecretText,
17+
shouldMaskSecretValue,
18+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/password-mask'
1919
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
2020
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
2121
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
@@ -50,7 +50,7 @@ const MIN_HEIGHT_PX = 80
5050
interface LongInputProps {
5151
/** Placeholder text to display when empty */
5252
placeholder?: string
53-
/** Whether to conceal the value until the textarea is focused */
53+
/** Whether to conceal the value except while the textarea is focused */
5454
password?: boolean
5555
/** Unique identifier for the block */
5656
blockId: string
@@ -85,7 +85,7 @@ interface LongInputProps {
8585
* - Handles drag-and-drop for connections and variable references
8686
* - Provides environment variable and tag autocomplete
8787
* - Resizable with custom drag handle
88-
* - Password masking with reveal on focus
88+
* - Password masking, revealed only while focused
8989
* - Integrates with ReactFlow for zoom control
9090
*/
9191
export function LongInput({
@@ -200,13 +200,7 @@ export function LongInput({
200200
// During streaming, use local content; otherwise use the controller value
201201
const value = wandHook.isStreaming ? localContent : ctrl.valueString
202202

203-
/**
204-
* A masked field reveals itself while focused so it stays editable, and while
205-
* workflow search has an active match inside it so the hit is findable —
206-
* matching the short-input treatment of the same flag.
207-
*/
208-
const shouldMask =
209-
Boolean(password) && !isFocused && !getValidWorkflowSearchRange(value, workflowSearchHighlight)
203+
const shouldMask = shouldMaskSecretValue({ password, isFocused })
210204
const displayValue = shouldMask ? maskSecretText(value) : value
211205

212206
const handleBlur = useCallback(() => {
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
export { maskSecretText, PASSWORD_MASKED_SUBBLOCK_TYPES } from './password-mask'
1+
export {
2+
maskSecretText,
3+
PASSWORD_MASKED_SUBBLOCK_TYPES,
4+
shouldMaskSecretValue,
5+
} from './password-mask'

0 commit comments

Comments
 (0)