Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/sim/app/api/v1/admin/folders/[id]/export/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
/**
* GET /api/v1/admin/folders/[id]/export
*
* Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
* Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON.
*
* The two formats are NOT equivalent. `json` emits the raw stored state for admin
* backup/restore. `zip` runs every workflow through `sanitizeForExport`, which withholds
* credentials and several other classes of sub-block value, so a ZIP does not restore
* faithfully — use `json` when the export has to.
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
* - format: 'zip' (default, sanitized) or 'json' (raw)
*
* Response:
* - ZIP file download (Content-Type: application/zip)
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/app/api/v1/workflows/[id]/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ export const revalidate = 0
* GET /api/v1/workflows/[id]/export
*
* Exports a workflow as a portable JSON envelope that
* `POST /api/v1/workflows/import` accepts verbatim. Payload assembly and the
* sanitization guarantees are documented on the shared
* `POST /api/v1/workflows/import` accepts without further editing. It is not a
* byte-for-byte clone: the envelope is secret-sanitized, so several classes of
* sub-block value import as empty and must be re-entered. Payload assembly and
* the authoritative list of what is withheld are documented on the shared
* {@link buildWorkflowExportPayload}; this route authenticates and renders the
* v1 envelope.
*/
Expand Down
64 changes: 37 additions & 27 deletions apps/sim/lib/workflows/credentials/credential-extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EXPORT_PRESERVED_RESOURCE_TYPES,
sanitizeForExport,
sanitizeWorkflowForSharing,
} from '@/lib/workflows/credentials/credential-extractor'
import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry'
Expand Down Expand Up @@ -46,14 +45,25 @@ function stateWithSubBlock(type: string, value: unknown): Partial<WorkflowState>
} as unknown as Partial<WorkflowState>
}

/**
* The exact option set `json-sanitizer`'s `sanitizeForExport` passes, copied rather than imported
* so this suite keeps testing `credential-extractor` without pulling in its own consumer.
*
* The copy cannot drift unnoticed: `import-export-roundtrip` calls the real `sanitizeForExport`
* and asserts the same withholding, so dropping an option there turns that suite red. Every
* export-shaped assertion below must use this constant — one that quietly omitted
* `redactOpaqueCredentialInputs` would describe a configuration no export surface runs.
*/
const EXPORT_OPTIONS = { preserveEnvVars: true, redactOpaqueCredentialInputs: true } as const

function sanitizedValue(type: string, value: unknown): unknown {
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type }],
outputs: {},
} as never)
const sanitized = sanitizeForExport(stateWithSubBlock(type, value))
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock(type, value), EXPORT_OPTIONS)
return sanitized.blocks?.b1?.subBlocks?.field?.value
}

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

it('clears tableId by key on a block with no registry config', () => {
vi.mocked(getBlock).mockReturnValue(undefined as never)
const sanitized = sanitizeForExport({
blocks: {
b1: {
id: 'b1',
type: 'unknown-block',
name: 'Test',
position: { x: 0, y: 0 },
subBlocks: { tableId: { id: 'tableId', type: 'short-input', value: 'tbl_abc' } },
outputs: {},
enabled: true,
const sanitized = sanitizeWorkflowForSharing(
{
blocks: {
b1: {
id: 'b1',
type: 'unknown-block',
name: 'Test',
position: { x: 0, y: 0 },
subBlocks: { tableId: { id: 'tableId', type: 'short-input', value: 'tbl_abc' } },
outputs: {},
enabled: true,
},
},
},
} as unknown as Partial<WorkflowState>)
} as unknown as Partial<WorkflowState>,
EXPORT_OPTIONS
)
expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull()
})

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

const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
const sanitized = sanitizeWorkflowForSharing(
stateWithSubBlock('tool-input', value),
EXPORT_OPTIONS
)

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

const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
const sanitized = sanitizeWorkflowForSharing(
stateWithSubBlock('tool-input', value),
EXPORT_OPTIONS
)

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

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

const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('table', value), {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('table', value), EXPORT_OPTIONS)

expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toBeNull()
})
Expand Down
42 changes: 29 additions & 13 deletions apps/sim/lib/workflows/credentials/credential-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,17 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([
])

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

