Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
587d3a3
fix(api): tell a missing key owner from an owner whose ban state is null
waleedlatif1 Nov 11, 2026
5e07f86
fix(credentials): report a refused connection host as host_not_allowed
waleedlatif1 Nov 11, 2026
ba17d53
fix(credentials): report an oversized Databricks token success as a 502
waleedlatif1 Nov 11, 2026
b5206f6
docs(api): say v2 execute options need a full-access key or api:write
waleedlatif1 Nov 11, 2026
894ab56
chore(scripts): scope the payload-param allowlist to internal operations
waleedlatif1 Nov 11, 2026
cc7816b
fix(redaction): keep Infisical secret names visible; redact only depl…
waleedlatif1 Nov 10, 2026
d3d4d9b
docs(networking): the SSRF guard matches NO_PROXY like curl, not like…
waleedlatif1 Nov 11, 2026
49ef21a
fix(api-keys): never echo an internal key-creation failure to the caller
waleedlatif1 Nov 11, 2026
f0c91fa
chore: log a failed route-cache clear and spell out the read-only key…
waleedlatif1 Nov 11, 2026
03e0b90
fix(credentials): release saved secrets only to internal-JWT callers
waleedlatif1 Nov 11, 2026
65dec86
fix(firecrawl): treat a blank maxDiscoveryDepth as unset
waleedlatif1 Nov 11, 2026
8369595
chore(credentials): log a broken JWKS and keep the successor map out …
waleedlatif1 Nov 11, 2026
9fcf4a2
test: drop or tighten assertions that could not fail
waleedlatif1 Nov 11, 2026
d45b05d
fix(security): ignore a malformed CIDR NO_PROXY entry
waleedlatif1 Nov 11, 2026
e1b532e
chore(workflows): scope the paused-row read to its workflow and reuse…
waleedlatif1 Nov 11, 2026
eb276fb
fix(credentials): never name a sibling credential in a trust-setup re…
waleedlatif1 Nov 11, 2026
3d3a9c9
docs: correct the Helm readOnlyRootFilesystem note and the deployed-r…
waleedlatif1 Nov 11, 2026
36450c5
test(credentials): rely on the shared env reset in the proxy validato…
waleedlatif1 Nov 11, 2026
5a84548
chore(helm): bump the chart version for the README correction
waleedlatif1 Nov 11, 2026
397444b
fix(redaction): keep prefixed secret keys masked while leaving Infisi…
waleedlatif1 Nov 11, 2026
17cf2df
fix(release): keep bare secretKey redacted, leave the proxy every-add…
waleedlatif1 Nov 11, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ Most `/api` requests also pass through Next.js Proxy. The current app configurat

The app makes outbound calls to model providers, integration APIs, your email provider, object storage, and your telemetry backend. Whether `HTTP_PROXY` / `HTTPS_PROXY` apply depends on which of those paths a call takes — there is no single answer, and no global setting that covers all of them.

The server runs on Bun, and Bun's native `fetch` honors `$HTTP_PROXY`, `$HTTPS_PROXY`, and `$NO_PROXY`. Sim installs no global dispatcher. Default `fetch` uses the configured proxy unless `NO_PROXY` excludes the destination. The SSRF guard reads the same three variables, with the same rules, for every request it sends. The rest either build their own HTTP agent or speak a non-HTTP protocol.
The server runs on Bun, and Bun's native `fetch` honors `$HTTP_PROXY`, `$HTTPS_PROXY`, and `$NO_PROXY`. Sim installs no global dispatcher. Default `fetch` uses the configured proxy unless `NO_PROXY` excludes the destination. The SSRF guard reads the same three variables for every request it sends, matching `NO_PROXY` the way curl does (see [Internal destinations and `NO_PROXY`](#internal-destinations-and-no_proxy)). The rest either build their own HTTP agent or speak a non-HTTP protocol.

