Skip to content

Commit 2054947

Browse files
authored
test(export): pin the table and tool-param loss the sanitized export accepts (#6613)
* test(export): pin the table and tool-param loss the sanitized export accepts #6591 enabled `redactOpaqueCredentialInputs` on the workflow export path, closing a real leak. It also made export lossy for tables and unauthoritative tool params, and nothing pinned that trade in either direction. Adds round-trip fixtures (an api block with two table sub-blocks, an agent block with a custom tool) plus assertions for the current loss, records the security/usability trade on the flag that governs it, and deletes a duplicate `sanitizeForExport` in credential-extractor that omitted the redaction flag and had zero production importers. No behavior change. * test(export): make the env-ref leak sweep load-bearing and drop test any The sweep asserted against a token the fixture no longer contained, so it passed vacuously. Both the fixture and the assertion now read one symbol, which is the only form that cannot drift. Also types the re-imported block lookup instead of casting through any.
1 parent 366829b commit 2054947

6 files changed

Lines changed: 197 additions & 73 deletions

File tree

apps/sim/app/api/v1/admin/folders/[id]/export/route.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
/**
22
* GET /api/v1/admin/folders/[id]/export
33
*
4-
* Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
4+
* Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON.
5+
*
6+
* The two formats are NOT equivalent. `json` emits the raw stored state for admin
7+
* backup/restore. `zip` runs every workflow through `sanitizeForExport`, which withholds
8+
* credentials and several other classes of sub-block value, so a ZIP does not restore
9+
* faithfully — use `json` when the export has to.
510
*
611
* Query Parameters:
7-
* - format: 'zip' (default) or 'json'
12+
* - format: 'zip' (default, sanitized) or 'json' (raw)
813
*
914
* Response:
1015
* - ZIP file download (Content-Type: application/zip)

apps/sim/app/api/v1/workflows/[id]/export/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ export const revalidate = 0
2424
* GET /api/v1/workflows/[id]/export
2525
*
2626
* Exports a workflow as a portable JSON envelope that
27-
* `POST /api/v1/workflows/import` accepts verbatim. Payload assembly and the
28-
* sanitization guarantees are documented on the shared
27+
* `POST /api/v1/workflows/import` accepts without further editing. It is not a
28+
* byte-for-byte clone: the envelope is secret-sanitized, so several classes of
29+
* sub-block value import as empty and must be re-entered. Payload assembly and
30+
* the authoritative list of what is withheld are documented on the shared
2931
* {@link buildWorkflowExportPayload}; this route authenticates and renders the
3032
* v1 envelope.
3133
*/

apps/sim/lib/workflows/credentials/credential-extractor.test.ts

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import {
66
EXPORT_PRESERVED_RESOURCE_TYPES,
7-
sanitizeForExport,
87
sanitizeWorkflowForSharing,
98
} from '@/lib/workflows/credentials/credential-extractor'
109
import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry'
@@ -46,14 +45,25 @@ function stateWithSubBlock(type: string, value: unknown): Partial<WorkflowState>
4645
} as unknown as Partial<WorkflowState>
4746
}
4847

48+
/**
49+
* The exact option set `json-sanitizer`'s `sanitizeForExport` passes, copied rather than imported
50+
* so this suite keeps testing `credential-extractor` without pulling in its own consumer.
51+
*
52+
* The copy cannot drift unnoticed: `import-export-roundtrip` calls the real `sanitizeForExport`
53+
* and asserts the same withholding, so dropping an option there turns that suite red. Every
54+
* export-shaped assertion below must use this constant — one that quietly omitted
55+
* `redactOpaqueCredentialInputs` would describe a configuration no export surface runs.
56+
*/
57+
const EXPORT_OPTIONS = { preserveEnvVars: true, redactOpaqueCredentialInputs: true } as const
58+
4959
function sanitizedValue(type: string, value: unknown): unknown {
5060
vi.mocked(getBlock).mockReturnValue({
5161
name: 'Test',
5262
description: '',
5363
subBlocks: [{ id: 'field', title: 'Field', type }],
5464
outputs: {},
5565
} as never)
56-
const sanitized = sanitizeForExport(stateWithSubBlock(type, value))
66+
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock(type, value), EXPORT_OPTIONS)
5767
return sanitized.blocks?.b1?.subBlocks?.field?.value
5868
}
5969

