Skip to content

Commit 5eca73e

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 15cf51d commit 5eca73e

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
@@ -128,7 +128,8 @@ describe('tokenCheckValidator', () => {
128128
})
129129

130130
describe('tokenCheckValidator with a typed host', () => {
131-
const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation
131+
const validateUrl = inputValidationMockFns.mockValidateUrlWithDNS
132+
const pinnedFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP
132133
const validateHost = tokenCheckValidator({
133134
serviceLabel: 'Example',
134135
domain: ({ domain }) => {
@@ -144,17 +145,30 @@ describe('tokenCheckValidator with a typed host', () => {
144145
})
145146

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

150157
it('verifies at the normalized host through the guarded egress and stores that host', async () => {
151-
secureFetch.mockResolvedValue(jsonResponse({ ok: true }))
158+
pinnedFetch.mockResolvedValue(jsonResponse({ ok: true }))
152159

153160
await expect(
154161
validateHost({ apiToken: 'good', domain: ' Tools.Example.com ' })
155162
).resolves.toMatchObject({ normalizedDomain: 'tools.example.com' })
156-
expect(secureFetch).toHaveBeenCalledWith(
163+
expect(validateUrl).toHaveBeenCalledWith(
164+
'https://tools.example.com/api/me',
165+
'url',
166+
'configuredEndpoint',
167+
expect.anything()
168+
)
169+
expect(pinnedFetch).toHaveBeenCalledWith(
157170
'https://tools.example.com/api/me',
171+
'203.0.113.7',
158172
expect.objectContaining({ profile: 'configuredEndpoint', maxRedirects: 0 })
159173
)
160174
})
@@ -170,22 +184,41 @@ describe('tokenCheckValidator with a typed host', () => {
170184
await expect(
171185
validateHost({ apiToken: 'good', domain: 'evil.example.com@tools.example.com' })
172186
).rejects.toMatchObject({ code: 'invalid_credentials' })
173-
expect(secureFetch).not.toHaveBeenCalled()
187+
expect(validateUrl).not.toHaveBeenCalled()
188+
expect(pinnedFetch).not.toHaveBeenCalled()
189+
})
190+
191+
it('reports a host the egress policy refuses as the user input, not a retryable outage', async () => {
192+
validateUrl.mockResolvedValue({ isValid: false, error: 'url resolves to a private address' })
193+
194+
await expect(
195+
validateHost({ apiToken: 'good', domain: 'grafana.internal' })
196+
).rejects.toMatchObject({ code: 'invalid_credentials', status: 400 })
197+
expect(pinnedFetch).not.toHaveBeenCalled()
198+
})
199+
200+
it('reports a host that cannot be reached as the provider being unavailable', async () => {
201+
pinnedFetch.mockRejectedValue(new Error('connect ECONNREFUSED'))
202+
203+
await expect(
204+
validateHost({ apiToken: 'good', domain: 'tools.example.com' })
205+
).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 })
174206
})
175207

176208
it.each([
177209
[
178210
'Node (code on cause)',
179-
new Error('fetch failed', {
211+
new Error('lookup failed', {
180212
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
181213
}),
182214
],
183-
[
184-
'Bun (code on the error)',
185-
Object.assign(new TypeError('fetch failed'), { code: 'ENOTFOUND' }),
186-
],
187-
])('reports a host that does not exist as not found under %s', async (_runtime, error) => {
188-
secureFetch.mockRejectedValue(error)
215+
['Bun (code on the error)', Object.assign(new Error('lookup failed'), { code: 'ENOTFOUND' })],
216+
])('reports a host that does not exist as not found under %s', async (_runtime, cause) => {
217+
validateUrl.mockResolvedValue({
218+
isValid: false,
219+
error: 'url hostname could not be resolved',
220+
cause,
221+
})
189222

190223
await expect(
191224
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,
@@ -153,13 +156,34 @@ function normalizedField(
153156
}
154157
}
155158

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

@@ -203,6 +225,7 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid
203225
...(workspaceId === undefined ? {} : { accountId: workspaceId }),
204226
},
205227
...(domain === undefined ? {} : { normalizedDomain: domain }),
228+
...(workspaceId === undefined ? {} : { normalizedWorkspaceId: workspaceId }),
206229
}
207230
const request = check.request(fields)
208231
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)