Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 10 additions & 5 deletions apps/sim/lib/compare/data/sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,19 @@ export const simProfile: CompetitorProfile = {
facts: {
platform: {
builderType: {
value: 'Visual drag-and-drop canvas, natural-language (Chat), or code (API/SDK)',
value: 'Visual drag-and-drop canvas, natural-language (Chat), or code (REST API and CLI)',
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
shortValue: 'Visual canvas, chat, or code',
confidence: 'verified',
sources: [
{
url: 'https://docs.sim.ai/introduction',
label: 'Sim Docs: Introduction (visual, natural-language, and API/SDK building)',
asOf: '2026-07-08',
label: 'Sim Docs: Introduction (visual, natural-language, and API building)',
asOf: '2026-11-10',
},
{
url: 'https://docs.sim.ai/cli',
label: 'Sim Docs: CLI',
asOf: '2026-11-10',
},
],
},
Expand All @@ -155,8 +160,8 @@ export const simProfile: CompetitorProfile = {
sources: [
{
url: 'https://docs.sim.ai/introduction',
label: 'Sim Docs: Introduction (visual, natural-language, and API/SDK building)',
asOf: '2026-07-08',
label: 'Sim Docs: Introduction (visual, natural-language, and API building)',
asOf: '2026-11-10',
},
],
},
Expand Down
40 changes: 40 additions & 0 deletions apps/sim/lib/credentials/databricks-service-account.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ describe('saving a Databricks connection', () => {
)
})

it('refuses an identity reply over the size cap instead of saving the host as the identity', async () => {
fetch.mockResolvedValue(
jsonResponse({ userName: 'me@example.com', groups: 'g'.repeat(1024 * 1024) })
)

await expect(
verifyAndEncryptDatabricksCredential(
patConfig('dbc-1234abcd-5678.cloud.databricks.com'),
owner
)
).rejects.toThrow(/Could not verify/)
expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled()
})

it.each([
'attacker.example.com',
'https://attacker.example.com',
Expand Down Expand Up @@ -175,6 +189,32 @@ describe('getting a Databricks token for a run', () => {
})
})

it('reports a token reply over the size cap as oversized, not as a refused request', async () => {
fetch.mockResolvedValue(jsonResponse({ access_token: 't'.repeat(1024 * 1024) }, 200))

await expect(resolveDatabricksCredentialToken('cred-1')).rejects.toMatchObject({
code: 'provider_unavailable',
logDetail: { reason: 'Databricks token response exceeded the size limit' },
})
})

it('reports a token reply cut off mid-body as the provider being unavailable, not a bad credential', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"error":"invalid_cl'))
controller.error(new Error('socket hang up'))
},
})
fetch.mockResolvedValue(
new Response(body, { status: 401, headers: { 'Content-Type': 'application/json' } })
)

await expect(resolveDatabricksCredentialToken('cred-1')).rejects.toMatchObject({
code: 'provider_unavailable',
logDetail: { reason: 'Databricks token response could not be read' },
})
})

