Skip to content

Commit 053d8bd

Browse files
committed
fix(credentials): report a refused host as the user's input, persist normalized account ids, cap Databricks reads
- A saved-credential check whose typed host the egress policy refuses (private address, plain http where https is required) now fails as invalid_credentials instead of provider_unavailable, which rendered as a retryable 503 - The stored account id is the validator's normalized value, matching how the domain is stored - Databricks token and identity responses are read through the capped JSON reader - Comparison data no longer says Sim can be built in code via an SDK
1 parent 411cc9c commit 053d8bd

8 files changed

Lines changed: 122 additions & 41 deletions

File tree

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,14 @@ 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',
145145
},
146146
],
147147
},
@@ -155,8 +155,8 @@ export const simProfile: CompetitorProfile = {
155155
sources: [
156156
{
157157
url: 'https://docs.sim.ai/introduction',
158-
label: 'Sim Docs: Introduction (visual, natural-language, and API/SDK building)',
159-
asOf: '2026-07-08',
158+
label: 'Sim Docs: Introduction (visual, natural-language, and API building)',
159+
asOf: '2026-11-10',
160160
},
161161
],
162162
},

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

Lines changed: 11 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 { 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,10 @@ 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(() => ({}))) as {
147153
access_token?: unknown
148154
error_description?: unknown
149155
error?: unknown
@@ -240,7 +246,10 @@ async function verifyWorkspaceIdentity(
240246
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
241247
})
242248
if (!response.ok) throw new Error(`workspace returned HTTP ${response.status}`)
243-
const me = (await response.json().catch(() => ({}))) as {
249+
const me = (await readResponseJsonWithLimit(response, {
250+
maxBytes: MAX_RESPONSE_BYTES,
251+
label: 'Databricks identity response',
252+
}).catch(() => ({}))) as {
244253
userName?: unknown
245254
displayName?: unknown
246255
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,9 @@ async function buildTokenServiceAccountSecret(
388388
providerId,
389389
apiToken,
390390
...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}),
391-
...(requiresWorkspace ? { providerWorkspaceId } : {}),
391+
...(requiresWorkspace
392+
? { providerWorkspaceId: validation.normalizedWorkspaceId ?? providerWorkspaceId }
393+
: {}),
392394
metadata: { ...validation.storedMetadata, ...principalMetadata },
393395
}
394396
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
@@ -271,6 +271,8 @@ export interface TokenServiceAccountValidationResult {
271271
storedMetadata?: Record<string, string>
272272
/** Normalized domain to persist instead of the raw user input (when collected). */
273273
normalizedDomain?: string
274+
/** Normalized account id to persist instead of the raw user input (when collected). */
275+
normalizedWorkspaceId?: string
274276
}
275277

276278
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

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -385,30 +385,30 @@ describe('saved credentials for a host the user chose', () => {
385385
it.each(HOST_CHOSEN_VALIDATORS)(
386386
'%s refuses credentials smuggled into the URL before sending the key anywhere',
387387
async (_, validate) => {
388-
inputValidationMockFns.mockSecureFetchWithValidation.mockReset()
388+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockReset()
389389

390390
await expect(
391391
validate({ apiToken: 'secret', domain: 'https://user:pass@instance.example.com' })
392392
).rejects.toMatchObject({ code: 'invalid_credentials' })
393-
expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled()
393+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
394394
}
395395
)
396396

397397
it.each(HOST_CHOSEN_VALIDATORS)(
398398
'%s refuses a non-http scheme instead of reading it as a host',
399399
async (_, validate) => {
400-
inputValidationMockFns.mockSecureFetchWithValidation.mockReset()
400+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockReset()
401401

402402
await expect(
403403
validate({ apiToken: 'secret', domain: 'ftp://instance.example.com' })
404404
).rejects.toMatchObject({ code: 'invalid_credentials' })
405-
expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled()
405+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
406406
}
407407
)
408408
})
409409

410410
describe('saved credentials whose vendor answers a restricted token in its error', () => {
411-
const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation
411+
const secureFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP
412412

413413
it.each([
414414
[
@@ -436,6 +436,11 @@ describe('saved credentials whose vendor answers a restricted token in its error
436436
'invalid_credentials',
437437
],
438438
])('%s', async (_, validate, reply, code) => {
439+
inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({
440+
isValid: true,
441+
resolvedIP: '203.0.113.7',
442+
originalHostname: 'instance.example.com',
443+
})
439444
secureFetch.mockReset().mockResolvedValue(reply)
440445

441446
const result = validate({ apiToken: 'tok', domain: 'https://instance.example.com' })

0 commit comments

Comments
 (0)