This depends on the runtime. The published images run Bun. If you build the standalone output and run it under Node instead, Node ignores these variables unless started with `NODE_USE_ENV_PROXY=1` (Node 22.21+ / 24.5+), and the `fetch`-based "Yes" rows stop being proxied — model providers, Resend, Gmail sending, the desktop update feed, and the telemetry relay. The Azure Blob, GCS and Azure Communication Services rows still hold: those SDKs read the proxy variables through their own agents rather than through `fetch`. See [Bun fetch](https://bun.sh/docs/runtime/networking/fetch) and [Node proxy configuration](https://nodejs.org/learn/http/enterprise-network-configuration).

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -4986,7 +4986,7 @@
"v2Enabled": true,
"keyType": "personal",
"expiresAt": null,
"scopes": ["api:read", "api:write"]
"scopes": ["api:read", "api:write", "search:read"]
}
}
}
Expand Down Expand Up @@ -18677,7 +18677,7 @@
"v2Enabled": true,
"keyType": "personal",
"expiresAt": null,
"scopes": ["api:read", "api:write"]
"scopes": ["api:read", "api:write", "search:read"]
}
}
]
Expand Down
6 changes: 3 additions & 3 deletions apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -14928,7 +14928,7 @@
"description": "Execute a deployed workflow version."
},
"version": {
"description": "Deployment version to execute instead of the active one. Requires an API key or OAuth access token with workspace write access, and the workflow must be deployed. An unknown version returns `404`.",
"description": "Deployment version to execute instead of the active one. Requires a full-access API key or an OAuth access token with `api:write`, plus workspace write access, and the workflow must be deployed. An unknown version returns `404`.",
"type": "integer",
"minimum": 1,
"maximum": 2147483647
Expand Down Expand Up @@ -15006,7 +15006,7 @@
},
"async": {
"default": false,
"description": "Queue the run and return a 202 receipt. Requires an API key or OAuth access token and a deployed run source. Cannot stream, select outputs, enable thinking/tool events, or include file-inlining options (`includeFileBase64` or `base64MaxBytes`).",
"description": "Queue the run and return a 202 receipt. Requires a full-access API key or an OAuth access token with `api:write`, and a deployed run source. Cannot stream, select outputs, enable thinking/tool events, or include file-inlining options (`includeFileBase64` or `base64MaxBytes`).",
"type": "boolean"
},
"executionTimeoutSeconds": {
Expand All @@ -15016,7 +15016,7 @@
"maximum": 604800
},
"waitTimeoutSeconds": {
"description": "Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires an API key or OAuth access token. Cannot be combined with `async` or `stream`.",
"description": "Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires a full-access API key or an OAuth access token with `api:write`. Cannot be combined with `async` or `stream`.",
"type": "integer",
"minimum": 0,
"maximum": 604800
Expand Down
54 changes: 54 additions & 0 deletions apps/sim/lib/api-key/orchestration/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { createWorkspaceKey } = vi.hoisted(() => ({ createWorkspaceKey: vi.fn() }))
vi.mock('@/lib/api-key/auth', () => ({
createApiKey: vi.fn(),
createWorkspaceApiKey: createWorkspaceKey,
}))

import {
performCreatePersonalApiKey,
performCreateWorkspaceApiKey,
} from '@/lib/api-key/orchestration'

const INTERNAL_DETAIL = 'connect ECONNREFUSED 10.0.0.5:5432 for role april'

describe('API key creation failures', () => {
beforeEach(() => {
resetDbChainMock()
createWorkspaceKey.mockReset()
})

it('never echoes an internal personal-key failure to the caller', async () => {
dbChainMockFns.limit.mockRejectedValueOnce(new Error(INTERNAL_DETAIL))

const result = await performCreatePersonalApiKey({ userId: 'user-1', name: 'CLI' })

expect(result).toMatchObject({ success: false, errorCode: 'internal' })
expect(result.error).not.toContain('ECONNREFUSED')
})

it('never echoes an internal workspace-key failure, but still names a conflict', async () => {
createWorkspaceKey.mockRejectedValueOnce(new Error(INTERNAL_DETAIL))
const internal = await performCreateWorkspaceApiKey({
workspaceId: 'ws-1',
userId: 'user-1',
name: 'CLI',
})
expect(internal).toMatchObject({ success: false, errorCode: 'internal' })
expect(internal.error).not.toContain('ECONNREFUSED')

createWorkspaceKey.mockRejectedValueOnce(new Error('A key named "CLI" already exists'))
const conflict = await performCreateWorkspaceApiKey({
workspaceId: 'ws-1',
userId: 'user-1',
name: 'CLI',
})
expect(conflict).toEqual({
success: false,
errorCode: 'conflict',
error: 'A key named "CLI" already exists',
})
})
})
11 changes: 6 additions & 5 deletions apps/sim/lib/api-key/orchestration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ export async function performCreatePersonalApiKey(
return { success: true, key: { ...created, key: plainKey } }
} catch (error) {
logger.error('Failed to create personal API key', { error })
return { success: false, errorCode: 'internal', error: toError(error).message }
// The CLI poll route is unauthenticated, so an internal failure never echoes its cause.
return { success: false, errorCode: 'internal', error: 'Failed to create API key' }
}
}

