Skip to content

Commit 5e07f86

Browse files
committed
fix(credentials): report a refused connection host as host_not_allowed
- egress validation names its refusal (dns or policy) instead of callers inferring it from a missing cause; an empty DNS answer is ENOTFOUND - the token check judges every DNS record when an environment proxy will carry the request, so a mixed public/private host is refused up front rather than failing in the transport as a retryable 502; the transport's own refusal is a typed EgressRefusedError and maps the same way - new host_not_allowed code (400) with its own connect-modal message - Flow and GitLab personal-token verification classify a refused or missing host the same way - the typed-URL scheme guard is shared and ignores the tabs and newlines the URL parser drops
1 parent 587d3a3 commit 5e07f86

14 files changed

Lines changed: 207 additions & 28 deletions

File tree

‎apps/sim/lib/core/network/transport.server.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { OutboundRoutingError } from '@/lib/core/network/routing'
66
import type { EgressProfile } from '@/lib/core/security/egress/profiles'
77
import { validateEgressUrl } from '@/lib/core/security/egress/validate'
88

9+
/** The egress policy refused a destination the transport was asked to reach through a proxy. */
10+
export class EgressRefusedError extends Error {
11+
constructor(message: string) {
12+
super(message)
13+
this.name = 'EgressRefusedError'
14+
}
15+
}
16+
917
interface OutboundTransportOptions {
1018
profile: EgressProfile
1119
resolvedIP?: string
@@ -58,7 +66,10 @@ export function createOutboundTransport(options: OutboundTransportOptions): Outb
5866
signal,
5967
requireEveryAddress: true,
6068
})
61-
if (!validation.isValid) throw new Error(validation.error, { cause: validation.cause })
69+
if (!validation.isValid) {
70+
if (validation.refusal === 'policy') throw new EgressRefusedError(validation.error)
71+
throw new Error(validation.error, { cause: validation.cause })
72+
}
6273
if (closed) throw new OutboundRoutingError('GATEWAY_UNAVAILABLE')
6374
let agent = environmentProxies.get(proxyUrl)
6475
if (!agent) {

‎apps/sim/lib/core/security/egress/validate.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ export interface EgressValidationSuccess {
3535
readonly originalHostname: string
3636
}
3737

38+
/** Why a destination failed: its name could not be resolved, or the policy refused it. */
39+
export type EgressRefusal = 'dns' | 'policy'
40+
3841
export interface EgressValidationFailure {
3942
readonly isValid: false
4043
readonly error: string
44+
readonly refusal: EgressRefusal
4145
/** Retains resolver failure codes so callers can distinguish an outage from a policy denial. */
4246
readonly cause?: unknown
4347
}
@@ -67,7 +71,11 @@ function fail(
6771
? { profile, reason: decision.reason, paramName }
6872
: { profile, reason: decision.reason, detail: decision.detail, paramName }
6973
)
70-
return { isValid: false, error: describeEgressDenial(decision, paramName, profile) }
74+
return {
75+
isValid: false,
76+
error: describeEgressDenial(decision, paramName, profile),
77+
refusal: 'policy',
78+
}
7179
}
7280

