GeezerVison is showing github.com — test(export): pin the table and tool-param loss the sanitized export … · simstudioai/sim@2054947
File treeExpand file treeCollapse file tree Open diff view settings admin/folders/[id]/export
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +7-2Lines changed: 7 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | /** | 2 | 2 | * GET /api/v1/admin/folders/[id]/export | 3 | 3 | * | 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. | 5 | 10 | * | 6 | 11 | * Query Parameters: | 7 | | - * - format: 'zip' (default) or 'json' | | 12 | + * - format: 'zip' (default, sanitized) or 'json' (raw) | 8 | 13 | * | 9 | 14 | * Response: | 10 | 15 | * - ZIP file download (Content-Type: application/zip) | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/app/api/v1/workflows/[id]/export/route.ts +4-2Lines changed: 4 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -24,8 +24,10 @@ export const revalidate = 0 | 24 | 24 | * GET /api/v1/workflows/[id]/export | 25 | 25 | * | 26 | 26 | * 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 | 29 | 31 | * {@link buildWorkflowExportPayload}; this route authenticates and renders the | 30 | 32 | * v1 envelope. | 31 | 33 | */ | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/lib/workflows/credentials/credential-extractor.test.ts +37-27Lines changed: 37 additions & 27 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 4 | 4 | import { beforeEach, describe, expect, it, vi } from 'vitest' | 5 | 5 | import { | 6 | 6 | EXPORT_PRESERVED_RESOURCE_TYPES, | 7 | | - sanitizeForExport, | 8 | 7 | sanitizeWorkflowForSharing, | 9 | 8 | } from '@/lib/workflows/credentials/credential-extractor' | 10 | 9 | 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> | 46 | 45 | } as unknown as Partial<WorkflowState> | 47 | 46 | } | 48 | 47 |
| | 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 | + | 49 | 59 | function sanitizedValue(type: string, value: unknown): unknown { | 50 | 60 | vi.mocked(getBlock).mockReturnValue({ | 51 | 61 | name: 'Test', | 52 | 62 | description: '', | 53 | 63 | subBlocks: [{ id: 'field', title: 'Field', type }], | 54 | 64 | outputs: {}, | 55 | 65 | } as never) | 56 | | - const sanitized = sanitizeForExport(stateWithSubBlock(type, value)) | | 66 | + const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock(type, value), EXPORT_OPTIONS) | 57 | 67 | return sanitized.blocks?.b1?.subBlocks?.field?.value | 58 | 68 | } | 59 | 69 |
| @@ -98,19 +108,22 @@ describe('export sanitizer resource coverage', () => { | 98 | 108 |
| 99 | 109 | it('clears tableId by key on a block with no registry config', () => { | 100 | 110 | 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 | + }, | 111 | 123 | }, | 112 | | - }, | 113 | | - } as unknown as Partial<WorkflowState>) | | 124 | + } as unknown as Partial<WorkflowState>, | | 125 | + EXPORT_OPTIONS | | 126 | + ) | 114 | 127 | expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull() | 115 | 128 | }) | 116 | 129 |
| @@ -133,10 +146,10 @@ describe('export sanitizer resource coverage', () => { | 133 | 146 | outputs: {}, | 134 | 147 | } as never) | 135 | 148 |
| 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 | + ) | 140 | 153 |
| 141 | 154 | expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([ | 142 | 155 | { | @@ -167,10 +180,10 @@ describe('export sanitizer resource coverage', () => { | 167 | 180 | outputs: {}, | 168 | 181 | } as never) | 169 | 182 |
| 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 | + ) | 174 | 187 |
| 175 | 188 | expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([ | 176 | 189 | { | @@ -182,7 +195,7 @@ describe('export sanitizer resource coverage', () => { | 182 | 195 | ]) | 183 | 196 | }) | 184 | 197 |
| 185 | | - it('withholds opaque table values from public snapshots', () => { | | 198 | + it('withholds opaque table values from public snapshots and exports', () => { | 186 | 199 | const value = [ | 187 | 200 | { Key: 'Authorization', Value: 'Bearer plaintext-secret' }, | 188 | 201 | { Key: 'API_TOKEN', Value: '{{API_TOKEN}}' }, | @@ -194,10 +207,7 @@ describe('export sanitizer resource coverage', () => { | 194 | 207 | outputs: {}, | 195 | 208 | } as never) | 196 | 209 |
| 197 | | - const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('table', value), { | 198 | | - preserveEnvVars: true, | 199 | | - redactOpaqueCredentialInputs: true, | 200 | | - }) | | 210 | + const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('table', value), EXPORT_OPTIONS) | 201 | 211 |
| 202 | 212 | expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toBeNull() | 203 | 213 | }) | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/lib/workflows/credentials/credential-extractor.ts +29-13Lines changed: 29 additions & 13 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -84,11 +84,17 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([ | 84 | 84 | ]) | 85 | 85 |
| 86 | 86 | /** | 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. | 88 | 89 | * | 89 | 90 | * 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. | 92 | 98 | */ | 93 | 99 | const OPAQUE_CREDENTIAL_BEARING_TYPES: ReadonlySet<string> = new Set(['table']) | 94 | 100 |
| @@ -269,6 +275,26 @@ interface SanitizedWorkflowState { | 269 | 275 |
| 270 | 276 | interface WorkflowSanitizationOptions { | 271 | 277 | 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 | + */ | 272 | 298 | redactOpaqueCredentialInputs?: boolean | 273 | 299 | } | 274 | 300 |
| @@ -436,13 +462,3 @@ export function sanitizeCredentials( | 436 | 462 | ): SanitizedWorkflowState { | 437 | 463 | return sanitizeWorkflowForSharing(state, { preserveEnvVars: false }) | 438 | 464 | } | 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 | | -} | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/lib/workflows/operations/export-workflow.ts +25-27Lines changed: 25 additions & 27 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -6,33 +6,6 @@ import { | 6 | 6 | } from '@/lib/workflows/sanitization/json-sanitizer' | 7 | 7 | import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' | 8 | 8 |
| 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 | | - | 36 | 9 | /** The subset of the workflow record the export payload reads. */ | 37 | 10 | export interface ExportableWorkflowRecord { | 38 | 11 | id: string | @@ -111,6 +84,31 @@ function toExportedEdge(edge: Edge): WorkflowExportEdge { | 111 | 84 | * Loads the workflow's normalized state, sanitizes it, and assembles the | 112 | 85 | * portable export envelope. Returns `null` when the workflow has no persisted | 113 | 86 | * 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. | 114 | 112 | */ | 115 | 113 | export async function buildWorkflowExportPayload( | 116 | 114 | workflowData: ExportableWorkflowRecord | |
|