@@ -98,19 +108,22 @@ describe('export sanitizer resource coverage', () => {
98108

99109
it('clears tableId by key on a block with no registry config', () => {
100110
vi.mocked(getBlock).mockReturnValue(undefined as never)
101-
const sanitized = sanitizeForExport({
102-
blocks: {
103-
b1: {
104-
id: 'b1',
105-
type: 'unknown-block',
106-
name: 'Test',
107-
position: { x: 0, y: 0 },
108-
subBlocks: { tableId: { id: 'tableId', type: 'short-input', value: 'tbl_abc' } },
109-
outputs: {},
110-
enabled: true,
111+
const sanitized = sanitizeWorkflowForSharing(
112+
{
113+
blocks: {
114+
b1: {
115+
id: 'b1',
116+
type: 'unknown-block',
117+
name: 'Test',
118+
position: { x: 0, y: 0 },
119+
subBlocks: { tableId: { id: 'tableId', type: 'short-input', value: 'tbl_abc' } },
120+
outputs: {},
121+
enabled: true,
122+
},
111123
},
112-
},
113-
} as unknown as Partial<WorkflowState>)
124+
} as unknown as Partial<WorkflowState>,
125+
EXPORT_OPTIONS
126+
)
114127
expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull()
115128
})
116129

@@ -133,10 +146,10 @@ describe('export sanitizer resource coverage', () => {
133146
outputs: {},
134147
} as never)
135148

136-
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), {
137-
preserveEnvVars: true,
138-
redactOpaqueCredentialInputs: true,
139-
})
149+
const sanitized = sanitizeWorkflowForSharing(
150+
stateWithSubBlock('tool-input', value),
151+
EXPORT_OPTIONS
152+
)
140153

141154
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
142155
{
@@ -167,10 +180,10 @@ describe('export sanitizer resource coverage', () => {
167180
outputs: {},
168181
} as never)
169182

170-
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), {
171-
preserveEnvVars: true,
172-
redactOpaqueCredentialInputs: true,
173-
})
183+
const sanitized = sanitizeWorkflowForSharing(
184+
stateWithSubBlock('tool-input', value),
185+
EXPORT_OPTIONS
186+
)
174187

175188
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
176189
{
@@ -182,7 +195,7 @@ describe('export sanitizer resource coverage', () => {
182195
])
183196
})
184197