7381
/**
@@ -85,14 +93,18 @@ export async function validateEgressUrl(
8593
): Promise<EgressValidationResult> {
8694
options.signal?.throwIfAborted()
8795
if (!url || typeof url !== 'string') {
88-
return { isValid: false, error: `${paramName} is required and must be a string` }
96+
return {
97+
isValid: false,
98+
error: `${paramName} is required and must be a string`,
99+
refusal: 'policy',
100+
}
89101
}
90102

91103
let parsed: URL
92104
try {
93105
parsed = new URL(url)
94106
} catch {
95-
return { isValid: false, error: `${paramName} must be a valid URL` }
107+
return { isValid: false, error: `${paramName} must be a valid URL`, refusal: 'policy' }
96108
}
97109

98110
const policy = resolveEgressPolicy(profile)
@@ -129,7 +141,12 @@ export async function validateEgressUrl(
129141
? { profile, paramName }
130142
: { profile, paramName, host, error: toError(error).message }
131143
)
132-
return { isValid: false, error: `${paramName} hostname could not be resolved`, cause: error }
144+
return {
145+
isValid: false,
146+
error: `${paramName} hostname could not be resolved`,
147+
cause: error,
148+
refusal: 'dns',
149+
}
133150
}
134151

135152
// Refused records are filtered rather than failing the whole host: pinning to a

‎apps/sim/lib/core/security/input-validation.server.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ vi.mock('@sim/security/dns', () => ({
1111
addresses.find((address) => address.includes('.')) ?? addresses[0],
1212
}))
1313

14+
import { EgressRefusedError } from '@/lib/core/network/transport.server'
1415
import {
1516
secureFetchWithValidation,
1617
validateUrlWithDNS,
@@ -122,6 +123,13 @@ describe('validateUrlWithDNS address classification', () => {
122123
).rejects.toMatchObject({ message: 'url hostname could not be resolved', cause })
123124
})
124125

126+
it('throws a policy refusal as an egress refusal, distinct from a lookup failure', async () => {
127+
mockResolve.mockResolvedValue(resolved(['10.0.0.5']))
128+
await expect(
129+
secureFetchWithValidation('https://internal.example/api', { profile: 'configuredEndpoint' })
130+
).rejects.toBeInstanceOf(EgressRefusedError)
131+
})
132+
125133
it('can conceal credential-derived host details in validation logs', async () => {
126134
mockResolve.mockRejectedValue(new Error('DNS failure with credential-host-canary'))
127135

‎apps/sim/lib/core/security/input-validation.server.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ import {
2020
import { OutboundRoutingError } from '@/lib/core/network/routing'
2121
import {
2222
createOutboundTransport,
23+
EgressRefusedError,
2324
requestWithOutboundDispatcher,
2425
} from '@/lib/core/network/transport.server'
2526
import { describeEgressDenial, type EgressProfile } from '@/lib/core/security/egress/profiles'
2627
import {
2728
checkEgressUrl,
2829
checkResolvedEgress,
30+
type EgressRefusal,
2931
type EgressValidationOptions,
3032
validateEgressUrl,
3133
} from '@/lib/core/security/egress/validate'
@@ -48,6 +50,8 @@ export type AsyncValidationResult =
4850
isValid: false
4951
error: string
5052
cause?: unknown
53+
/** Set by {@link validateUrlWithDNS}: a failed lookup, or a destination the policy refused. */
54+
refusal?: EgressRefusal
5155
resolvedIP?: undefined
5256
originalHostname?: undefined
5357
}
@@ -74,7 +78,7 @@ export async function validateUrlWithDNS(
7478
const result = await validateEgressUrl(url, paramName, profile, options)
7579
return result.isValid
7680
? { isValid: true, resolvedIP: result.resolvedIP, originalHostname: result.originalHostname }
77-
: { isValid: false, error: result.error, cause: result.cause }
81+
: { isValid: false, error: result.error, cause: result.cause, refusal: result.refusal }
7882
}
7983

8084
/**
@@ -1582,7 +1586,7 @@ export async function secureFetchWithPinnedIP(
15821586
* @param options - Fetch options, including the required egress `profile`
15831587
* @param paramName - Name of the parameter for error messages (default: 'url')
15841588
* @returns SecureFetchResponse
1585-
* @throws Error if URL validation fails
1589+
* @throws EgressRefusedError if the egress policy refuses the URL, Error if its host cannot be resolved
15861590
*/
15871591
export async function secureFetchWithValidation(
15881592
url: string,
@@ -1594,6 +1598,7 @@ export async function secureFetchWithValidation(
15941598
signal: options.signal,
15951599
})
15961600
if (!validation.isValid) {
1601+
if (validation.refusal === 'policy') throw new EgressRefusedError(validation.error)
15971602
throw new Error(validation.error, { cause: validation.cause })
15981603
}
15991604
return secureFetchWithPinnedIP(url, validation.resolvedIP, options)

‎apps/sim/lib/credentials/gitlab-personal-token.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
88
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
99
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
1010

