Skip to content

Commit ee09784

Browse files
committed
Merge remote-tracking branch 'origin/staging' into wip/platform-org-wiring
2 parents 4d8d951 + 1a605bc commit ee09784

9 files changed

Lines changed: 183 additions & 41 deletions

File tree

‎apps/sim/lib/compare/data/sim.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,19 @@ export const simProfile: CompetitorProfile = {
134134
facts: {
135135
platform: {
136136
builderType: {
137-
value: 'Visual drag-and-drop canvas, natural-language (Chat), or code (API/SDK)',
137+
value: 'Visual drag-and-drop canvas, natural-language (Chat), or code (REST API and CLI)',
138138
shortValue: 'Visual canvas, chat, or code',
139139
confidence: 'verified',
140140
sources: [
141141
{
142142
url: 'https://docs.sim.ai/introduction',
143-
label: 'Sim Docs: Introduction (visual, natural-language, and API/SDK building)',
144-
asOf: '2026-07-08',
143+
label: 'Sim Docs: Introduction (visual, natural-language, and API building)',
144+
asOf: '2026-11-10',
145+
},
146+
{
147+
url: 'https://docs.sim.ai/cli',
148+
label: 'Sim Docs: CLI',
149+
asOf: '2026-11-10',
145150
},
146151
],
147152
},
@@ -155,8 +160,8 @@ export const simProfile: CompetitorProfile = {
155160
sources: [
156161
{
157162
url: 'https://docs.sim.ai/introduction',
158-
label: 'Sim Docs: Introduction (visual, natural-language, and API/SDK building)',
159-
asOf: '2026-07-08',
163+
label: 'Sim Docs: Introduction (visual, natural-language, and API building)',
164+
asOf: '2026-11-10',
160165
},
161166
],
162167
},

‎apps/sim/lib/credentials/databricks-service-account.server.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ describe('saving a Databricks connection', () => {
4848
)
4949
})
5050

51+
it('refuses an identity reply over the size cap instead of saving the host as the identity', async () => {
52+
fetch.mockResolvedValue(
53+
jsonResponse({ userName: 'me@example.com', groups: 'g'.repeat(1024 * 1024) })
54+
)
55+
56+
await expect(
57+
verifyAndEncryptDatabricksCredential(
58+
patConfig('dbc-1234abcd-5678.cloud.databricks.com'),
59+
owner
60+
)
61+
).rejects.toThrow(/Could not verify/)
62+
expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled()
63+
})
64+
5165
it.each([
5266
'attacker.example.com',
5367
'https://attacker.example.com',
@@ -175,6 +189,32 @@ describe('getting a Databricks token for a run', () => {
175189
})
176190
})
177191

192+
it('reports a token reply over the size cap as oversized, not as a refused request', async () => {
193+
fetch.mockResolvedValue(jsonResponse({ access_token: 't'.repeat(1024 * 1024) }, 200))
194+
195+
await expect(resolveDatabricksCredentialToken('cred-1')).rejects.toMatchObject({
196+
code: 'provider_unavailable',
197+
logDetail: { reason: 'Databricks token response exceeded the size limit' },
198+
})
199+
})
200+
201+
it('reports a token reply cut off mid-body as the provider being unavailable, not a bad credential', async () => {
202+
const body = new ReadableStream<Uint8Array>({
203+
start(controller) {
204+
controller.enqueue(new TextEncoder().encode('{"error":"invalid_cl'))
205+
controller.error(new Error('socket hang up'))
206+
},
207+
})
208+
fetch.mockResolvedValue(
209+
new Response(body, { status: 401, headers: { 'Content-Type': 'application/json' } })
210+
)
211+
212+
await expect(resolveDatabricksCredentialToken('cred-1')).rejects.toMatchObject({
213+
code: 'provider_unavailable',
214+
logDetail: { reason: 'Databricks token response could not be read' },
215+
})
216+
})
217+
178218
it('reports a token reply without a token as the provider misbehaving, not a bad credential', async () => {
179219
fetch.mockResolvedValue(jsonResponse({}, 200))
180220

‎apps/sim/lib/credentials/databricks-service-account.server.ts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
55
import { z } from 'zod'
66
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
77
import { validateDatabricksWorkspaceHost } from '@/lib/core/security/input-validation'
8+
import { isPayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
89
import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config'
910
import {
1011
providerFailureCode,
@@ -42,6 +43,8 @@ const DATABRICKS_CREDENTIAL_SECRET_TYPE = 'databricks_credential_v1' as const
4243
const DATABRICKS_WORKLOAD_IDENTITY_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.databricks
4344

4445
const REQUEST_TIMEOUT_MS = 15_000
46+
/** Token and identity responses are small; the cap keeps a misbehaving host from exhausting memory. */
47+
const MAX_RESPONSE_BYTES = 1024 * 1024
4548
const CLIENT_ID_PATTERN = /^[A-Za-z0-9-]{8,128}$/
4649

4750
/** Workspace origin with Databricks' own host allowlist as the SSRF guard. */
@@ -143,7 +146,21 @@ async function requestWorkspaceToken(
143146
redirect: 'error',
144147
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
145148
})
146-
const payload = (await response.json().catch(() => ({}))) as {
149+
const payload = (await readResponseJsonWithLimit(response, {
150+
maxBytes: MAX_RESPONSE_BYTES,
151+
label: 'Databricks token response',
152+
}).catch((error: unknown) => {
153+
if (error instanceof SyntaxError) return {}
154+
signal?.throwIfAborted()
155+
if (isPayloadSizeLimitError(error)) {
156+
throw new DatabricksCredentialError(
157+
'Databricks token response exceeded the size limit',
158+
response.status
159+
)
160+
}
161+
// An interrupted body says nothing about the credential, whatever the status line was.
162+
throw new DatabricksCredentialError('Databricks token response could not be read', 502)
163+
})) as {
147164
access_token?: unknown
148165
error_description?: unknown
149166
error?: unknown
@@ -240,7 +257,15 @@ async function verifyWorkspaceIdentity(
240257
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
241258
})
242259
if (!response.ok) throw new Error(`workspace returned HTTP ${response.status}`)
243-
const me = (await response.json().catch(() => ({}))) as {
260+
// A malformed body only loses the display name; an oversized one must fail
261+
// verification rather than save the host as the identity.
262+
const me = (await readResponseJsonWithLimit(response, {
263+
maxBytes: MAX_RESPONSE_BYTES,
264+
label: 'Databricks identity response',
265+
}).catch((error: unknown) => {
266+
if (error instanceof SyntaxError) return {}
267+
throw error
268+
})) as {
244269
userName?: unknown
245270
displayName?: unknown
246271
}

‎apps/sim/lib/credentials/service-account-secret.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,9 @@ async function buildTokenServiceAccountSecret(
393393
providerId,
394394
apiToken,
395395
...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}),
396-
...(requiresWorkspace ? { providerWorkspaceId } : {}),
396+
...(requiresWorkspace
397+
? { providerWorkspaceId: validation.normalizedWorkspaceId ?? providerWorkspaceId }
398+
: {}),
397399
metadata: { ...validation.storedMetadata, ...principalMetadata },
398400
}
399401
const { encrypted } = await encryptSecret(JSON.stringify(blob))

‎apps/sim/lib/credentials/token-service-accounts/server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ export interface TokenServiceAccountValidationResult {
270270
storedMetadata?: Record<string, string>
271271
/** Normalized domain to persist instead of the raw user input (when collected). */
272272
normalizedDomain?: string
273+
/** Normalized account id to persist instead of the raw user input (when collected). */
274+
normalizedWorkspaceId?: string
273275
}
274276

275277
export type TokenServiceAccountValidator = (

‎apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@ import { validateGitLabServiceAccount } from '@/lib/credentials/token-service-ac
1515
const activeToken = { name: 'sim-workflows', active: true, revoked: false, scopes: ['api'] }
1616

1717
describe('GitLab saved token verification', () => {
18-
const fetch = inputValidationMockFns.mockSecureFetchWithValidation
18+
const fetch = inputValidationMockFns.mockSecureFetchWithPinnedIP
19+
const validateUrl = inputValidationMockFns.mockValidateUrlWithDNS
1920

2021
beforeEach(() => {
2122
fetch.mockReset()
23+
validateUrl.mockReset().mockResolvedValue({
24+
isValid: true,
25+
resolvedIP: '203.0.113.7',
26+
originalHostname: 'gitlab.example.com',
27+
})
2228
})
2329

2430
it('verifies the token at the host it was typed with and stores that host', async () => {
@@ -33,6 +39,7 @@ describe('GitLab saved token verification', () => {
3339
})
3440
expect(fetch).toHaveBeenCalledWith(
3541
'https://gitlab.example.com/api/v4/personal_access_tokens/self',
42+
'203.0.113.7',
3643
expect.objectContaining({ maxRedirects: 0, profile: 'configuredEndpoint' })
3744
)
3845
})
@@ -67,11 +74,11 @@ describe('GitLab saved token verification', () => {
6774
})
6875

6976
it('reports a host that does not exist as not found, so the user fixes it instead of retrying', async () => {
70-
fetch.mockRejectedValue(
71-
new Error('url hostname could not be resolved', {
72-
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
73-
})
74-
)
77+
validateUrl.mockResolvedValue({
78+
isValid: false,
79+
error: 'url hostname could not be resolved',
80+
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
81+
})
7582

7683
await expect(
7784
validateGitLabServiceAccount({ apiToken: 'glpat-x', domain: 'gitlab.exmaple.com' })

‎apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts‎

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ describe('tokenCheckValidator', () => {
129129
})
130130

131131
describe('tokenCheckValidator with a typed host', () => {
132-
const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation
132+
const validateUrl = inputValidationMockFns.mockValidateUrlWithDNS
133+
const pinnedFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP
133134
const validateHost = tokenCheckValidator({
134135
serviceLabel: 'Example',
135136
domain: ({ domain }) => {
@@ -145,17 +146,30 @@ describe('tokenCheckValidator with a typed host', () => {
145146
})
146147

147148
beforeEach(() => {
148-
secureFetch.mockReset()
149+
validateUrl.mockReset()
150+
validateUrl.mockResolvedValue({
151+
isValid: true,
152+
resolvedIP: '203.0.113.7',
153+
originalHostname: 'tools.example.com',
154+
})
155+
pinnedFetch.mockReset()
149156
})
150157

151158
it('verifies at the normalized host through the guarded egress and stores that host', async () => {
152-
secureFetch.mockResolvedValue(jsonResponse({ ok: true }))
159+
pinnedFetch.mockResolvedValue(jsonResponse({ ok: true }))
153160

154161
await expect(
155162
validateHost({ apiToken: 'good', domain: ' Tools.Example.com ' })
156163
).resolves.toMatchObject({ normalizedDomain: 'tools.example.com' })
157-
expect(secureFetch).toHaveBeenCalledWith(
164+
expect(validateUrl).toHaveBeenCalledWith(
165+
'https://tools.example.com/api/me',
166+
'url',
167+
'configuredEndpoint',
168+
expect.anything()
169+
)
170+
expect(pinnedFetch).toHaveBeenCalledWith(
158171
'https://tools.example.com/api/me',
172+
'203.0.113.7',
159173
expect.objectContaining({ profile: 'configuredEndpoint', maxRedirects: 0 })
160174
)
161175
})
@@ -174,22 +188,41 @@ describe('tokenCheckValidator with a typed host', () => {
174188
await expect(
175189
validateHost({ apiToken: 'good', domain: 'evil.example.com@tools.example.com' })
176190
).rejects.toMatchObject({ code: 'invalid_credentials' })
177-
expect(secureFetch).not.toHaveBeenCalled()
191+
expect(validateUrl).not.toHaveBeenCalled()
192+
expect(pinnedFetch).not.toHaveBeenCalled()
193+
})
194+
195+
it('reports a host the egress policy refuses as the user input, not a retryable outage', async () => {
196+
validateUrl.mockResolvedValue({ isValid: false, error: 'url resolves to a private address' })
197+
198+
await expect(
199+
validateHost({ apiToken: 'good', domain: 'grafana.internal' })
200+
).rejects.toMatchObject({ code: 'invalid_credentials', status: 400 })
201+
expect(pinnedFetch).not.toHaveBeenCalled()
202+
})
203+
204+
it('reports a host that cannot be reached as the provider being unavailable', async () => {
205+
pinnedFetch.mockRejectedValue(new Error('connect ECONNREFUSED'))
206+
207+
await expect(
208+
validateHost({ apiToken: 'good', domain: 'tools.example.com' })
209+
).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 })
178210
})
179211

180212
it.each([
181213
[
182214
'Node (code on cause)',
183-
new Error('fetch failed', {
215+
new Error('lookup failed', {
184216
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
185217
}),
186218
],
187-
[
188-
'Bun (code on the error)',
189-
Object.assign(new TypeError('fetch failed'), { code: 'ENOTFOUND' }),
190-
],
191-
])('reports a host that does not exist as not found under %s', async (_runtime, error) => {
192-
secureFetch.mockRejectedValue(error)
219+
['Bun (code on the error)', Object.assign(new Error('lookup failed'), { code: 'ENOTFOUND' })],
220+
])('reports a host that does not exist as not found under %s', async (_runtime, cause) => {
221+
validateUrl.mockResolvedValue({
222+
isValid: false,
223+
error: 'url hostname could not be resolved',
224+
cause,
225+
})
193226

194227
await expect(
195228
validateHost({ apiToken: 'good', domain: 'tools.exmaple.com' })

‎apps/sim/lib/credentials/token-service-accounts/validators/http.ts‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { truncate } from '@sim/utils/string'
22
import type { EgressProfile } from '@/lib/core/security/egress/profiles'
3-
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
3+
import {
4+
secureFetchWithPinnedIP,
5+
validateUrlWithDNS,
6+
} from '@/lib/core/security/input-validation.server'
47
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
58
import {
69
fetchProvider,
@@ -154,13 +157,34 @@ function normalizedField(
154157
}
155158
}
156159

157-
/** Sends a check through the guarded egress profile, mapping a host that doesn't exist to `site_not_found`. */
160+
/**
161+
* Sends a check through the guarded egress profile. A host the policy refuses
162+
* (a private address, or plain http where https is required) is the user's
163+
* input, so it fails as `invalid_credentials` rather than `provider_unavailable`,
164+
* which renders as a retryable 503. A host that doesn't exist is `site_not_found`.
165+
*/
158166
async function sendThroughEgress(
159167
profile: NonNullable<TokenCheck['egress']>,
160168
{ url, method = 'GET', headers, body }: TokenCheckRequest
161169
): Promise<ProviderResponse> {
170+
const validation = await validateUrlWithDNS(url, 'url', profile, { logDetails: false })
171+
if (!validation.isValid) {
172+
// Only a failed DNS lookup carries a cause; without one the policy refused the host.
173+
if (validation.cause === undefined) {
174+
throw new TokenServiceAccountValidationError('invalid_credentials', 400, {
175+
step: 'verify',
176+
reason: 'host not allowed',
177+
})
178+
}
179+
const notFound = isHostNotFound(validation.cause)
180+
throw new TokenServiceAccountValidationError(
181+
notFound ? 'site_not_found' : 'provider_unavailable',
182+
notFound ? 400 : 502,
183+
{ step: 'verify', reason: notFound ? 'host does not resolve' : 'host lookup failed' }
184+
)
185+
}
162186
try {
163-
return await secureFetchWithValidation(url, {
187+
return await secureFetchWithPinnedIP(url, validation.resolvedIP, {
164188
profile,
165189
method,
166190
headers,
@@ -170,13 +194,11 @@ async function sendThroughEgress(
170194
maxResponseBytes: MAX_EGRESS_RESPONSE_BYTES,
171195
logUrlValidationDetails: false,
172196
})
173-
} catch (error) {
174-
const notFound = isHostNotFound(error)
175-
throw new TokenServiceAccountValidationError(
176-
notFound ? 'site_not_found' : 'provider_unavailable',
177-
notFound ? 400 : 502,
178-
{ step: 'verify', reason: notFound ? 'host does not resolve' : 'host unreachable' }
179-
)
197+
} catch {
198+
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
199+
step: 'verify',
200+
reason: 'host unreachable',
201+
})
180202
}
181203
}
182204

@@ -204,6 +226,7 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid
204226
...(workspaceId === undefined ? {} : { accountId: workspaceId }),
205227
},
206228
...(domain === undefined ? {} : { normalizedDomain: domain }),
229+
...(workspaceId === undefined ? {} : { normalizedWorkspaceId: workspaceId }),
207230
}
208231
const request = check.request(fields)
209232
const response: ProviderResponse = check.egress

0 commit comments

Comments
 (0)