Skip to content

Commit 6712f35

Browse files
committed
fix(sailpoint): address review - SSRF host allowlist, working aggregate, retry/count fixes
- Restrict tenant host resolution to *.api.identitynow.com / *.api.identitynowgov.com (or a bare tenant subdomain) so the client-credentials request can never post the PAT secret to an attacker-controlled or internal host. Throws on any other host. - Make search_aggregate functional: add an aggregationsDsl input (tool/contract/route/block) and preserve the AggregationResult object instead of dropping it through the list handler. - Retry the token exchange on 429 with Retry-After backoff, matching sailpointFetch. - Rebuild the multipart FormData per attempt so 401/429 retries never reuse a consumed body. - Expose an "Include Total Count" toggle so totalCount is reachable from the block UI. - Regenerate docs. Add route tests for the SSRF guard and aggregate object preservation.
1 parent 63e9e55 commit 6712f35

10 files changed

Lines changed: 223 additions & 54 deletions

File tree

‎apps/docs/content/docs/en/integrations/sailpoint.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -717,14 +717,15 @@ Run a global search across SailPoint indices (identities, entitlements, roles, a
717717

718718
### `sailpoint_search_aggregate`
719719

720-
Return aggregation buckets for a SailPoint search query (e.g. counts grouped by a field).
720+
Return the aggregation result (buckets under `aggregations`, plus `hits`) for a SailPoint search query.
721721

722722
#### Input
723723

724724
| Parameter | Type | Required | Description |
725725
| --------- | ---- | -------- | ----------- |
726726
| `indices` | json | No | Indices to aggregate over \(defaults to \["identities"\]\) |
727-
| `query` | string | No | Elasticsearch query string |
727+
| `query` | string | No | Elasticsearch query string to scope the documents before aggregating |
728+
| `aggregationsDsl` | json | No | Elasticsearch aggregations DSL object defining the buckets/metrics to compute, e.g. \{ "department": \{ "terms": \{ "field": "attributes.department" \} \} \} |
728729
| `limit` | number | No | Maximum number of aggregation results \(max 250\) |
729730
| `offset` | number | No | Pagination offset \(0-based\) |
730731

‎apps/sim/app/api/tools/sailpoint/client.ts‎

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,48 @@ export function normalizeApiVersion(value: string | undefined | null): SailPoint
4646
}
4747