Expand Down Expand Up @@ -266,10 +267,10 @@ export async function performCreateWorkspaceApiKey(
} catch (error) {
const message = toError(error).message
logger.error('Failed to create workspace API key', { error })
return {
success: false,
error: message,
errorCode: message.includes('already exists') ? 'conflict' : 'internal',
if (message.includes('already exists')) {
return { success: false, error: message, errorCode: 'conflict' }
}
// The CLI poll route is unauthenticated, so an internal failure never echoes its cause.
return { success: false, error: 'Failed to create API key', errorCode: 'internal' }
}
}
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/v2/openapi/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1939,7 +1939,7 @@ const declaredRoutes = [
v2Enabled: true,
keyType: 'personal',
expiresAt: null,
scopes: ['api:read', 'api:write'],
scopes: ['api:read', 'api:write', 'search:read'],
},
},
]
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/api/contracts/v2/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,7 +1508,7 @@ export const v2WorkflowRunSelectionSchema = z.discriminatedUnion('source', [
version: versionNumberSchema
.optional()
.describe(
'Deployment version to execute instead of the active one. Requires an API key or OAuth access token with workspace write access, and the workflow must be deployed. An unknown version returns `404`.'
'Deployment version to execute instead of the active one. Requires a full-access API key or an OAuth access token with `api:write`, plus workspace write access, and the workflow must be deployed. An unknown version returns `404`.'
),
})
.strict(),
Expand Down Expand Up @@ -1599,7 +1599,7 @@ export const v2ExecuteWorkflowBodySchema = z
.optional()
.default(false)
.describe(
'Queue the run and return a 202 receipt. Requires an API key or OAuth access token and a deployed run source. Cannot stream, select outputs, enable thinking/tool events, or include file-inlining options (`includeFileBase64` or `base64MaxBytes`).'
'Queue the run and return a 202 receipt. Requires a full-access API key or an OAuth access token with `api:write`, and a deployed run source. Cannot stream, select outputs, enable thinking/tool events, or include file-inlining options (`includeFileBase64` or `base64MaxBytes`).'
),
/**
* An upper bound on the request, not the effective timeout: the server
Expand Down Expand Up @@ -1628,7 +1628,7 @@ export const v2ExecuteWorkflowBodySchema = z
.max(MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS)
.optional()
.describe(
'Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires an API key or OAuth access token. Cannot be combined with `async` or `stream`.'
'Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires a full-access API key or an OAuth access token with `api:write`. Cannot be combined with `async` or `stream`.'
),
stream: z
.boolean()
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,26 @@ describe('v2 API key authentication', () => {
)
})

it('accepts a personal key whose owner has never had a ban state recorded', async () => {
queueTableRows(schemaMock.apiKey, [
{
id: 'key-1',
userId: 'user-1',
workspaceId: null,
type: 'personal',
access: 'write',
expiresAt: null,
ownerUserId: 'user-1',
userBanned: null,
userSuspendedAt: null,
},
])

const result = await authenticateV2ApiKey({ apiKey: 'secret', bearer: null })

expect(result.principal).toEqual(createPersonalApiKeyPrincipal())
})

it('refuses a personal key whose owner row is gone as invalid, expired or not', async () => {
for (const expiresAt of [null, new Date(Date.now() - 1)]) {
queueTableRows(schemaMock.apiKey, [
Expand All @@ -209,6 +229,7 @@ describe('v2 API key authentication', () => {
type: 'personal',
access: 'write',
expiresAt,
ownerUserId: null,
userBanned: null,
userSuspendedAt: null,
},
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/lib/api/server/routes/v2-api-key-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,16 @@ interface ApiKeyRow {
type: string
access: ApiKeyAccess
expiresAt: Date | null
/** The joined owner's id: `null` only when the owner row is gone (`user.banned` is itself nullable). */
ownerUserId: string | null
userBanned: boolean | null
userSuspendedAt: Date | null
}

