11import { truncate } from '@sim/utils/string'
2+ import type { EgressProfile } from '@/lib/core/security/egress/profiles'
3+ import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
24import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
35import {
46 fetchProvider ,
@@ -19,12 +21,43 @@ interface TokenCheckRequest {
1921 body ?: string
2022}
2123
24+ /** The parts of a provider response a check reads; both `fetch` and the guarded egress fetch return them. */
25+ interface CheckResponse {
26+ ok : boolean
27+ status : number
28+ headers : { get ( name : string ) : string | null }
29+ body : ReadableStream < Uint8Array > | null
30+ }
31+
2232interface TokenCheck {
2333 /** Service name used as the default display name. */
2434 serviceLabel : string
35+ /**
36+ * For a credential whose domain is part of the connection (a host, instance
37+ * URL, or region): normalizes what was typed into exactly the form the tools
38+ * use, throwing for anything they couldn't safely use. The result is what
39+ * {@link request} sees and what the credential stores, so runs reach only the
40+ * verified host.
41+ */
42+ domain ?: ( fields : TokenServiceAccountFields ) => string
43+ /**
44+ * For a credential whose account id is part of the connection (an org slug,
45+ * account SID): returns it trimmed, throwing for a value that could steer the
46+ * request's path or host.
47+ */
48+ workspaceId ?: ( fields : TokenServiceAccountFields ) => string
49+ /**
50+ * The request goes to a host the user chose (self-hosted or white-label), so
51+ * it runs through the same SSRF-guarded egress profile as the tools instead
52+ * of a plain fetch.
53+ */
54+ egress ?: Extract < EgressProfile , 'configuredEndpoint' | 'selfHostedService' >
55+ /** The vendor echoes the token in its error body, so that body is never read into logs. */
56+ errorBodyEchoesToken ?: boolean
2557 /**
2658 * A cheap documented read that succeeds only with a valid token — the same
27- * host and auth scheme the service's tools use.
59+ * host and auth scheme the service's tools use. `fields` carries the
60+ * normalized domain and account id.
2861 */
2962 request : ( fields : TokenServiceAccountFields ) => TokenCheckRequest
3063 /**
@@ -56,34 +89,99 @@ interface TokenCheck {
5689}
5790
5891const MAX_CHECK_RESPONSE_BYTES = 64 * 1024
92+ /** The guarded fetch refuses a larger declared body outright; JSON reads stay capped at {@link MAX_CHECK_RESPONSE_BYTES}. */
93+ const MAX_EGRESS_RESPONSE_BYTES = 1024 * 1024
5994
6095/** Reads a credential check's JSON response, capped so a hostile body can't exhaust memory. */
61- export function readJsonBody ( response : Response , serviceLabel : string ) : Promise < unknown > {
96+ export function readJsonBody ( response : CheckResponse , serviceLabel : string ) : Promise < unknown > {
6297 return readResponseJsonWithLimit ( response , {
6398 maxBytes : MAX_CHECK_RESPONSE_BYTES ,
6499 label : `${ serviceLabel } credential check` ,
65100 } )
66101}
67102
103+ /** Runs `normalize` on a typed connection field, rejecting what it refuses as the user's input. */
104+ function normalizedField (
105+ normalize : ( ( fields : TokenServiceAccountFields ) => string ) | undefined ,
106+ fields : TokenServiceAccountFields ,
107+ field : 'domain' | 'account id'
108+ ) : string | undefined {
109+ if ( ! normalize ) return undefined
110+ try {
111+ return normalize ( fields )
112+ } catch {
113+ throw new TokenServiceAccountValidationError ( 'invalid_credentials' , 400 , {
114+ step : 'verify' ,
115+ reason : `invalid ${ field } ` ,
116+ } )
117+ }
118+ }
119+
120+ /** Sends a check through the guarded egress profile, mapping a host that doesn't exist to `site_not_found`. */
121+ async function sendThroughEgress (
122+ profile : NonNullable < TokenCheck [ 'egress' ] > ,
123+ { url, method = 'GET' , headers, body } : TokenCheckRequest
124+ ) : Promise < CheckResponse > {
125+ try {
126+ return await secureFetchWithValidation ( url , {
127+ profile,
128+ method,
129+ headers,
130+ body,
131+ timeout : 10_000 ,
132+ maxRedirects : 0 ,
133+ maxResponseBytes : MAX_EGRESS_RESPONSE_BYTES ,
134+ logUrlValidationDetails : false ,
135+ } )
136+ } catch ( error ) {
137+ // Only ENOTFOUND proves the host doesn't exist; other lookup and network failures may pass.
138+ const notFound = ( error as { cause ?: { code ?: unknown } } ) ?. cause ?. code === 'ENOTFOUND'
139+ throw new TokenServiceAccountValidationError (
140+ notFound ? 'site_not_found' : 'provider_unavailable' ,
141+ notFound ? 400 : 502 ,
142+ { step : 'verify' , reason : notFound ? 'host does not resolve' : 'host unreachable' }
143+ )
144+ }
145+ }
146+
68147/**
69148 * Validator for a service whose token is accepted or rejected by one
70149 * authenticated request: 2xx accepts, a non-transient 4xx rejects the token,
71150 * anything else is the provider being unavailable. Redirects are refused, so a
72151 * token is never forwarded to another host.
73152 */
74153export function tokenCheckValidator ( check : TokenCheck ) : TokenServiceAccountValidator {
75- const accepted : TokenServiceAccountValidationResult = {
76- displayName : check . serviceLabel ,
77- principal : null ,
78- auditMetadata : { } ,
79- }
80- return async ( fields ) : Promise < TokenServiceAccountValidationResult > => {
81- const { url, method = 'GET' , headers, body } = check . request ( fields )
82- const response = await fetchProvider (
83- url ,
84- { method, headers, body, redirect : 'error' } ,
85- 'verify'
86- )
154+ return async ( typed ) : Promise < TokenServiceAccountValidationResult > => {
155+ const domain = normalizedField ( check . domain , typed , 'domain' )
156+ const workspaceId = normalizedField ( check . workspaceId , typed , 'account id' )
157+ const fields = {
158+ ...typed ,
159+ ...( domain === undefined ? { } : { domain } ) ,
160+ ...( workspaceId === undefined ? { } : { providerWorkspaceId : workspaceId } ) ,
161+ }
162+ const accepted : TokenServiceAccountValidationResult = {
163+ displayName : domain ? `${ check . serviceLabel } (${ domain } )` : check . serviceLabel ,
164+ principal : null ,
165+ auditMetadata : { } ,
166+ ...( domain === undefined ? { } : { normalizedDomain : domain } ) ,
167+ }
168+ const request = check . request ( fields )
169+ const response : CheckResponse = check . egress
170+ ? await sendThroughEgress ( check . egress , request )
171+ : await fetchProvider (
172+ request . url ,
173+ {
174+ method : request . method ?? 'GET' ,
175+ headers : request . headers ,
176+ body : request . body ,
177+ redirect : 'error' ,
178+ } ,
179+ 'verify' ,
180+ // A host built from the typed domain or account id that doesn't exist is the user's typo.
181+ domain === undefined && workspaceId === undefined
182+ ? undefined
183+ : { dnsFailureCode : 'site_not_found' }
184+ )
87185 // A JSON 403 is the vendor's own answer; an HTML one is a firewall or CDN page.
88186 const scopedForbidden =
89187 check . forbiddenMeansScoped &&
@@ -115,12 +213,21 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid
115213 response . status ,
116214 {
117215 step : 'verify' ,
118- body : errorBody === unreadable ? '' : truncate ( JSON . stringify ( errorBody ) ?? '' , 500 ) ,
216+ body :
217+ errorBody === unreadable || check . errorBodyEchoesToken
218+ ? ''
219+ : truncate ( JSON . stringify ( errorBody ) ?? '' , 500 ) ,
119220 }
120221 )
121222 }
122- if ( ! scopedForbidden ) {
123- await throwForProviderResponse ( response , 'verify' )
223+ if ( ! scopedForbidden && ! response . ok ) {
224+ if ( ! check . errorBodyEchoesToken ) await throwForProviderResponse ( response , 'verify' )
225+ await response . body ?. cancel ( ) . catch ( ( ) => undefined )
226+ throw new TokenServiceAccountValidationError (
227+ providerFailureCode ( response . status ) ,
228+ response . status ,
229+ { step : 'verify' }
230+ )
124231 }
125232 if ( check . acceptsBody && response . ok ) {
126233 let body : unknown
0 commit comments