Skip to content

Commit 1b1639c

Browse files
authored
fix(release): host_not_allowed for refused hosts, internal-JWT-only secret release, and final release-audit cleanups (#9870)
* fix(api): tell a missing key owner from an owner whose ban state is null user.banned is nullable, so a real owner with banned = NULL had every personal key refused as orphaned. Select the joined user id and test that instead; the ban and suspension checks are unchanged. * fix(credentials): report a refused connection host as host_not_allowed - egress validation names its refusal (dns or policy) instead of callers inferring it from a missing cause; an empty DNS answer is ENOTFOUND - the token check judges every DNS record when an environment proxy will carry the request, so a mixed public/private host is refused up front rather than failing in the transport as a retryable 502; the transport's own refusal is a typed EgressRefusedError and maps the same way - new host_not_allowed code (400) with its own connect-modal message - Flow and GitLab personal-token verification classify a refused or missing host the same way - the typed-URL scheme guard is shared and ignores the tabs and newlines the URL parser drops * fix(credentials): report an oversized Databricks token success as a 502 An oversized 2xx token reply carried status 200 into provider_unavailable and its logs. A refusal keeps its own status. * docs(api): say v2 execute options need a full-access key or api:write run.version, async and waitTimeoutSeconds refuse read-only keys; the meta example lists search:read with the full-access scopes. * chore(scripts): scope the payload-param allowlist to internal operations Every listed tool is an internal operation, so the probed-request fallback no longer builds or consults it. * fix(redaction): keep Infisical secret names visible; redact only deployKey * docs(networking): the SSRF guard matches NO_PROXY like curl, not like Bun * fix(api-keys): never echo an internal key-creation failure to the caller The unauthenticated CLI poll route and the settings routes returned the raw error message for an internal failure. Return a fixed message and keep the cause in the server log; a conflict still names the existing key. The CLI's comment now says a 400 can also be a refused key creation. * chore: log a failed route-cache clear and spell out the read-only key rollback The route-cache clear stays non-fatal but warns when it fails for any reason other than a missing build id or cache. The api-key-read-only flag says to revoke or replace read-only keys before rolling back. * fix(credentials): release saved secrets only to internal-JWT callers The token route refused only session callers; any other auth type would have been handed a saved secret. Allow internal JWT and refuse the rest. Tools never execute in the browser (the registry boundary keeps the tool registry out of every workspace client graph, and nothing else imports the executor), so the browser-only token fetch through the route is deleted. * fix(firecrawl): treat a blank maxDiscoveryDepth as unset A cleared field arrived as '' and was sent as 0, a crawl of the start page only. It now follows the blank maxDepth handling. * chore(credentials): log a broken JWKS and keep the successor map out of credential display The JWKS route still answers 404 when the signing key is broken, but warns with the error. The GitLab personal-token display reads its block type from the catalog entry instead of importing the generated successor map into a client-imported module. * test: drop or tighten assertions that could not fail - delegation: the read-only replacement case decided its filter by scanning bound params in a fake DB; no real-boundary check exists, so it is dropped with the fake's filter - Twilio: assert how many recordings requests were sent, so an empty loop cannot pass - Pi: the removed-model case restated the absent-from-catalog case * fix(security): ignore a malformed CIDR NO_PROXY entry 10.0.0.0/8/junk parsed as /8. An entry with extra segments is dropped like any other unparseable entry, and the TSDoc says IP entries match within their address family. * chore(workflows): scope the paused-row read to its workflow and reuse the mutability guard - the execution-status paused-row read filters by workflowId as well - delete-workflow uses requireMutableWorkflow instead of an inline copy - polling webhook logs carry the webhook id in metadata * fix(credentials): never name a sibling credential in a trust-setup refusal The caller may not be able to see the credential that holds or blocks the subject, so both refusals name only the workspace or organization. The uniqueness check documents its limits. * docs: correct the Helm readOnlyRootFilesystem note and the deployed-run variables TSDoc * test(credentials): rely on the shared env reset in the proxy validator tests * chore(helm): bump the chart version for the README correction * fix(redaction): keep prefixed secret keys masked while leaving Infisical's bare secretKey visible * fix(release): keep bare secretKey redacted, leave the proxy every-address rule to the transport, and report unknown GitLab hosts as input errors
1 parent a17b8a8 commit 1b1639c

54 files changed

Lines changed: 451 additions & 226 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/platform/self-hosting/networking.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ Most `/api` requests also pass through Next.js Proxy. The current app configurat
265265
266266
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.
267267
268-
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.
268+
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.
269269
270270
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).
271271

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4986,7 +4986,7 @@
49864986
"v2Enabled": true,
49874987
"keyType": "personal",
49884988
"expiresAt": null,
4989-
"scopes": ["api:read", "api:write"]
4989+
"scopes": ["api:read", "api:write", "search:read"]
49904990
}
49914991
}
49924992
}
@@ -18677,7 +18677,7 @@
1867718677
"v2Enabled": true,
1867818678
"keyType": "personal",
1867918679
"expiresAt": null,
18680-
"scopes": ["api:read", "api:write"]
18680+
"scopes": ["api:read", "api:write", "search:read"]
1868118681
}
1868218682
}
1868318683
]

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14928,7 +14928,7 @@
1492814928
"description": "Execute a deployed workflow version."
1492914929
},
1493014930
"version": {
14931-
"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`.",
14931+
"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`.",
1493214932
"type": "integer",
1493314933
"minimum": 1,
1493414934
"maximum": 2147483647
@@ -15006,7 +15006,7 @@
1500615006
},
1500715007
"async": {
1500815008
"default": false,
15009-
"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`).",
15009+
"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`).",
1501015010
"type": "boolean"
1501115011
},
1501215012
"executionTimeoutSeconds": {
@@ -15016,7 +15016,7 @@
1501615016
"maximum": 604800
1501715017
},
1501815018
"waitTimeoutSeconds": {
15019-
"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`.",
15019+
"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`.",
1502015020
"type": "integer",
1502115021
"minimum": 0,
1502215022
"maximum": 604800
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const { createWorkspaceKey } = vi.hoisted(() => ({ createWorkspaceKey: vi.fn() }))
5+
vi.mock('@/lib/api-key/auth', () => ({
6+
createApiKey: vi.fn(),
7+
createWorkspaceApiKey: createWorkspaceKey,
8+
}))
9+
10+
import {
11+
performCreatePersonalApiKey,
12+
performCreateWorkspaceApiKey,
13+
} from '@/lib/api-key/orchestration'
14+
15+
const INTERNAL_DETAIL = 'connect ECONNREFUSED 10.0.0.5:5432 for role april'
16+
17+
describe('API key creation failures', () => {
18+
beforeEach(() => {
19+
resetDbChainMock()
20+
createWorkspaceKey.mockReset()
21+
})
22+
23+
it('never echoes an internal personal-key failure to the caller', async () => {
24+
dbChainMockFns.limit.mockRejectedValueOnce(new Error(INTERNAL_DETAIL))
25+
26+
const result = await performCreatePersonalApiKey({ userId: 'user-1', name: 'CLI' })
27+
28+
expect(result).toMatchObject({ success: false, errorCode: 'internal' })
29+
expect(result.error).not.toContain('ECONNREFUSED')
30+
})
31+
32+
it('never echoes an internal workspace-key failure, but still names a conflict', async () => {
33+
createWorkspaceKey.mockRejectedValueOnce(new Error(INTERNAL_DETAIL))
34+
const internal = await performCreateWorkspaceApiKey({
35+
workspaceId: 'ws-1',
36+
userId: 'user-1',
37+
name: 'CLI',
38+
})
39+
expect(internal).toMatchObject({ success: false, errorCode: 'internal' })
40+
expect(internal.error).not.toContain('ECONNREFUSED')
41+
42+
createWorkspaceKey.mockRejectedValueOnce(new Error('A key named "CLI" already exists'))
43+
const conflict = await performCreateWorkspaceApiKey({
44+
workspaceId: 'ws-1',
45+
userId: 'user-1',
46+
name: 'CLI',
47+
})
48+
expect(conflict).toEqual({
49+
success: false,
50+
errorCode: 'conflict',
51+
error: 'A key named "CLI" already exists',
52+
})
53+
})
54+
})

‎apps/sim/lib/api-key/orchestration/index.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,8 @@ export async function performCreatePersonalApiKey(
208208
return { success: true, key: { ...created, key: plainKey } }
209209
} catch (error) {
210210
logger.error('Failed to create personal API key', { error })
211-
return { success: false, errorCode: 'internal', error: toError(error).message }
211+
// The CLI poll route is unauthenticated, so an internal failure never echoes its cause.
212+
return { success: false, errorCode: 'internal', error: 'Failed to create API key' }
212213
}
213214
}
214215