function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow {
if (!row) throw new V2ApiKeyUnauthenticatedError()
if (row.type === 'personal' && row.workspaceId === null) {
if (row.userBanned === null) {
if (row.ownerUserId === null) {
logger.error('Personal API key is missing its credential owner', { keyId: row.id })
throw new V2ApiKeyUnauthenticatedError()
}
Expand Down Expand Up @@ -118,6 +120,7 @@ async function authenticateApiKey(apiKeyHeader: string): Promise<V2ApiKeyAuthCon
type: apiKey.type,
access: apiKey.access,
expiresAt: apiKey.expiresAt,
ownerUserId: user.id,
userBanned: user.banned,
userSuspendedAt: user.suspendedAt,
})
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const FEATURE_FLAGS = {
},
'api-key-read-only': {
description:
'Allow creating read-only API keys. Turn on only after every pod runs a release that enforces key access, since older pods treat a read-only key as full access. Before rolling back below the release that added read-only keys, turn this off first: that older code also serves every existing read-only key as full access. Enforcement of existing read-only keys never depends on this flag. Global; disabled by default.',
'Allow creating read-only API keys. Turn on only after every pod runs a release that enforces key access, since older pods treat a read-only key as full access. Before rolling back below the release that added read-only keys, turn this off and revoke or replace every existing read-only key: turning the flag off only stops new ones, and that older code serves existing read-only keys as full access. Enforcement of existing read-only keys never depends on this flag. Global; disabled by default.',
fallback: 'API_KEY_READ_ONLY',
},
'mothership-model-selector': {
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/core/network/environment-proxy.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,12 @@ describe('proxy selection from the environment', () => {
target: 'https://11.0.0.1/',
proxy: 'http://p:3128/',
},
{
case: 'a malformed CIDR NO_PROXY entry is ignored rather than read as its first prefix',
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '10.0.0.0/8/junk' },
target: 'https://10.20.30.40/',
proxy: 'http://p:3128/',
},
{
case: 'an IPv6 CIDR NO_PROXY entry excludes addresses inside the range',
env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: 'fd00::/8' },
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/lib/core/network/transport.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { OutboundRoutingError } from '@/lib/core/network/routing'
import type { EgressProfile } from '@/lib/core/security/egress/profiles'
import { validateEgressUrl } from '@/lib/core/security/egress/validate'

/** The egress policy refused a destination the transport was asked to reach through a proxy. */
export class EgressRefusedError extends Error {
constructor(message: string) {
super(message)
this.name = 'EgressRefusedError'
}
}

interface OutboundTransportOptions {
profile: EgressProfile
resolvedIP?: string
Expand Down Expand Up @@ -58,7 +66,10 @@ export function createOutboundTransport(options: OutboundTransportOptions): Outb
signal,
requireEveryAddress: true,
})
if (!validation.isValid) throw new Error(validation.error, { cause: validation.cause })
if (!validation.isValid) {
if (validation.refusal === 'policy') throw new EgressRefusedError(validation.error)
throw new Error(validation.error, { cause: validation.cause })
}
if (closed) throw new OutboundRoutingError('GATEWAY_UNAVAILABLE')
let agent = environmentProxies.get(proxyUrl)
if (!agent) {
Expand Down
25 changes: 21 additions & 4 deletions apps/sim/lib/core/security/egress/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,13 @@ export interface EgressValidationSuccess {
readonly originalHostname: string
}

/** Why a destination failed: its name could not be resolved, or the policy refused it. */
export type EgressRefusal = 'dns' | 'policy'

export interface EgressValidationFailure {
readonly isValid: false
readonly error: string
readonly refusal: EgressRefusal
/** Retains resolver failure codes so callers can distinguish an outage from a policy denial. */
readonly cause?: unknown
}
Expand Down Expand Up @@ -67,7 +71,11 @@ function fail(
? { profile, reason: decision.reason, paramName }
: { profile, reason: decision.reason, detail: decision.detail, paramName }
)
return { isValid: false, error: describeEgressDenial(decision, paramName, profile) }
return {
isValid: false,
error: describeEgressDenial(decision, paramName, profile),
refusal: 'policy',
}
}

/**
Expand All @@ -85,14 +93,18 @@ export async function validateEgressUrl(
): Promise<EgressValidationResult> {
options.signal?.throwIfAborted()
if (!url || typeof url !== 'string') {
return { isValid: false, error: `${paramName} is required and must be a string` }
return {
isValid: false,
error: `${paramName} is required and must be a string`,
refusal: 'policy',
}
}

let parsed: URL
try {
parsed = new URL(url)
} catch {
return { isValid: false, error: `${paramName} must be a valid URL` }
return { isValid: false, error: `${paramName} must be a valid URL`, refusal: 'policy' }
}

const policy = resolveEgressPolicy(profile)
Expand Down Expand Up @@ -129,7 +141,12 @@ export async function validateEgressUrl(
? { profile, paramName }
: { profile, paramName, host, error: toError(error).message }
)
return { isValid: false, error: `${paramName} hostname could not be resolved`, cause: error }
return {
isValid: false,
error: `${paramName} hostname could not be resolved`,
cause: error,
refusal: 'dns',
}
}

// Refused records are filtered rather than failing the whole host: pinning to a
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/core/security/input-validation.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('@sim/security/dns', () => ({
addresses.find((address) => address.includes('.')) ?? addresses[0],
}))

import { EgressRefusedError } from '@/lib/core/network/transport.server'
import {
secureFetchWithValidation,
validateUrlWithDNS,
Expand Down Expand Up @@ -122,6 +123,13 @@ describe('validateUrlWithDNS address classification', () => {
).rejects.toMatchObject({ message: 'url hostname could not be resolved', cause })
})

it('throws a policy refusal as an egress refusal, distinct from a lookup failure', async () => {
mockResolve.mockResolvedValue(resolved(['10.0.0.5']))
await expect(
secureFetchWithValidation('https://internal.example/api', { profile: 'configuredEndpoint' })
).rejects.toBeInstanceOf(EgressRefusedError)
})

it('can conceal credential-derived host details in validation logs', async () => {
mockResolve.mockRejectedValue(new Error('DNS failure with credential-host-canary'))

Expand Down
Loading
Loading