4848
/**
49-
* Resolves the API + token hosts for a tenant. Accepts either a bare tenant subdomain (`acme`) or a
50-
* full host/URL (`https://acme.api.identitynow.com`, `acme.api.identitynow.com`), stripping any
51-
* protocol, path, or version segment the caller may have included.
49+
* Allowed SailPoint API host suffixes. A user-supplied full host must end with one of these; this
50+
* prevents SSRF and PAT-secret disclosure by ensuring the client-credentials request (which carries
51+
* `client_secret`) can only ever be sent to a SailPoint tenant host, never an arbitrary destination.
52+
*/
53+
const ALLOWED_HOST_SUFFIXES = ['.api.identitynow.com', '.api.identitynowgov.com'] as const
54+
55+
/**
56+
* Resolves the API + token hosts for a tenant. Accepts either a bare tenant subdomain (`acme` →
57+
* `acme.api.identitynow.com`) or a full SailPoint host/URL (`https://acme.api.identitynow.com`),
58+
* stripping any protocol, path, or version segment. Throws when the resolved host is not a SailPoint
59+
* identitynow.com host - the credentials must never be posted to an attacker-controlled or internal
60+
* host.
5261
*/
5362
export function resolveSailPointHosts(
5463
tenant: string,
5564
apiVersion: SailPointApiVersion
5665
): SailPointHosts {
5766
let host = tenant.trim().replace(/^https?:\/\//i, '')
58-
host = host.replace(/[/?#].*$/, '').replace(/\.+$/, '')
59-
if (!host.includes('.')) {
67+
host = host
68+
.replace(/[/?#].*$/, '')
69+
.replace(/\.+$/, '')
70+
.toLowerCase()
71+
72+
if (!host) {
73+
throw new Error('SailPoint tenant is required')
74+
}
75+
76+
if (host.includes('.')) {
77+
const isAllowed =
78+
/^[a-z0-9.-]+$/.test(host) && ALLOWED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))
79+
if (!isAllowed) {
80+
throw new Error(
81+
`SailPoint host "${host}" is not an allowed identitynow.com host. Enter your tenant name (e.g. "acme") or a full *.api.identitynow.com host.`
82+
)
83+
}
84+
} else {
85+
if (!/^[a-z0-9][a-z0-9-]*$/.test(host)) {
86+
throw new Error(`Invalid SailPoint tenant "${tenant}"`)
87+
}
6088
host = `${host}.api.identitynow.com`
6189
}
90+
6291
return {
6392
host,
6493
apiBaseUrl: `https://${host}/${apiVersion}`,
@@ -92,10 +121,12 @@ interface CachedToken {
92121
}
93122

94123
const TOKEN_EXPIRY_BUFFER_MS = 60_000
124+
const MAX_FETCH_RETRIES = 4
95125
const tokenCache = new Map<string, CachedToken>()
96126

97127
function cacheKey(creds: SailPointServerCredentials): string {
98-
return `${creds.tenant}:${creds.clientId}:${creds.apiVersion}`
128+
const { host } = resolveSailPointHosts(creds.tenant, creds.apiVersion)
129+
return `${host}:${creds.clientId}:${creds.apiVersion}`
99130
}
100131

101132
/** Drops any cached token for these credentials so the next call re-exchanges. */
@@ -112,19 +143,32 @@ export async function getSailPointAccessToken(creds: SailPointServerCredentials)
112143
}
113144

114145
const { tokenUrl } = resolveSailPointHosts(creds.tenant, creds.apiVersion)
115-
const response = await fetch(tokenUrl, {
116-
method: 'POST',
117-
headers: {
118-
Accept: 'application/json',
119-
'Content-Type': 'application/x-www-form-urlencoded',
120-
},
121-
body: new URLSearchParams({
122-
grant_type: 'client_credentials',
123-
client_id: creds.clientId,
124-
client_secret: creds.clientSecret,
125-
}).toString(),
126-
cache: 'no-store',
127-
})
146+
147+
let attempt = 0
148+
let response: Response
149+
while (true) {
150+
response = await fetch(tokenUrl, {
151+
method: 'POST',
152+
headers: {
153+
Accept: 'application/json',
154+
'Content-Type': 'application/x-www-form-urlencoded',
155+
},
156+
body: new URLSearchParams({
157+
grant_type: 'client_credentials',
158+
client_id: creds.clientId,
159+
client_secret: creds.clientSecret,
160+
}).toString(),
161+
cache: 'no-store',
162+
})
163+
// The token endpoint shares SailPoint's per-client_id rate limit, so back off on a 429 too.
164+
if (response.status === 429 && attempt < MAX_FETCH_RETRIES) {
165+
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
166+
attempt += 1
167+
await sleep(backoffWithJitter(attempt, retryAfterMs))
168+
continue
169+
}
170+
break
171+
}
128172

129173
const data: unknown = await response.json().catch(() => null)
130174
if (!response.ok) {
@@ -134,7 +178,8 @@ export async function getSailPointAccessToken(creds: SailPointServerCredentials)
134178
throw new Error('SailPoint authentication did not return an access token')
135179
}
136180

137-
const expiresInSec = typeof data.expires_in === 'number' ? data.expires_in : 3600
181+
const parsedExpiry = Number(data.expires_in)
182+
const expiresInSec = Number.isFinite(parsedExpiry) && parsedExpiry > 0 ? parsedExpiry : 3600
138183
tokenCache.set(key, {
139184
token: data.access_token,
140185
expiresAt: Date.now() + Math.max(expiresInSec * 1000 - TOKEN_EXPIRY_BUFFER_MS, 0),
@@ -163,7 +208,7 @@ export async function sailpointFetch(
163208
buildRequest: (token: string, hosts: SailPointHosts) => { url: string; init: RequestInit },
164209
options: { maxRetries?: number } = {}
165210
): Promise<SailPointFetchResult> {
166-
const maxRetries = options.maxRetries ?? 4
211+
const maxRetries = options.maxRetries ?? MAX_FETCH_RETRIES
167212
const hosts = resolveSailPointHosts(creds.tenant, creds.apiVersion)
168213
let attempt = 0
169214
let refreshedOn401 = false

‎apps/sim/app/api/tools/sailpoint/load/route.ts‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6161
apiVersion: normalizeApiVersion(body.apiVersion),
6262
}
6363

64-
const formData = new FormData()
64+
let fileBuffer: Buffer | null = null
65+
let fileName = 'aggregation.csv'
66+
let fileType = 'text/csv'
6567

6668
if (body.file && typeof body.file === 'object') {
6769
const userFiles = processFilesToUserFiles([body.file as RawFileInput], requestId, logger)
@@ -75,11 +77,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7577

7678
try {
7779
const { buffer } = await downloadServableFileFromStorage(userFile, requestId, logger)
78-
formData.append(
79-
'file',
80-
new Blob([new Uint8Array(buffer)], { type: userFile.type || 'text/csv' }),
81-
userFile.name || 'aggregation.csv'
82-
)
80+
fileBuffer = buffer
81+
fileName = userFile.name || 'aggregation.csv'
82+
fileType = userFile.type || 'text/csv'
8383
} catch (error) {
8484
const notReady = docNotReadyResponse(error)
8585
if (notReady) return notReady
@@ -90,22 +90,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9090
}
9191
}
9292

93-
if (body.operation === 'sailpoint_load_accounts' && body.disableOptimization) {
94-
formData.append('disableOptimization', 'true')
95-
}
96-
93+
const includeDisableOptimization =
94+
body.operation === 'sailpoint_load_accounts' && body.disableOptimization === true
9795
const loadPath = LOAD_PATHS[body.operation]
9896

97+
/**
98+
* Builds a fresh multipart body for every attempt. A single FormData instance can be a consumed
99+
* (non-replayable) stream, so reusing it across the client's 401/429 retries could send the
100+
* aggregation without the CSV - rebuild it per request instead.
101+
*/
102+
const buildFormData = (): FormData => {
103+
const formData = new FormData()
104+
if (fileBuffer) {
105+
formData.append('file', new Blob([new Uint8Array(fileBuffer)], { type: fileType }), fileName)
106+
}
107+
if (includeDisableOptimization) {
108+
formData.append('disableOptimization', 'true')
109+
}
110+
return formData
111+
}
112+
99113
try {
100114
logger.info(`[${requestId}] SailPoint aggregation`, {
101115
operation: body.operation,
102116
apiVersion: creds.apiVersion,
103-
hasFile: formData.has('file'),
117+
hasFile: fileBuffer != null,
104118
})
105119

106120
const result = await sailpointFetch(creds, (_token, hosts) => ({
107121
url: `${hosts.apiBaseUrl}/sources/${encodeURIComponent(body.sourceId)}/${loadPath}`,
108-
init: { method: 'POST', body: formData },
122+
init: { method: 'POST', body: buildFormData() },
109123
}))
110124

111125
if (!result.ok) {

‎apps/sim/app/api/tools/sailpoint/query/route.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,34 @@ describe('SailPoint query route', () => {
124124
expect(data.output.results).toEqual([{ _type: 'identity', id: 'i1' }])
125125
})
126126

127+
it('preserves the aggregation object returned by /search/aggregate', async () => {
128+
const aggregationResult = {
129+
aggregations: { department: { buckets: [{ key: 'Finance', count: 12 }] } },
130+
hits: [],
131+
}
132+
fetchMock
133+
.mockResolvedValueOnce(tokenResponse())
134+
.mockResolvedValueOnce(jsonResponse(aggregationResult))
135+
136+
const request = createMockRequest('POST', {
137+
...baseCreds,
138+
tenant: 'acme-aggregate',
139+
operation: 'sailpoint_search_aggregate',
140+
indices: 'identities',
141+
query: 'attributes.department:*',
142+
})
143+
144+
const response = await POST(request)
145+
const data = await response.json()
146+
147+
expect(response.status).toBe(200)
148+
expect(fetchMock.mock.calls[1]?.[0]).toBe(
149+
'https://acme-aggregate.api.identitynow.com/v2025/search/aggregate'
150+
)
151+
// The full AggregationResult object is preserved under `item`, not dropped as an empty list.
152+
expect(data.output).toEqual({ item: aggregationResult })
153+
})
154+
127155
it('caches the token across calls with the same credentials', async () => {
128156
fetchMock
129157
.mockResolvedValueOnce(tokenResponse())
@@ -204,6 +232,38 @@ describe('SailPoint query route', () => {
204232
expect(fetchMock).not.toHaveBeenCalled()
205233
})
206234

235+
it('rejects a non-SailPoint tenant host without sending credentials (SSRF guard)', async () => {
236+
const request = createMockRequest('POST', {
237+
...baseCreds,
238+
tenant: 'evil.example.com',
239+
operation: 'sailpoint_list_identities',
240+
})
241+
242+
const response = await POST(request)
243+
const data = await response.json()
244+
245+
expect(response.status).toBe(500)
246+
expect(fetchMock).not.toHaveBeenCalled()
247+
expect(data.error).toContain('not an allowed')
248+
})
249+
250+
it('accepts a full *.api.identitynow.com host', async () => {
251+
fetchMock
252+
.mockResolvedValueOnce(tokenResponse())
253+
.mockResolvedValueOnce(jsonResponse([{ id: 'i1' }]))
254+
255+
const request = createMockRequest('POST', {
256+
...baseCreds,
257+
tenant: 'https://acme.api.identitynow.com',
258+
operation: 'sailpoint_list_identities',
259+
})
260+
261+
const response = await POST(request)
262+
263+
expect(response.status).toBe(200)
264+
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://acme.api.identitynow.com/v2025/identities')
265+
})
266+
207267
it('propagates a SailPoint error body', async () => {
208268
fetchMock
209269
.mockResolvedValueOnce(tokenResponse())

‎apps/sim/app/api/tools/sailpoint/query/route.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ function dispatch(
167167
query: body.query ? { query: body.query } : undefined,
168168
sort: toStringList(body.sort),
169169
searchAfter: toStringList(body.searchAfter),
170-
includeNested: body.includeNested,
170+
// Only send includeNested when explicitly false; the API already defaults it to true.
171+
includeNested: body.includeNested === false ? false : undefined,
171172
})
172173
return execute(
173174
creds,
@@ -195,6 +196,7 @@ function dispatch(
195196
const searchBody = filterUndefined({
196197
indices: toStringList(body.indices) ?? ['identities'],
197198
query: body.query ? { query: body.query } : undefined,
199+
aggregationsDsl: body.aggregationsDsl,
198200
})
199201
return execute(
200202
creds,
@@ -203,7 +205,9 @@ function dispatch(
203205
url: `${h.apiBaseUrl}/search/aggregate${qs({ limit: body.limit, offset: body.offset })}`,
204206
init: jsonInit(searchBody),
205207
}),
206-
'search'
208+
// /search/aggregate returns an AggregationResult object (aggregations + hits), not an
209+
// array - route it as an item so the buckets are preserved rather than dropped.
210+
'item'
207211
)
208212
}
209213
case 'sailpoint_list_identities':

0 commit comments

Comments
 (0)