Skip to content

Commit 586f210

Browse files
committed
fix(secrets): preserve own environment keys
1 parent 3d13e88 commit 586f210

8 files changed

Lines changed: 88 additions & 11 deletions

File tree

apps/sim/app/api/function/execute/route.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,26 @@ describe('Function Execute API Route', () => {
935935
expect((await response.json()).__resolvedSecretNames).toEqual(['ALLOWED'])
936936
})
937937

938+
it('resolves a selected __proto__ secret as an own environment key', async () => {
939+
const response = await POST(
940+
createMockRequest(
941+
'POST',
942+
{
943+
code: 'return "{{__proto__}}"',
944+
envVars: Object.fromEntries([['__proto__', 'secret-value']]),
945+
secretScope: 'selected',
946+
mountedSecrets: ['__proto__'],
947+
},
948+
{
949+
'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1',
950+
}
951+
)
952+
)
953+
954+
expect(response.status).toBe(200)
955+
expect((await response.json()).__resolvedSecretNames).toEqual(['__proto__'])
956+
})
957+
938958
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
939959
const req = createMockRequest('POST', {
940960
code: 'return <email>',

apps/sim/app/api/function/execute/route.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
writeWorkspaceFileByPath,
1818
} from '@/lib/copilot/vfs/resource-writer'
1919
import { isRemoteSandboxEnabled } from '@/lib/core/config/env-flags'
20+
import { setRecordValue } from '@/lib/core/utils/records'
2021
import { generateRequestId } from '@/lib/core/utils/request'
2122
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2223
import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm'
@@ -580,7 +581,7 @@ function scopeEnvironmentVariables(
580581
const scoped: Record<string, string> = {}
581582
const missing: string[] = []
582583
for (const name of allowed) {
583-
if (name in envVars) scoped[name] = envVars[name]
584+
if (Object.hasOwn(envVars, name)) setRecordValue(scoped, name, envVars[name])
584585
else missing.push(name)
585586
}
586587
if (missing.length > 0) {
@@ -608,19 +609,19 @@ function resolveEnvironmentVariables(
608609
const resolverVars: Record<string, string> = {}
609610
Object.entries(params).forEach(([key, value]) => {
610611
if (value !== undefined && value !== null) {
611-
resolverVars[key] = String(value)
612+
setRecordValue(resolverVars, key, String(value))
612613
}
613614
})
614615
Object.entries(envVars).forEach(([key, value]) => {
615616
if (value !== undefined && value !== null) {
616-
resolverVars[key] = value
617+
setRecordValue(resolverVars, key, value)
617618
}
618619
})
619620

620621
while ((match = regex.exec(code)) !== null) {
621622
const varName = match[1].trim()
622623

623-
if (!(varName in resolverVars)) {
624+
if (!Object.hasOwn(resolverVars, varName)) {
624625
continue
625626
}
626627

apps/sim/lib/api/contracts/hotspots.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { z } from 'zod'
2-
import { customPatternSchema, unknownRecordSchema } from '@/lib/api/contracts/primitives'
2+
import {
3+
customPatternSchema,
4+
stringRecordSchema,
5+
unknownRecordSchema,
6+
} from '@/lib/api/contracts/primitives'
37
import { defineRouteContract } from '@/lib/api/contracts/types'
48
import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
59
export const guardrailsValidateContract = defineRouteContract({
@@ -175,9 +179,9 @@ export const functionExecuteContract = defineRouteContract({
175179
})
176180
.strict()
177181
.optional(),
178-
envVars: z.record(z.string(), z.string()).optional().default({}),
182+
envVars: stringRecordSchema.optional().default({}),
179183
blockData: unknownRecordSchema.optional().default({}),
180-
blockNameMapping: z.record(z.string(), z.string()).optional().default({}),
184+
blockNameMapping: stringRecordSchema.optional().default({}),
181185
blockOutputSchemas: z.record(z.string(), unknownRecordSchema).optional().default({}),
182186
workflowVariables: unknownRecordSchema.optional().default({}),
183187
contextVariables: unknownRecordSchema.optional().default({}),

apps/sim/lib/api/contracts/primitives.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
1+
import { isPlainRecord } from '@sim/utils/object'
12
import { z } from 'zod'
3+
import { setRecordValue } from '@/lib/core/utils/records'
24
import { PII_LANGUAGE_CODES, stripNerEntities } from '@/lib/guardrails/pii-entities'
35
import { validateRegexPattern } from '@/lib/guardrails/validate_regex'
46

57
export const unknownRecordSchema = z.record(z.string(), z.unknown())
68

9+
export const stringRecordSchema = z
10+
.custom<Record<string, string>>(
11+
(value) =>
12+
isPlainRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'),
13+
{ error: 'Expected a record of string values' }
14+
)
15+
.transform((value) => {
16+
const record: Record<string, string> = {}
17+
for (const [key, entry] of Object.entries(value)) {
18+
setRecordValue(record, key, entry)
19+
}
20+
return record
21+
})
22+
723
export function flattenFieldErrors<TFields extends string>(
824
error: z.ZodError
925
): Partial<Record<TFields, string>> {

apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ describe('materializeCopilotCodeSecrets', () => {
112112
})
113113
})
114114

115+
it('mounts an own __proto__ secret as data without mutating record prototypes', async () => {
116+
queueSources({ personal: Object.fromEntries([['__proto__', 'personal-cipher']]) })
117+
118+
const result = await materializeCopilotCodeSecrets({
119+
actorUserId: 'user-1',
120+
workspaceId: 'workspace-1',
121+
requestedNames: ['__proto__'],
122+
})
123+
124+
expect(Object.hasOwn(result.envVars, '__proto__')).toBe(true)
125+
expect(result.envVars.__proto__).toBe('plain:personal-cipher')
126+
expect(Object.getPrototypeOf(result.envVars)).toBe(Object.prototype)
127+
})
128+
115129
it('casts stored JSON values before using JSONB operators', async () => {
116130
queueSources({ personal: { API_KEY: 'personal-cipher' } })
117131

apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
MAX_SECRET_MOUNT_NAMES,
88
} from '@/lib/copilot/secret-mount-policy'
99
import { decryptSecret } from '@/lib/core/security/encryption'
10+
import { setRecordValue } from '@/lib/core/utils/records'
1011
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
1112
import type { ResolvedSecretTraceCatalogEntry } from '@/executor/utils/resolved-secret-trace-registry'
1213

@@ -70,7 +71,7 @@ function encryptedVariables(row: { variables: unknown } | undefined): Record<str
7071
return {}
7172
const result: Record<string, string> = {}
7273
for (const [name, value] of Object.entries(row.variables)) {
73-
if (typeof value === 'string') result[name] = value
74+
if (typeof value === 'string') setRecordValue(result, name, value)
7475
}
7576
return result
7677
}

apps/sim/lib/core/utils/records.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ describe('record normalization utilities', () => {
2929
expect(normalizeStringRecord([])).toEqual({})
3030
})
3131

32+
it('preserves own __proto__ keys without changing the record prototype', () => {
33+
const normalized = normalizeStringRecord(Object.fromEntries([['__proto__', 'secret-value']]))
34+
35+
expect(Object.hasOwn(normalized, '__proto__')).toBe(true)
36+
expect(normalized.__proto__).toBe('secret-value')
37+
expect(Object.getPrototypeOf(normalized)).toBe(Object.prototype)
38+
})
39+
3240
it('normalizes record maps by dropping malformed entries', () => {
3341
expect(
3442
normalizeRecordMap({

apps/sim/lib/core/utils/records.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import { isPlainRecord } from '@sim/utils/object'
33
export type UnknownRecord = Record<string, unknown>
44
export type StringRecord = Record<string, string>
55

6+
export function setRecordValue(record: Record<string, unknown>, key: string, value: unknown): void {
7+
Object.defineProperty(record, key, {
8+
value,
9+
enumerable: true,
10+
configurable: true,
11+
writable: true,
12+
})
13+
}
14+
615
/**
716
* Normalizes optional execution context maps to the record shape expected by
817
* internal API contracts.
@@ -25,7 +34,11 @@ export function normalizeStringRecord(value: unknown): StringRecord {
2534
if (entryValue === undefined || entryValue === null) {
2635
continue
2736
}
28-
normalized[key] = typeof entryValue === 'string' ? entryValue : String(entryValue)
37+
setRecordValue(
38+
normalized,
39+
key,
40+
typeof entryValue === 'string' ? entryValue : String(entryValue)
41+
)
2942
}
3043
return normalized
3144
}
@@ -41,7 +54,7 @@ export function normalizeRecordMap(value: unknown): Record<string, UnknownRecord
4154
const normalized: Record<string, UnknownRecord> = {}
4255
for (const [key, entryValue] of Object.entries(value)) {
4356
if (isPlainRecord(entryValue)) {
44-
normalized[key] = entryValue
57+
setRecordValue(normalized, key, entryValue)
4558
}
4659
}
4760
return normalized
@@ -72,7 +85,7 @@ export function normalizeWorkflowVariables(value: unknown): UnknownRecord {
7285
const key = id ?? name
7386

7487
if (key) {
75-
normalized[key] = variable
88+
setRecordValue(normalized, key, variable)
7689
}
7790
}
7891

0 commit comments

Comments
 (0)