@@ -45,7 +45,7 @@ interface TokenCheck {
4545 * of a plain fetch.
4646 */
4747 egress ?: Extract < EgressProfile , 'configuredEndpoint' | 'selfHostedService' >
48- /** The vendor echoes the token in its error body, so that body is never read into logs. */
48+ /** The vendor echoes the token in its error body, so that body never reaches logs or error details . */
4949 errorBodyEchoesToken ?: boolean
5050 /**
5151 * A cheap documented read that succeeds only with a valid token — the same
@@ -62,14 +62,10 @@ interface TokenCheck {
6262 forbiddenMeansScoped ?: boolean
6363 /**
6464 * For a vendor that reports a rejected key inside a successful response (a
65- * GraphQL `errors` array): returns whether the parsed body proves the key.
65+ * GraphQL `errors` array): returns whether the parsed body proves the key, or
66+ * a display name read from it (a GitLab token's name) when it does.
6667 */
67- acceptsBody ?: ( body : unknown ) => boolean
68- /**
69- * Names the credential from the verified reply (a GitLab token's name), used
70- * as its default display name alongside the domain or account id.
71- */
72- describe ?: ( body : unknown ) => string | undefined
68+ acceptsBody ?: ( body : unknown ) => boolean | { displayName : string }
7369 /**
7470 * For a vendor whose error status alone doesn't say the key is bad (Fireflies
7571 * returns 500 with `auth_failed`; Brex returns its scope-error 403 for an
@@ -98,6 +94,49 @@ export function readJsonBody(response: ProviderResponse, serviceLabel: string):
9894 } )
9995}
10096
97+ /**
98+ * A typed http(s) base URL in the form tools build request paths on: a bare host
99+ * is taken as https, trailing slashes are dropped, and any other scheme,
100+ * credentials, a query, or a fragment is refused, since each would retarget the
101+ * request. `allowPath: false` also refuses a path, for tools that append to the
102+ * origin.
103+ */
104+ export function normalizeHttpBaseUrl (
105+ raw : string | undefined ,
106+ { allowPath = true } : { allowPath ?: boolean } = { }
107+ ) : string {
108+ const typed = ( raw ?? '' ) . trim ( )
109+ if ( ! typed ) throw new Error ( 'missing URL' )
110+ const url = new URL ( / ^ [ a - z ] [ a - z \d + . - ] * : \/ \/ / i. test ( typed ) ? typed : `https://${ typed } ` )
111+ if ( url . protocol !== 'https:' && url . protocol !== 'http:' ) throw new Error ( 'http(s) only' )
112+ if ( url . username || url . password || url . search || url . hash ) {
113+ throw new Error ( 'credentials, query, or fragment in URL' )
114+ }
115+ const path = url . pathname . replace ( / \/ + $ / , '' )
116+ if ( path && ! allowPath ) throw new Error ( 'path not allowed' )
117+ return `${ url . origin } ${ path } `
118+ }
119+
120+ /**
121+ * Refuses a typed URL that carries credentials, before a tool's own normalizer
122+ * can drop them silently and verify a different identity than the user meant.
123+ */
124+ export function refuseUrlCredentials ( raw : string | undefined ) : string {
125+ const typed = ( raw ?? '' ) . trim ( )
126+ const authority = typed . replace ( / ^ [ a - z ] [ a - z \d + . - ] * : \/ \/ / i, '' ) . split ( / [ / ? # ] / ) [ 0 ]
127+ if ( authority . includes ( '@' ) ) throw new Error ( 'credentials in URL' )
128+ return typed
129+ }
130+
131+ /** A domain that must be one of a fixed set (a region or regional host), compared case-insensitively. */
132+ export function allowedDomain ( allowed : ReadonlySet < string > ) {
133+ return ( { domain } : TokenServiceAccountFields ) : string => {
134+ const value = ( domain ?? '' ) . trim ( ) . toLowerCase ( )
135+ if ( ! allowed . has ( value ) ) throw new Error ( 'unknown region' )
136+ return value
137+ }
138+ }
139+
101140/** Runs `normalize` on a typed connection field, rejecting what it refuses as the user's input. */
102141function normalizedField (
103142 normalize : ( ( fields : TokenServiceAccountFields ) => string ) | undefined ,
@@ -224,6 +263,7 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid
224263 }
225264 if ( ! scopedForbidden && ! response . ok ) {
226265 if ( ! check . errorBodyEchoesToken ) await throwForProviderResponse ( response , 'verify' )
266+ // The body echoes the token, so it is dropped unread.
227267 await response . body ?. cancel ( ) . catch ( ( ) => undefined )
228268 throw new TokenServiceAccountValidationError (
229269 providerFailureCode ( response . status ) ,
@@ -240,11 +280,15 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid
240280 step : 'verify' ,
241281 } )
242282 }
243- if ( ! check . acceptsBody ( body ) ) {
283+ const verdict = check . acceptsBody ( body )
284+ if ( ! verdict ) {
244285 throw new TokenServiceAccountValidationError ( 'invalid_credentials' , 400 , { step : 'verify' } )
245286 }
246- const name = check . describe ?.( body )
247- return name ? { ...accepted , displayName : where ? `${ name } (${ where } )` : name } : accepted
287+ if ( typeof verdict === 'object' ) {
288+ const name = verdict . displayName
289+ return { ...accepted , displayName : where ? `${ name } (${ where } )` : name }
290+ }
291+ return accepted
248292 }
249293 await response . body ?. cancel ( ) . catch ( ( ) => undefined )
250294 return accepted
0 commit comments