Expand Down Expand Up @@ -269,6 +275,26 @@ interface SanitizedWorkflowState {

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

Expand Down Expand Up @@ -436,13 +462,3 @@ export function sanitizeCredentials(
): SanitizedWorkflowState {
return sanitizeWorkflowForSharing(state, { preserveEnvVars: false })
}

/**
* Sanitize workflow state for export (preserves env vars)
* Convenience wrapper for workflow export
*/
export function sanitizeForExport(
state: Partial<WorkflowState> | null | undefined
): SanitizedWorkflowState {
return sanitizeWorkflowForSharing(state, { preserveEnvVars: true })
}
52 changes: 25 additions & 27 deletions apps/sim/lib/workflows/operations/export-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,6 @@ import {
} from '@/lib/workflows/sanitization/json-sanitizer'
import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'

/**
* Server-only assembly of the public workflow-export payload, shared by the v1
* and v2 export routes so both surfaces emit byte-identical envelopes.
*
* Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits
* the raw state for backup/restore, this runs the payload through
* `sanitizeForExport`, which nulls five classes of sub-block value:
* - `password: true` fields, unless the value is a whole `{{ENV_VAR}}`
* reference, which is preserved so the import resolves it in the target
* workspace;
* - `oauth-input` credentials;
* - sensitive nested `tool-input` params and params without authoritative metadata;
* - opaque credential-bearing values such as arbitrary table cells;
* - **workspace-scoped bindings** — selector fields and id-keyed fields that
* point at rows that do not exist in another workspace, cleared rather than
* carried across as dangling ids.
*
* The last class means an export is **not** a byte-for-byte clone even when
* re-imported into the same workspace: those bindings come back empty and must
* be re-selected. This matches the in-app export.
*
* Workflow **variables** are emitted as stored: they are plaintext workflow
* configuration readable by anyone with workspace read (the same permission the
* export routes require); secrets belong in environment variables, which travel
* as unresolved `{{ENV_VAR}}` references.
*/

/** The subset of the workflow record the export payload reads. */
export interface ExportableWorkflowRecord {
id: string
Expand Down Expand Up @@ -111,6 +84,31 @@ function toExportedEdge(edge: Edge): WorkflowExportEdge {
* Loads the workflow's normalized state, sanitizes it, and assembles the
* portable export envelope. Returns `null` when the workflow has no persisted
* normalized state (the caller renders its own 404).
*
* Server-only assembly of the public workflow-export payload, shared by the v1
* and v2 export routes so both surfaces emit byte-identical envelopes.
*
* Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits
* the raw state for backup/restore, this runs the payload through
* `sanitizeForExport`, which nulls five classes of sub-block value:
* - `password: true` fields, unless the value is a whole `{{ENV_VAR}}`
* reference, which is preserved so the import resolves it in the target
* workspace;
* - `oauth-input` credentials;
* - sensitive nested `tool-input` params and params without authoritative metadata;
* - opaque credential-bearing values such as arbitrary table cells;
* - **workspace-scoped bindings** — selector fields and id-keyed fields that
* point at rows that do not exist in another workspace, cleared rather than
* carried across as dangling ids.
*
* The last two classes mean an export is **not** a byte-for-byte clone even when
* re-imported into the same workspace: those bindings and every table come back
* empty and must be re-entered. This matches the in-app export.
*
* Workflow **variables** are emitted as stored: they are plaintext workflow
* configuration readable by anyone with workspace read (the same permission the
* export routes require); secrets belong in environment variables, which travel
* as unresolved `{{ENV_VAR}}` references.
*/
export async function buildWorkflowExportPayload(
workflowData: ExportableWorkflowRecord
Expand Down
Loading
Loading