11+
import { EgressRefusedError } from '@/lib/core/network/transport.server'
1112
import {
1213
decryptPersonalToken,
1314
encryptPersonalToken,
@@ -96,6 +97,15 @@ describe('GitLab personal token verification', () => {
9697
await expect(verifyGitLabPersonalToken('secret')).rejects.toThrow('GitLab rejected this token')
9798
expect(mocks.fetch).toHaveBeenCalledTimes(1)
9899
})
100+
it('reports a host the egress policy refuses as the user input', async () => {
101+
mocks.fetch
102+
.mockReset()
103+
.mockRejectedValue(new EgressRefusedError('url resolves to a private address'))
104+
await expect(verifyGitLabPersonalToken('secret', 'gitlab.internal')).rejects.toMatchObject({
105+
code: 'validation',
106+
message: expect.stringContaining("can't connect to that address"),
107+
})
108+
})
99109
it('binds organization tokens independently of the execution workspace', async () => {
100110
mocks.encrypt.mockResolvedValue({ encrypted: 'ciphertext' })
101111
const { workspaceId: _workspaceId, ...identity } = envelope

‎apps/sim/lib/credentials/gitlab-personal-token.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod'
2+
import { EgressRefusedError } from '@/lib/core/network/transport.server'
23
import { OrchestrationError } from '@/lib/core/orchestration/types'
34
import { resourceScopeFromOwner, sameResourceScope } from '@/lib/core/resource-scope'
45
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
@@ -55,6 +56,14 @@ export async function verifyGitLabPersonalToken(accessToken: string, rawHost?: s
5556
timeout: 10_000,
5657
maxResponseBytes: 64 * 1024,
5758
maxRedirects: 0,
59+
}).catch((error: unknown) => {
60+
if (error instanceof EgressRefusedError) {
61+
throw new OrchestrationError(
62+
'validation',
63+
"Sim can't connect to that address. Use your GitLab instance's public https URL."
64+
)
65+
}
66+
throw error
5867
})
5968
if (!response.ok) {
6069
throw new OrchestrationError(

‎apps/sim/lib/credentials/token-service-accounts/descriptors.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2338,6 +2338,8 @@ export function getTokenServiceAccountErrorMessage(
23382338
)
23392339
case 'site_not_found':
23402340
return "We couldn't find an account at that domain. Check the spelling and try again."
2341+
case 'host_not_allowed':
2342+
return "Sim can't connect to that address. Use the service's public https URL."
23412343
case 'provider_unavailable':
23422344
return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
23432345
case 'duplicate_display_name':

‎apps/sim/lib/credentials/token-service-accounts/errors.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export type TokenServiceAccountValidationCode =
1414
| 'invalid_credentials'
1515
| 'permission_conflict'
1616
| 'site_not_found'
17+
/** The egress policy refused the typed host (a private address, or plain http). */
18+
| 'host_not_allowed'
1719
| 'provider_unavailable'
1820

1921
export class TokenServiceAccountValidationError extends Error {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {
2+
inputValidationMock,
3+
inputValidationMockFns,
4+
} from '@sim/testing/mocks/input-validation.mock'
5+
import { describe, expect, it, vi } from 'vitest'
6+
7+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
8+
9+
import { EgressRefusedError } from '@/lib/core/network/transport.server'
10+
import { validateFlowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/flow'
11+
12+
const fields = { apiToken: 'tok', domain: 'flow.internal', providerWorkspaceId: 'ws-1' }
13+
14+
describe('validateFlowServiceAccount', () => {
15+
it('reports a host the egress policy refuses as the user input, not a retryable outage', async () => {
16+
inputValidationMockFns.mockSecureFetchWithValidation.mockRejectedValue(
17+
new EgressRefusedError('url resolves to a private address')
18+
)
19+
20+
await expect(validateFlowServiceAccount(fields)).rejects.toMatchObject({
21+
code: 'host_not_allowed',
22+
status: 400,
23+
})
24+
})
25+
26+
it('reports a host that does not exist as not found', async () => {
27+
inputValidationMockFns.mockSecureFetchWithValidation.mockRejectedValue(
28+
new Error('url hostname could not be resolved', {
29+
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
30+
})
31+
)
32+
33+
await expect(validateFlowServiceAccount(fields)).rejects.toMatchObject({
34+
code: 'site_not_found',
35+
status: 400,
36+
})
37+
})
38+
})

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { toRecord } from '@sim/utils/object'
2+
import { EgressRefusedError } from '@/lib/core/network/transport.server'
23
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
34
import { userPrincipal } from '@/lib/credentials/principal'
45
import {
6+
isHostNotFound,
57
isTransientProviderStatus,
68
TokenServiceAccountValidationError,
79
} from '@/lib/credentials/token-service-accounts/errors'
@@ -60,6 +62,13 @@ export async function validateFlowServiceAccount(
6062
}
6163
} catch (error) {
6264
if (error instanceof TokenServiceAccountValidationError) throw error
65+
// A host the egress policy refuses, or one that doesn't exist, is the user's input.
66+
if (error instanceof EgressRefusedError) {
67+
throw new TokenServiceAccountValidationError('host_not_allowed', 400)
68+
}
69+
if (error instanceof Error && isHostNotFound(error.cause)) {
70+
throw new TokenServiceAccountValidationError('site_not_found', 400)
71+
}
6372
throw new TokenServiceAccountValidationError('provider_unavailable', 502)
6473
}
6574
}

0 commit comments

Comments
 (0)