Skip to content

Commit f4f5a17

Browse files
committed
fix(snowflake): verify key pairs through the egress guard and leave an organization reconnect's mode unchosen
Key-pair validation now sends its signed JWT through the same guarded egress path as keyless, so an account host that resolves to a private address is refused as host_not_allowed. An organization credential's stored mode is not reported, so its reconnect starts with no mode chosen instead of defaulting to a programmatic access token. The Infisical and ServiceNow grant tests use the shared jsonResponse helper.
1 parent 149c68e commit f4f5a17

6 files changed

Lines changed: 112 additions & 98 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,15 @@ function SnowflakeAccountForm({
156156
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
157157
const [description, setDescription] = useState(initialDescription ?? '')
158158

159-
// A reconnect opens on the credential's stored mode, so it never switches auth by default;
160-
// a new connection defaults to keyless wherever this deployment supports it.
161-
const defaultAuthType: SnowflakeAuthType = credentialId
162-
? (keyless.reconnectAuthType ?? 'programmatic_access_token')
159+
// A reconnect opens on the credential's stored mode, so it never switches auth by default. An
160+
// organization credential's mode is not reported, so its reconnect starts with no mode chosen
161+
// rather than a guess. A new connection defaults to keyless wherever this deployment supports it.
162+
const defaultAuthType: SnowflakeAuthType | null = credentialId
163+
? (keyless.reconnectAuthType ?? null)
163164
: keyless.available
164165
? 'workload_identity'
165166
: 'programmatic_access_token'
166-
const authType: SnowflakeAuthType = selectedAuthType ?? defaultAuthType
167+
const authType = selectedAuthType ?? defaultAuthType
167168
const connectionSubmit = useSavedConnectionSubmit({
168169
providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
169170
workspaceId,
@@ -178,6 +179,7 @@ function SnowflakeAccountForm({
178179
connectionSubmit.isPending || createCredential.isPending || updateCredential.isPending
179180
const disabled =
180181
isPending ||
182+
!authType ||
181183
!normalizedHost ||
182184
(authType === 'programmatic_access_token' && !token.trim()) ||
183185
(authType === 'key_pair' && (!user.trim() || !privateKey.trim())) ||
@@ -277,15 +279,15 @@ function SnowflakeAccountForm({
277279
<ChipModalField
278280
type='dropdown'
279281
title='Authentication'
280-
value={authType}
282+
value={authType ?? undefined}
281283
onChange={(value) => {
282284
setSelectedAuthType(value as SnowflakeAuthType)
283285
clearErrors()
284286
}}
285287
options={authOptions}
286288
placeholder='Choose how Sim signs in to Snowflake'
287289
required
288-
hint={AUTH_TYPE_HINTS[authType]}
290+
hint={authType ? AUTH_TYPE_HINTS[authType] : undefined}
289291
/>
290292
<ChipModalField
291293
type='input'

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

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,6 @@ describe('a Snowflake key-pair connection', () => {
255255
const FINGERPRINT = `SHA256:${createHash('sha256')
256256
.update(publicKey.export({ type: 'spki', format: 'der' }))
257257
.digest('base64')}`
258-
const fetchMock = vi.fn<typeof fetch>()
259258

260259
function keyPairConfig(fields: Record<string, unknown> = {}) {
261260
return JSON.stringify({
@@ -267,34 +266,31 @@ describe('a Snowflake key-pair connection', () => {
267266
})
268267
}
269268

270-
/** Snowflake's side of key-pair auth: a KEYPAIR_JWT bearer token whose claims and key match. */
269+
/** Snowflake's side of key-pair auth, behind the egress guard: a KEYPAIR_JWT bearer token whose claims and key match. */
271270
function snowflakeAccountTrustingKey(account: string) {
272-
fetchMock.mockImplementation(async (_input, init) => {
273-
const headers = new Headers(init?.headers)
274-
const authorization = headers.get('authorization') ?? ''
275-
if (headers.get('x-snowflake-authorization-token-type') !== 'KEYPAIR_JWT') {
276-
return jsonResponse({ message: 'Invalid token type' }, 401)
271+
secureFetch.mockImplementation(
272+
async (_url: string, init: { headers: Record<string, string> }) => {
273+
const headers = new Headers(init?.headers)
274+
const authorization = headers.get('authorization') ?? ''
275+
if (headers.get('x-snowflake-authorization-token-type') !== 'KEYPAIR_JWT') {
276+
return jsonResponse({ message: 'Invalid token type' }, 401)
277+
}
278+
try {
279+
const { payload } = await jwtVerify(authorization.replace(/^Bearer /, ''), publicKey, {
280+
algorithms: ['RS256'],
281+
issuer: `${account}.SIM_WORKFLOWS.${FINGERPRINT}`,
282+
subject: `${account}.SIM_WORKFLOWS`,
283+
requiredClaims: ['iat', 'exp'],
284+
})
285+
if ((payload.exp ?? 0) - (payload.iat ?? 0) > 3600) throw new Error('lifetime too long')
286+
} catch {
287+
return jsonResponse({ code: '390144', message: 'JWT token is invalid' }, 401)
288+
}
289+
return jsonResponse({ data: [['SIM_WORKFLOWS', account, 'ANALYST']] })
277290
}
278-
try {
279-
const { payload } = await jwtVerify(authorization.replace(/^Bearer /, ''), publicKey, {
280-
algorithms: ['RS256'],
281-
issuer: `${account}.SIM_WORKFLOWS.${FINGERPRINT}`,
282-
subject: `${account}.SIM_WORKFLOWS`,
283-
requiredClaims: ['iat', 'exp'],
284-
})
285-
if ((payload.exp ?? 0) - (payload.iat ?? 0) > 3600) throw new Error('lifetime too long')
286-
} catch {
287-
return jsonResponse({ code: '390144', message: 'JWT token is invalid' }, 401)
288-
}
289-
return jsonResponse({ data: [['SIM_WORKFLOWS', account, 'ANALYST']] })
290-
})
291+
)
291292
}
292293

293-
beforeEach(() => {
294-
fetchMock.mockReset()
295-
vi.stubGlobal('fetch', fetchMock)
296-
})
297-
298294
it('signs in with a JWT naming the account, the uppercased user, and the key fingerprint', async () => {
299295
snowflakeAccountTrustingKey('MYORG-MYACCOUNT')
300296

@@ -315,7 +311,7 @@ describe('a Snowflake key-pair connection', () => {
315311
privateKey: PRIVATE_KEY.trim(),
316312
},
317313
})
318-
expect(new URL(String(fetchMock.mock.calls[0][0])).href).toBe(
314+
expect(new URL(String(secureFetch.mock.calls[0][0])).href).toBe(
319315
`https://${HOST}/api/v2/statements`
320316
)
321317
})
@@ -386,7 +382,7 @@ describe('a Snowflake key-pair connection', () => {
386382
const saving = verifyAndEncryptSnowflakeConnection(keyPairConfig({ privateKey: key }), owner)
387383
await expect(saving).rejects.toBeInstanceOf(SnowflakeCredentialError)
388384
await expect(saving).rejects.toThrow(/key/i)
389-
expect(fetchMock).not.toHaveBeenCalled()
385+
expect(secureFetch).not.toHaveBeenCalled()
390386
})
391387

392388
it('refuses a host outside Snowflake before signing anything', async () => {
@@ -395,7 +391,7 @@ describe('a Snowflake key-pair connection', () => {
395391
verifyAndEncryptSnowflakeConnection(keyPairConfig({ host: 'evil.example.com' }), owner)
396392
)
397393
).toMatch(/host/i)
398-
expect(fetchMock).not.toHaveBeenCalled()
394+
expect(secureFetch).not.toHaveBeenCalled()
399395
})
400396

401397
it('signs a fresh JWT for each run with the key-pair token type', async () => {

‎apps/sim/lib/credentials/token-service-accounts/grants/infisical.test.ts‎

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { jsonResponse } from '@sim/testing/helpers/http'
12
import {
23
inputValidationMock,
34
inputValidationMockFns,
@@ -21,13 +22,6 @@ const FIELDS = {
2122

2223
const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation
2324

24-
function login(body: unknown, status = 200) {
25-
return new Response(JSON.stringify(body), {
26-
status,
27-
headers: { 'Content-Type': 'application/json' },
28-
})
29-
}
30-
3125
describe('Infisical Universal Auth login', () => {
3226
beforeEach(() => {
3327
secureFetch.mockReset()
@@ -48,7 +42,7 @@ describe('Infisical Universal Auth login', () => {
4842

4943
it('logs in with a JSON body at the instance through the configured-endpoint guard', async () => {
5044
secureFetch.mockResolvedValueOnce(
51-
login({
45+
jsonResponse({
5246
accessToken: 'machine-token',
5347
expiresIn: 7200,
5448
accessTokenMaxTTL: 2_592_000,
@@ -84,7 +78,7 @@ describe('Infisical Universal Auth login', () => {
8478

8579
it('serves a long-lived token for at most a day so an early revocation is picked up', async () => {
8680
secureFetch.mockResolvedValueOnce(
87-
login({ accessToken: 'long-token', expiresIn: 2_592_000, tokenType: 'Bearer' })
81+
jsonResponse({ accessToken: 'long-token', expiresIn: 2_592_000, tokenType: 'Bearer' })
8882
)
8983

9084
const minted = await mintInfisicalUniversalAuthToken(FIELDS)
@@ -95,7 +89,7 @@ describe('Infisical Universal Auth login', () => {
9589

9690
it('serves the token of an identity whose access token TTL is 0 (no expiry) for a day', async () => {
9791
secureFetch.mockResolvedValueOnce(
98-
login({ accessToken: 'unbounded-token', expiresIn: 0, tokenType: 'Bearer' })
92+
jsonResponse({ accessToken: 'unbounded-token', expiresIn: 0, tokenType: 'Bearer' })
9993
)
10094

10195
const minted = await mintInfisicalUniversalAuthToken(FIELDS)
@@ -112,7 +106,7 @@ describe('Infisical Universal Auth login', () => {
112106
['a fractional lifetime', { accessToken: 'token', expiresIn: 1.5 }],
113107
['a non-object body', ['token']],
114108
])('treats %s as the provider failing, not the credentials', async (_case, body) => {
115-
secureFetch.mockResolvedValueOnce(login(body))
109+
secureFetch.mockResolvedValueOnce(jsonResponse(body))
116110

117111
await expect(mintInfisicalUniversalAuthToken(FIELDS)).rejects.toMatchObject({
118112
code: 'provider_unavailable',
@@ -128,7 +122,7 @@ describe('Infisical Universal Auth login', () => {
128122
[500, 'provider_unavailable'],
129123
])('classifies a %s login refusal as %s', async (status, code) => {
130124
secureFetch.mockResolvedValueOnce(
131-
login({ statusCode: status, message: 'nope', error: 'Unauthorized' }, status)
125+
jsonResponse({ statusCode: status, message: 'nope', error: 'Unauthorized' }, status)
132126
)
133127

134128
await expect(mintInfisicalUniversalAuthToken(FIELDS)).rejects.toMatchObject({ code, status })

‎apps/sim/lib/credentials/token-service-accounts/grants/servicenow.test.ts‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { jsonResponse } from '@sim/testing/helpers/http'
12
import {
23
inputValidationMock,
34
inputValidationMockFns,
@@ -21,13 +22,6 @@ const FIELDS = {
2122

2223
const secureFetch = inputValidationMockFns.mockSecureFetchWithValidation
2324

24-
function tokenResponse(body: unknown, status = 200) {
25-
return new Response(JSON.stringify(body), {
26-
status,
27-
headers: { 'Content-Type': 'application/json' },
28-
})
29-
}
30-
3125
describe('ServiceNow client credentials grant', () => {
3226
beforeEach(() => {
3327
secureFetch.mockReset()
@@ -48,7 +42,7 @@ describe('ServiceNow client credentials grant', () => {
4842

4943
it('posts the grant as a form to the instance oauth_token.do through the egress guard', async () => {
5044
secureFetch.mockResolvedValueOnce(
51-
tokenResponse({ access_token: 'sn-token', expires_in: 1799, token_type: 'Bearer' })
45+
jsonResponse({ access_token: 'sn-token', expires_in: 1799, token_type: 'Bearer' })
5246
)
5347

5448
const minted = await mintServiceNowClientCredentialsToken(FIELDS)

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock
1010
import { EgressRefusedError } from '@/lib/core/network/transport.server'
1111
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
1212
import {
13+
validateSnowflakeKeyPair,
1314
validateSnowflakeServiceAccount,
1415
validateSnowflakeWorkloadIdentity,
1516
} from '@/lib/credentials/token-service-accounts/validators/snowflake'
@@ -117,3 +118,26 @@ describe('validateSnowflakeWorkloadIdentity', () => {
117118
)
118119
})
119120
})
121+
122+
describe('validateSnowflakeKeyPair', () => {
123+
beforeEach(() => {
124+
vi.stubGlobal('fetch', mockFetch)
125+
})
126+
127+
afterEach(() => {
128+
vi.resetAllMocks()
129+
})
130+
131+
it('sends the signed JWT only through the egress guard', async () => {
132+
inputValidationMockFns.mockSecureFetchWithValidation.mockRejectedValueOnce(
133+
new EgressRefusedError('private address')
134+
)
135+
136+
await expectCode(
137+
validateSnowflakeKeyPair({ domain: fields.domain, jwt: 'signed-jwt' }),
138+
'host_not_allowed',
139+
400
140+
)
141+
expect(mockFetch).not.toHaveBeenCalled()
142+
})
143+
})

0 commit comments

Comments
 (0)