@@ -266,10 +267,10 @@ export async function performCreateWorkspaceApiKey(
266267
} catch (error) {
267268
const message = toError(error).message
268269
logger.error('Failed to create workspace API key', { error })
269-
return {
270-
success: false,
271-
error: message,
272-
errorCode: message.includes('already exists') ? 'conflict' : 'internal',
270+
if (message.includes('already exists')) {
271+
return { success: false, error: message, errorCode: 'conflict' }
273272
}
273+
// The CLI poll route is unauthenticated, so an internal failure never echoes its cause.
274+
return { success: false, error: 'Failed to create API key', errorCode: 'internal' }
274275
}
275276
}

‎apps/sim/lib/api/contracts/v2/openapi/resources.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1939,7 +1939,7 @@ const declaredRoutes = [
19391939
v2Enabled: true,
19401940
keyType: 'personal',
19411941
expiresAt: null,
1942-
scopes: ['api:read', 'api:write'],
1942+
scopes: ['api:read', 'api:write', 'search:read'],
19431943
},
19441944
},
19451945
]

‎apps/sim/lib/api/contracts/v2/workflows.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,7 +1508,7 @@ export const v2WorkflowRunSelectionSchema = z.discriminatedUnion('source', [
15081508
version: versionNumberSchema
15091509
.optional()
15101510
.describe(
1511-
'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`.'
1511+
'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`.'
15121512
),
15131513
})
15141514
.strict(),
@@ -1599,7 +1599,7 @@ export const v2ExecuteWorkflowBodySchema = z
15991599
.optional()
16001600
.default(false)
16011601
.describe(
1602-
'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`).'
1602+
'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`).'
16031603
),
16041604
/**
16051605
* An upper bound on the request, not the effective timeout: the server
@@ -1628,7 +1628,7 @@ export const v2ExecuteWorkflowBodySchema = z
16281628
.max(MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS)
16291629
.optional()
16301630
.describe(
1631-
'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`.'
1631+
'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`.'
16321632
),
16331633
stream: z
16341634
.boolean()

‎apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,26 @@ describe('v2 API key authentication', () => {
199199
)
200200
})
201201

202+
it('accepts a personal key whose owner has never had a ban state recorded', async () => {
203+
queueTableRows(schemaMock.apiKey, [
204+
{
205+
id: 'key-1',
206+
userId: 'user-1',
207+
workspaceId: null,
208+
type: 'personal',
209+
access: 'write',
210+
expiresAt: null,
211+
ownerUserId: 'user-1',
212+
userBanned: null,
213+
userSuspendedAt: null,
214+
},
215+
])
216+
217+
const result = await authenticateV2ApiKey({ apiKey: 'secret', bearer: null })
218+
219+
expect(result.principal).toEqual(createPersonalApiKeyPrincipal())
220+
})
221+
202222
it('refuses a personal key whose owner row is gone as invalid, expired or not', async () => {
203223
for (const expiresAt of [null, new Date(Date.now() - 1)]) {
204224
queueTableRows(schemaMock.apiKey, [
@@ -209,6 +229,7 @@ describe('v2 API key authentication', () => {
209229
type: 'personal',
210230
access: 'write',
211231
expiresAt,
232+
ownerUserId: null,
212233
userBanned: null,
213234
userSuspendedAt: null,
214235
},

‎apps/sim/lib/api/server/routes/v2-api-key-auth.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,16 @@ interface ApiKeyRow {
7777
type: string
7878
access: ApiKeyAccess
7979
expiresAt: Date | null
80+
/** The joined owner's id: `null` only when the owner row is gone (`user.banned` is itself nullable). */
81+
ownerUserId: string | null
8082
userBanned: boolean | null
8183
userSuspendedAt: Date | null
8284
}
8385

8486
function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow {
8587
if (!row) throw new V2ApiKeyUnauthenticatedError()
8688
if (row.type === 'personal' && row.workspaceId === null) {
87-
if (row.userBanned === null) {
89+
if (row.ownerUserId === null) {
8890
logger.error('Personal API key is missing its credential owner', { keyId: row.id })
8991
throw new V2ApiKeyUnauthenticatedError()
9092
}
@@ -118,6 +120,7 @@ async function authenticateApiKey(apiKeyHeader: string): Promise<V2ApiKeyAuthCon
118120
type: apiKey.type,
119121
access: apiKey.access,
120122
expiresAt: apiKey.expiresAt,
123+
ownerUserId: user.id,
121124
userBanned: user.banned,
122125
userSuspendedAt: user.suspendedAt,
123126
})

‎apps/sim/lib/core/config/feature-flags.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ const FEATURE_FLAGS = {
7979
},
8080
'api-key-read-only': {
8181
description:
82-
'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.',
82+
'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.',
8383
fallback: 'API_KEY_READ_ONLY',
8484
},
8585
'mothership-model-selector': {

0 commit comments

Comments
 (0)