185-
it('withholds opaque table values from public snapshots', () => {
198+
it('withholds opaque table values from public snapshots and exports', () => {
186199
const value = [
187200
{ Key: 'Authorization', Value: 'Bearer plaintext-secret' },
188201
{ Key: 'API_TOKEN', Value: '{{API_TOKEN}}' },
@@ -194,10 +207,7 @@ describe('export sanitizer resource coverage', () => {
194207
outputs: {},
195208
} as never)
196209

197-
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('table', value), {
198-
preserveEnvVars: true,
199-
redactOpaqueCredentialInputs: true,
200-
})
210+
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('table', value), EXPORT_OPTIONS)
201211

202212
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toBeNull()
203213
})

apps/sim/lib/workflows/credentials/credential-extractor.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,17 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([
8484
])
8585

8686
/**
87-
* Sub-block values whose interior cannot be projected safely for a read-only snapshot API.
87+
* Sub-block values whose interior cannot be projected safely once the payload leaves the
88+
* workspace.
8889
*
8990
* Tables are arbitrary key/value rows used for authorization headers and sandbox environment
90-
* variables. Their cells carry no password metadata, so public snapshots must withhold the whole
91-
* value. Tool inputs are handled separately through the search-replace parameter codecs.
91+
* variables. Their cells carry no password metadata — nothing distinguishes
92+
* `Authorization: Bearer sk-…` from `Content-Type: application/json` — so the whole value is
93+
* withheld. Tool inputs are handled separately through the search-replace parameter codecs.
94+
*
95+
* Which surfaces withhold these values, what that costs, and the shape a future relaxation must
96+
* take are recorded on {@link WorkflowSanitizationOptions.redactOpaqueCredentialInputs}, the flag
97+
* that governs both this set and the tool-input branch.
9298
*/
9399
const OPAQUE_CREDENTIAL_BEARING_TYPES: ReadonlySet<string> = new Set(['table'])
94100

@@ -269,6 +275,26 @@ interface SanitizedWorkflowState {
269275

270276
interface WorkflowSanitizationOptions {
271277
preserveEnvVars?: boolean
278+
/**
279+
* Withhold values whose interior cannot be projected safely once the payload leaves the
280+
* workspace — whole `table` values (see {@link OPAQUE_CREDENTIAL_BEARING_TYPES}) and every
281+
* `tool-input` parameter with no authoritative codec metadata.
282+
*
283+
* Governed surfaces are every caller that passes this flag: the public execution-snapshot
284+
* projection, the pinned deployment-version read, and — since #6591 — workflow export, which
285+
* reaches the in-app Export as JSON button, the folder and multi-select ZIPs, and the v1/v2
286+
* export APIs.
287+
*
288+
* The accepted cost on the export surface is that an export is lossy for tables and does not
289+
* round-trip: non-secret configuration (api `params`, cloudwatch dimensions, response `headers`,
290+
* sts `tags`) is withheld alongside the secrets, and a whole-`{{ENV_VAR}}` reference inside a
291+
* cell is withheld too, unlike the same reference in a `password: true` field. Withholding was
292+
* chosen over per-cell heuristics because the sub-blocks that motivate the loss — every header
293+
* table and the `browser_use`/`stagehand`/`daytona` variable tables — are exactly the ones a
294+
* pasted bearer token lands in, and an export file leaves the trust boundary. Relaxing this
295+
* needs a per-sub-block opt-in that fails closed for tables added later, not a wider default;
296+
* `import-export-roundtrip` pins the current loss so the trade cannot be reversed silently.
297+
*/
272298
redactOpaqueCredentialInputs?: boolean
273299
}
274300

@@ -436,13 +462,3 @@ export function sanitizeCredentials(
436462
): SanitizedWorkflowState {
437463
return sanitizeWorkflowForSharing(state, { preserveEnvVars: false })
438464
}
439-
440-
/**
441-
* Sanitize workflow state for export (preserves env vars)
442-
* Convenience wrapper for workflow export
443-
*/
444-
export function sanitizeForExport(
445-
state: Partial<WorkflowState> | null | undefined
446-
): SanitizedWorkflowState {
447-
return sanitizeWorkflowForSharing(state, { preserveEnvVars: true })
448-
}

apps/sim/lib/workflows/operations/export-workflow.ts

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,6 @@ import {
66
} from '@/lib/workflows/sanitization/json-sanitizer'
77
import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'
88

9-
/**
10-
* Server-only assembly of the public workflow-export payload, shared by the v1
11-
* and v2 export routes so both surfaces emit byte-identical envelopes.
12-
*
13-
* Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits
14-
* the raw state for backup/restore, this runs the payload through
15-
* `sanitizeForExport`, which nulls five classes of sub-block value:
16-
* - `password: true` fields, unless the value is a whole `{{ENV_VAR}}`
17-
* reference, which is preserved so the import resolves it in the target
18-
* workspace;
19-
* - `oauth-input` credentials;
20-
* - sensitive nested `tool-input` params and params without authoritative metadata;
21-
* - opaque credential-bearing values such as arbitrary table cells;
22-
* - **workspace-scoped bindings** — selector fields and id-keyed fields that
23-
* point at rows that do not exist in another workspace, cleared rather than
24-
* carried across as dangling ids.
25-
*
26-
* The last class means an export is **not** a byte-for-byte clone even when
27-
* re-imported into the same workspace: those bindings come back empty and must
28-
* be re-selected. This matches the in-app export.
29-
*
30-
* Workflow **variables** are emitted as stored: they are plaintext workflow
31-
* configuration readable by anyone with workspace read (the same permission the
32-
* export routes require); secrets belong in environment variables, which travel
33-
* as unresolved `{{ENV_VAR}}` references.
34-
*/
35-
369
/** The subset of the workflow record the export payload reads. */
3710
export interface ExportableWorkflowRecord {
3811
id: string
@@ -111,6 +84,31 @@ function toExportedEdge(edge: Edge): WorkflowExportEdge {
11184
* Loads the workflow's normalized state, sanitizes it, and assembles the
11285
* portable export envelope. Returns `null` when the workflow has no persisted
11386
* normalized state (the caller renders its own 404).
87+
*
88+
* Server-only assembly of the public workflow-export payload, shared by the v1
89+
* and v2 export routes so both surfaces emit byte-identical envelopes.
90+
*
91+
* Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits
92+
* the raw state for backup/restore, this runs the payload through
93+
* `sanitizeForExport`, which nulls five classes of sub-block value:
94+
* - `password: true` fields, unless the value is a whole `{{ENV_VAR}}`
95+
* reference, which is preserved so the import resolves it in the target
96+
* workspace;
97+
* - `oauth-input` credentials;
98+
* - sensitive nested `tool-input` params and params without authoritative metadata;
99+
* - opaque credential-bearing values such as arbitrary table cells;
100+
* - **workspace-scoped bindings** — selector fields and id-keyed fields that
101+
* point at rows that do not exist in another workspace, cleared rather than
102+
* carried across as dangling ids.
103+
*
104+
* The last two classes mean an export is **not** a byte-for-byte clone even when
105+
* re-imported into the same workspace: those bindings and every table come back
106+
* empty and must be re-entered. This matches the in-app export.
107+
*
108+
* Workflow **variables** are emitted as stored: they are plaintext workflow
109+
* configuration readable by anyone with workspace read (the same permission the
110+
* export routes require); secrets belong in environment variables, which travel
111+
* as unresolved `{{ENV_VAR}}` references.
114112
*/
115113
export async function buildWorkflowExportPayload(
116114
workflowData: ExportableWorkflowRecord

0 commit comments

Comments
 (0)