Skip to content

Commit 837f676

Browse files
committed
refactor(credentials): host-aware token checks in the shared validator factory
tokenCheckValidator now takes a domain normalizer (stored as the credential's domain), an account-id normalizer, an SSRF-guarded egress profile for hosts the user chose, and a flag that keeps token-echoing error bodies out of logs. GitLab becomes a declaration on it, so every token check reads the same way.
1 parent 7299c3e commit 837f676

6 files changed

Lines changed: 166 additions & 117 deletions

File tree

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,16 @@ export async function parseProviderJson<T>(res: Response, step: string): Promise
153153
}
154154
}
155155

156+
/** The parts of a provider response the error helpers read; a guarded egress response has them too. */
157+
type ProviderErrorResponse = Pick<Response, 'ok' | 'status' | 'body'> & {
158+
headers: { get(name: string): string | null }
159+
}
160+
156161
/**
157162
* Reads a bounded snippet of a provider error body for server logs. Never
158163
* throws — an unreadable body logs as an empty string.
159164
*/
160-
export async function readProviderErrorSnippet(res: Response): Promise<string> {
165+
export async function readProviderErrorSnippet(res: ProviderErrorResponse): Promise<string> {
161166
try {
162167
// Bounded: a provider or proxy can answer with an arbitrarily large error body.
163168
const text = await readResponseTextWithLimit(res, {
@@ -189,7 +194,7 @@ export async function readProviderErrorSnippet(res: Response): Promise<string> {
189194
* Snowflake, Atlassian) already raise `site_not_found` before reaching here.
190195
*/
191196
export async function throwForProviderResponse(
192-
res: Response,
197+
res: ProviderErrorResponse,
193198
step: string,
194199
context: Record<string, unknown> = {}
195200
): Promise<void> {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ import { validateClickupServiceAccount } from '@/lib/credentials/token-service-a
110110
import { validateCodaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/coda'
111111
import { validateFlowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/flow'
112112
import { validateGaugeServiceAccount } from '@/lib/credentials/token-service-accounts/validators/gauge'
113-
import { validateGitLabServiceAccount } from '@/lib/credentials/token-service-accounts/validators/gitlab'
114113
import { validateHarmonicServiceAccount } from '@/lib/credentials/token-service-accounts/validators/harmonic'
115114
import { validateHiggsfieldServiceAccount } from '@/lib/credentials/token-service-accounts/validators/higgsfield'
116115
import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot'
@@ -149,6 +148,7 @@ import {
149148
validateFirefliesServiceAccount,
150149
validateFlintServiceAccount,
151150
validateGammaServiceAccount,
151+
validateGitLabServiceAccount,
152152
validateGrainServiceAccount,
153153
validateGranolaServiceAccount,
154154
validateHexServiceAccount,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1010

1111
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
1212

13-
import { validateGitLabServiceAccount } from '@/lib/credentials/token-service-accounts/validators/gitlab'
13+
import { validateGitLabServiceAccount } from '@/lib/credentials/token-service-accounts/validators/token-checks'
1414

1515
const activeToken = { name: 'sim-workflows', active: true, revoked: false, scopes: ['api'] }
1616

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

Lines changed: 0 additions & 96 deletions
This file was deleted.

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

Lines changed: 124 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { 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'
24
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
35
import {
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+
2232
interface 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

5891
const 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
*/
74153
export 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

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '@/lib/credentials/token-service-accounts/validators/http'
1515
import { ashbyAuthHeaders } from '@/tools/ashby/utils'
1616
import { checkrHeaders } from '@/tools/checkr/utils'
17+
import { normalizeGitLabHost } from '@/tools/gitlab/utils'
1718
import { GRANOLA_API_BASE, granolaHeaders } from '@/tools/granola/utils'
1819
import { WHATSAPP_GRAPH_VERSION } from '@/tools/whatsapp/utils'
1920

@@ -423,6 +424,38 @@ export const validateGranolaServiceAccount = tokenCheckValidator({
423424
}),
424425
})
425426

427+
/** Scopes that let the GitLab tools call the REST API. */
428+
const GITLAB_API_SCOPES = new Set(['api', 'read_api'])
429+
430+
/**
431+
* GitLab's token self-lookup answers for a personal, project, or group access token at the
432+
* instance it was typed with (gitlab.com or self-managed); the token must be active and carry an
433+
* API scope. A reply that isn't a token lookup is the instance misbehaving, not a bad token.
434+
*/
435+
export const validateGitLabServiceAccount = tokenCheckValidator({
436+
serviceLabel: 'GitLab',
437+
domain: ({ domain }) => normalizeGitLabHost(domain),
438+
egress: 'configuredEndpoint',
439+
request: ({ apiToken, domain }) => ({
440+
url: `https://${domain}/api/v4/personal_access_tokens/self`,
441+
headers: { 'PRIVATE-TOKEN': apiToken, Accept: 'application/json' },
442+
}),
443+
acceptsBody: (body) => {
444+
const token = toRecord(body)
445+
if (typeof token.active !== 'boolean' || !Array.isArray(token.scopes)) {
446+
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
447+
step: 'verify',
448+
reason: 'GitLab returned an unexpected token lookup',
449+
})
450+
}
451+
return (
452+
token.active &&
453+
token.revoked !== true &&
454+
token.scopes.some((scope) => GITLAB_API_SCOPES.has(String(scope)))
455+
)
456+
},
457+
})
458+
426459
/** Hex's list-projects endpoint (the one the Hex tools call) answers 200 for a valid token and 401 otherwise. */
427460
export const validateHexServiceAccount = tokenCheckValidator({
428461
serviceLabel: 'Hex',

0 commit comments

Comments
 (0)