it('reports a token reply without a token as the provider misbehaving, not a bad credential', async () => {
fetch.mockResolvedValue(jsonResponse({}, 200))

Expand Down
29 changes: 27 additions & 2 deletions apps/sim/lib/credentials/databricks-service-account.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { validateDatabricksWorkspaceHost } from '@/lib/core/security/input-validation'
import { isPayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config'
import {
providerFailureCode,
Expand Down Expand Up @@ -42,6 +43,8 @@ const DATABRICKS_CREDENTIAL_SECRET_TYPE = 'databricks_credential_v1' as const
const DATABRICKS_WORKLOAD_IDENTITY_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.databricks

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

/** Workspace origin with Databricks' own host allowlist as the SSRF guard. */
Expand Down Expand Up @@ -143,7 +146,21 @@ async function requestWorkspaceToken(
redirect: 'error',
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
})
const payload = (await response.json().catch(() => ({}))) as {
const payload = (await readResponseJsonWithLimit(response, {
maxBytes: MAX_RESPONSE_BYTES,
label: 'Databricks token response',
}).catch((error: unknown) => {
if (error instanceof SyntaxError) return {}
signal?.throwIfAborted()
if (isPayloadSizeLimitError(error)) {
throw new DatabricksCredentialError(
'Databricks token response exceeded the size limit',
response.status
)
}
// An interrupted body says nothing about the credential, whatever the status line was.
throw new DatabricksCredentialError('Databricks token response could not be read', 502)
})) as {
access_token?: unknown
error_description?: unknown
error?: unknown
Expand Down Expand Up @@ -240,7 +257,15 @@ async function verifyWorkspaceIdentity(
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!response.ok) throw new Error(`workspace returned HTTP ${response.status}`)
const me = (await response.json().catch(() => ({}))) as {
// A malformed body only loses the display name; an oversized one must fail
// verification rather than save the host as the identity.
const me = (await readResponseJsonWithLimit(response, {
maxBytes: MAX_RESPONSE_BYTES,
label: 'Databricks identity response',
}).catch((error: unknown) => {
if (error instanceof SyntaxError) return {}
throw error
})) as {
userName?: unknown
displayName?: unknown
}
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/lib/credentials/service-account-secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,9 @@ async function buildTokenServiceAccountSecret(
providerId,
apiToken,
...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}),
...(requiresWorkspace ? { providerWorkspaceId } : {}),
...(requiresWorkspace
? { providerWorkspaceId: validation.normalizedWorkspaceId ?? providerWorkspaceId }
: {}),
metadata: { ...validation.storedMetadata, ...principalMetadata },
}
const { encrypted } = await encryptSecret(JSON.stringify(blob))
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/credentials/token-service-accounts/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ export interface TokenServiceAccountValidationResult {
storedMetadata?: Record<string, string>
/** Normalized domain to persist instead of the raw user input (when collected). */
normalizedDomain?: string
/** Normalized account id to persist instead of the raw user input (when collected). */
normalizedWorkspaceId?: string
}

export type TokenServiceAccountValidator = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ import { validateGitLabServiceAccount } from '@/lib/credentials/token-service-ac
const activeToken = { name: 'sim-workflows', active: true, revoked: false, scopes: ['api'] }

describe('GitLab saved token verification', () => {
const fetch = inputValidationMockFns.mockSecureFetchWithValidation
const fetch = inputValidationMockFns.mockSecureFetchWithPinnedIP
const validateUrl = inputValidationMockFns.mockValidateUrlWithDNS

beforeEach(() => {
fetch.mockReset()
validateUrl.mockReset().mockResolvedValue({
isValid: true,
resolvedIP: '203.0.113.7',
originalHostname: 'gitlab.example.com',
})
})

it('verifies the token at the host it was typed with and stores that host', async () => {
Expand All @@ -33,6 +39,7 @@ describe('GitLab saved token verification', () => {
})
expect(fetch).toHaveBeenCalledWith(
'https://gitlab.example.com/api/v4/personal_access_tokens/self',
'203.0.113.7',
expect.objectContaining({ maxRedirects: 0, profile: 'configuredEndpoint' })
)
})
Expand Down Expand Up @@ -67,11 +74,11 @@ describe('GitLab saved token verification', () => {
})

it('reports a host that does not exist as not found, so the user fixes it instead of retrying', async () => {
fetch.mockRejectedValue(
new Error('url hostname could not be resolved', {
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
})
)
validateUrl.mockResolvedValue({
isValid: false,
error: 'url hostname could not be resolved',
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
})

await expect(
validateGitLabServiceAccount({ apiToken: 'glpat-x', domain: 'gitlab.exmaple.com' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ describe('tokenCheckValidator', () => {
})

describe('tokenCheckValidator with a typed host', () => {
const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation
const validateUrl = inputValidationMockFns.mockValidateUrlWithDNS
const pinnedFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP
const validateHost = tokenCheckValidator({
serviceLabel: 'Example',
domain: ({ domain }) => {
Expand All @@ -145,17 +146,30 @@ describe('tokenCheckValidator with a typed host', () => {
})

beforeEach(() => {
secureFetch.mockReset()
validateUrl.mockReset()
validateUrl.mockResolvedValue({
isValid: true,
resolvedIP: '203.0.113.7',
originalHostname: 'tools.example.com',
})
pinnedFetch.mockReset()
})

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

await expect(
validateHost({ apiToken: 'good', domain: ' Tools.Example.com ' })
).resolves.toMatchObject({ normalizedDomain: 'tools.example.com' })
expect(secureFetch).toHaveBeenCalledWith(
expect(validateUrl).toHaveBeenCalledWith(
'https://tools.example.com/api/me',
'url',
'configuredEndpoint',
expect.anything()
)
expect(pinnedFetch).toHaveBeenCalledWith(
'https://tools.example.com/api/me',
'203.0.113.7',
expect.objectContaining({ profile: 'configuredEndpoint', maxRedirects: 0 })
)
})
Expand All @@ -174,22 +188,41 @@ describe('tokenCheckValidator with a typed host', () => {
await expect(
validateHost({ apiToken: 'good', domain: 'evil.example.com@tools.example.com' })
).rejects.toMatchObject({ code: 'invalid_credentials' })
expect(secureFetch).not.toHaveBeenCalled()
expect(validateUrl).not.toHaveBeenCalled()
expect(pinnedFetch).not.toHaveBeenCalled()
})

it('reports a host the egress policy refuses as the user input, not a retryable outage', async () => {
validateUrl.mockResolvedValue({ isValid: false, error: 'url resolves to a private address' })

await expect(
validateHost({ apiToken: 'good', domain: 'grafana.internal' })
).rejects.toMatchObject({ code: 'invalid_credentials', status: 400 })
expect(pinnedFetch).not.toHaveBeenCalled()
})

it('reports a host that cannot be reached as the provider being unavailable', async () => {
pinnedFetch.mockRejectedValue(new Error('connect ECONNREFUSED'))

await expect(
validateHost({ apiToken: 'good', domain: 'tools.example.com' })
).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 })
})

it.each([
[
'Node (code on cause)',
new Error('fetch failed', {
new Error('lookup failed', {
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
}),
],
[
'Bun (code on the error)',
Object.assign(new TypeError('fetch failed'), { code: 'ENOTFOUND' }),
],
])('reports a host that does not exist as not found under %s', async (_runtime, error) => {
secureFetch.mockRejectedValue(error)
['Bun (code on the error)', Object.assign(new Error('lookup failed'), { code: 'ENOTFOUND' })],
])('reports a host that does not exist as not found under %s', async (_runtime, cause) => {
validateUrl.mockResolvedValue({
isValid: false,
error: 'url hostname could not be resolved',
cause,
})

await expect(
validateHost({ apiToken: 'good', domain: 'tools.exmaple.com' })
Expand Down
43 changes: 33 additions & 10 deletions apps/sim/lib/credentials/token-service-accounts/validators/http.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { truncate } from '@sim/utils/string'
import type { EgressProfile } from '@/lib/core/security/egress/profiles'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import {
fetchProvider,
Expand Down Expand Up @@ -154,13 +157,34 @@ function normalizedField(
}
}

/** Sends a check through the guarded egress profile, mapping a host that doesn't exist to `site_not_found`. */
/**
* Sends a check through the guarded egress profile. A host the policy refuses
* (a private address, or plain http where https is required) is the user's
* input, so it fails as `invalid_credentials` rather than `provider_unavailable`,
* which renders as a retryable 503. A host that doesn't exist is `site_not_found`.
*/
async function sendThroughEgress(
profile: NonNullable<TokenCheck['egress']>,
{ url, method = 'GET', headers, body }: TokenCheckRequest
): Promise<ProviderResponse> {
const validation = await validateUrlWithDNS(url, 'url', profile, { logDetails: false })
if (!validation.isValid) {
// Only a failed DNS lookup carries a cause; without one the policy refused the host.
if (validation.cause === undefined) {
throw new TokenServiceAccountValidationError('invalid_credentials', 400, {
step: 'verify',
reason: 'host not allowed',
})
}
const notFound = isHostNotFound(validation.cause)
throw new TokenServiceAccountValidationError(
notFound ? 'site_not_found' : 'provider_unavailable',
notFound ? 400 : 502,
{ step: 'verify', reason: notFound ? 'host does not resolve' : 'host lookup failed' }
)
}
try {
return await secureFetchWithValidation(url, {
return await secureFetchWithPinnedIP(url, validation.resolvedIP, {
profile,
method,
headers,
Expand All @@ -170,13 +194,11 @@ async function sendThroughEgress(
maxResponseBytes: MAX_EGRESS_RESPONSE_BYTES,
logUrlValidationDetails: false,
})
} catch (error) {
const notFound = isHostNotFound(error)
throw new TokenServiceAccountValidationError(
notFound ? 'site_not_found' : 'provider_unavailable',
notFound ? 400 : 502,
{ step: 'verify', reason: notFound ? 'host does not resolve' : 'host unreachable' }
)
} catch {
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
step: 'verify',
reason: 'host unreachable',
})
}
}

Expand Down Expand Up @@ -204,6 +226,7 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid
...(workspaceId === undefined ? {} : { accountId: workspaceId }),
},
...(domain === undefined ? {} : { normalizedDomain: domain }),
...(workspaceId === undefined ? {} : { normalizedWorkspaceId: workspaceId }),
}
const request = check.request(fields)
const response: ProviderResponse = check.egress
Expand Down
Loading
Loading