diff --git a/apps/sim/lib/compare/data/sim.ts b/apps/sim/lib/compare/data/sim.ts index 4a5f189c699..b685f1f627b 100644 --- a/apps/sim/lib/compare/data/sim.ts +++ b/apps/sim/lib/compare/data/sim.ts @@ -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)', 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', }, ], }, @@ -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', }, ], }, diff --git a/apps/sim/lib/credentials/databricks-service-account.server.test.ts b/apps/sim/lib/credentials/databricks-service-account.server.test.ts index 61a0e5c5b1f..e088e979c12 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.test.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.test.ts @@ -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', @@ -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({ + 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)) diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index 8b3d46aba38..8d50a390496 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -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, @@ -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. */ @@ -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 @@ -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 } diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 4b5509ea62b..b6e993c3158 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -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)) diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index 2bc713557ae..1fbaa9bbce1 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -271,6 +271,8 @@ export interface TokenServiceAccountValidationResult { storedMetadata?: Record /** 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 = ( diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts index 57cff93e77f..7889c7b562b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts @@ -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 () => { @@ -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' }) ) }) @@ -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' }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts index b76b6ca4d01..a4856417897 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts @@ -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 }) => { @@ -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 }) ) }) @@ -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' }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts index 0474ab705ad..9eefe6eb482 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts @@ -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, @@ -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, { url, method = 'GET', headers, body }: TokenCheckRequest ): Promise { + 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, @@ -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', + }) } } @@ -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 diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 5de8a993d5b..0e5ccc3b1bc 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -385,30 +385,30 @@ describe('saved credentials for a host the user chose', () => { it.each(HOST_CHOSEN_VALIDATORS)( '%s refuses credentials smuggled into the URL before sending the key anywhere', async (_, validate) => { - inputValidationMockFns.mockSecureFetchWithValidation.mockReset() + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockReset() await expect( validate({ apiToken: 'secret', domain: 'https://user:pass@instance.example.com' }) ).rejects.toMatchObject({ code: 'invalid_credentials' }) - expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled() + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() } ) it.each(HOST_CHOSEN_VALIDATORS)( '%s refuses a non-http scheme instead of reading it as a host', async (_, validate) => { - inputValidationMockFns.mockSecureFetchWithValidation.mockReset() + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockReset() await expect( validate({ apiToken: 'secret', domain: 'ftp://instance.example.com' }) ).rejects.toMatchObject({ code: 'invalid_credentials' }) - expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled() + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() } ) }) describe('saved credentials whose vendor answers a restricted token in its error', () => { - const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation + const secureFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP it.each([ [ @@ -436,6 +436,11 @@ describe('saved credentials whose vendor answers a restricted token in its error 'invalid_credentials', ], ])('%s', async (_, validate, reply, code) => { + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.7', + originalHostname: 'instance.example.com', + }) secureFetch.mockReset().mockResolvedValue(reply) const result = validate({ apiToken: 'tok', domain: 'https://instance.example.com' })