@vellumai/credential-executor 0.4.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +55 -0
- package/bun.lock +37 -0
- package/package.json +32 -0
- package/src/__tests__/command-executor.test.ts +1333 -0
- package/src/__tests__/command-validator.test.ts +708 -0
- package/src/__tests__/command-workspace.test.ts +997 -0
- package/src/__tests__/grant-store.test.ts +467 -0
- package/src/__tests__/http-executor.test.ts +1251 -0
- package/src/__tests__/http-policy.test.ts +970 -0
- package/src/__tests__/local-materializers.test.ts +826 -0
- package/src/__tests__/managed-materializers.test.ts +961 -0
- package/src/__tests__/toolstore.test.ts +539 -0
- package/src/__tests__/transport.test.ts +388 -0
- package/src/audit/store.ts +188 -0
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/executor.ts +840 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +282 -0
- package/src/commands/validator.ts +438 -0
- package/src/commands/workspace.ts +512 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +247 -0
- package/src/grants/rpc-handlers.ts +269 -0
- package/src/grants/temporary-store.ts +219 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +540 -0
- package/src/http/path-template.ts +179 -0
- package/src/http/policy.ts +256 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +106 -0
- package/src/main.ts +263 -0
- package/src/managed-main.ts +420 -0
- package/src/materializers/local.ts +300 -0
- package/src/materializers/managed-platform.ts +270 -0
- package/src/paths.ts +137 -0
- package/src/server.ts +636 -0
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +290 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +342 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP executor for the Credential Execution Service.
|
|
3
|
+
*
|
|
4
|
+
* Implements the full `make_authenticated_request` flow:
|
|
5
|
+
*
|
|
6
|
+
* 1. Resolve the credential handle to a local or managed subject.
|
|
7
|
+
* 2. Check grants (policy evaluation) — block off-grant requests before
|
|
8
|
+
* any network call.
|
|
9
|
+
* 3. Materialise the credential through the appropriate backend.
|
|
10
|
+
* 4. Inject auth into the outbound request according to the subject's
|
|
11
|
+
* handle type.
|
|
12
|
+
* 5. Perform the HTTP request.
|
|
13
|
+
* 6. Reject redirect hops that would violate the grant policy.
|
|
14
|
+
* 7. Filter the response through the PR 21 sanitisation pipeline.
|
|
15
|
+
* 8. Generate a token-free audit summary.
|
|
16
|
+
*
|
|
17
|
+
* Security invariants:
|
|
18
|
+
* - Off-grant requests never reach the network.
|
|
19
|
+
* - Caller-supplied raw auth headers are rejected.
|
|
20
|
+
* - Redirect hops to domains/paths outside the grant's scope are blocked.
|
|
21
|
+
* - The assistant runtime only sees sanitised HTTP results and audit
|
|
22
|
+
* summaries — never raw tokens or secrets.
|
|
23
|
+
* - Audit summaries are always token-free.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
MakeAuthenticatedRequest,
|
|
28
|
+
MakeAuthenticatedRequestResponse,
|
|
29
|
+
} from "@vellumai/ces-contracts";
|
|
30
|
+
import { HandleType, parseHandle, hashProposal } from "@vellumai/ces-contracts";
|
|
31
|
+
|
|
32
|
+
import { evaluateHttpPolicy, type PolicyResult } from "./policy.js";
|
|
33
|
+
import { filterHttpResponse, type RawHttpResponse } from "./response-filter.js";
|
|
34
|
+
import { generateHttpAuditSummary } from "./audit.js";
|
|
35
|
+
|
|
36
|
+
import type { PersistentGrantStore } from "../grants/persistent-store.js";
|
|
37
|
+
import type { TemporaryGrantStore } from "../grants/temporary-store.js";
|
|
38
|
+
|
|
39
|
+
import type { LocalMaterialiser, MaterialisedCredential } from "../materializers/local.js";
|
|
40
|
+
import { materializeManagedToken, type ManagedMaterializerOptions } from "../materializers/managed-platform.js";
|
|
41
|
+
import { resolveLocalSubject, type LocalSubjectResolverDeps } from "../subjects/local.js";
|
|
42
|
+
import { resolveManagedSubject, type ManagedSubjectResolverOptions } from "../subjects/managed.js";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Auth injection constants
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Headers that are forbidden in caller-supplied requests. This is
|
|
50
|
+
* enforced at the policy layer, but we double-check before injection
|
|
51
|
+
* as defense-in-depth.
|
|
52
|
+
*/
|
|
53
|
+
const AUTH_HEADERS_TO_STRIP = new Set([
|
|
54
|
+
"authorization",
|
|
55
|
+
"cookie",
|
|
56
|
+
"proxy-authorization",
|
|
57
|
+
"x-api-key",
|
|
58
|
+
"x-auth-token",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Executor dependencies
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export interface HttpExecutorDeps {
|
|
66
|
+
/** Persistent grant store for policy evaluation. */
|
|
67
|
+
persistentGrantStore: PersistentGrantStore;
|
|
68
|
+
/** Temporary grant store for policy evaluation. */
|
|
69
|
+
temporaryGrantStore: TemporaryGrantStore;
|
|
70
|
+
/** Local materialiser for local_static and local_oauth handles. */
|
|
71
|
+
localMaterialiser: LocalMaterialiser;
|
|
72
|
+
/** Dependencies for local subject resolution. */
|
|
73
|
+
localSubjectDeps: LocalSubjectResolverDeps;
|
|
74
|
+
/** Options for managed subject resolution (null if managed mode is unavailable). */
|
|
75
|
+
managedSubjectOptions?: ManagedSubjectResolverOptions;
|
|
76
|
+
/** Options for managed token materialisation (null if managed mode is unavailable). */
|
|
77
|
+
managedMaterializerOptions?: ManagedMaterializerOptions;
|
|
78
|
+
/** Session ID for audit records. */
|
|
79
|
+
sessionId: string;
|
|
80
|
+
/** Optional custom fetch implementation (for testing). */
|
|
81
|
+
fetch?: typeof globalThis.fetch;
|
|
82
|
+
/** Optional logger. */
|
|
83
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Redirect policy
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Maximum number of redirects to follow before aborting.
|
|
92
|
+
*/
|
|
93
|
+
const MAX_REDIRECTS = 5;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* HTTP status codes that indicate a redirect.
|
|
97
|
+
*/
|
|
98
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Executor implementation
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Execute an authenticated HTTP request through the full CES pipeline.
|
|
106
|
+
*
|
|
107
|
+
* This is the handler implementation for the `make_authenticated_request`
|
|
108
|
+
* RPC method. It is pure logic with injected dependencies, making it
|
|
109
|
+
* testable without real network calls or credential stores.
|
|
110
|
+
*/
|
|
111
|
+
export async function executeAuthenticatedHttpRequest(
|
|
112
|
+
request: MakeAuthenticatedRequest,
|
|
113
|
+
deps: HttpExecutorDeps,
|
|
114
|
+
): Promise<MakeAuthenticatedRequestResponse> {
|
|
115
|
+
const logger = deps.logger ?? console;
|
|
116
|
+
|
|
117
|
+
// 1. Parse the handle to determine source (local vs managed)
|
|
118
|
+
const parseResult = parseHandle(request.credentialHandle);
|
|
119
|
+
if (!parseResult.ok) {
|
|
120
|
+
return {
|
|
121
|
+
success: false,
|
|
122
|
+
error: {
|
|
123
|
+
code: "INVALID_HANDLE",
|
|
124
|
+
message: parseResult.error,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Evaluate grant policy — blocks off-grant requests before network
|
|
130
|
+
const policyResult = evaluateHttpPolicy(
|
|
131
|
+
{
|
|
132
|
+
credentialHandle: request.credentialHandle,
|
|
133
|
+
method: request.method,
|
|
134
|
+
url: request.url,
|
|
135
|
+
headers: request.headers,
|
|
136
|
+
purpose: request.purpose,
|
|
137
|
+
grantId: request.grantId,
|
|
138
|
+
},
|
|
139
|
+
deps.persistentGrantStore,
|
|
140
|
+
deps.temporaryGrantStore,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (!policyResult.allowed) {
|
|
144
|
+
if (policyResult.reason === "forbidden_headers") {
|
|
145
|
+
return {
|
|
146
|
+
success: false,
|
|
147
|
+
error: {
|
|
148
|
+
code: "FORBIDDEN_HEADERS",
|
|
149
|
+
message: `Request contains forbidden auth headers that the agent must not set: ${policyResult.forbiddenHeaders.join(", ")}. CES injects authentication — the caller must not supply raw auth headers.`,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// approval_required — return the proposal so the assistant can prompt
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
error: {
|
|
158
|
+
code: "APPROVAL_REQUIRED",
|
|
159
|
+
message: `No active grant covers this request. Approval is required.`,
|
|
160
|
+
details: {
|
|
161
|
+
proposal: policyResult.proposal,
|
|
162
|
+
proposalHash: hashProposal(policyResult.proposal),
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const grantId = policyResult.grantId;
|
|
169
|
+
|
|
170
|
+
// 3. Materialise the credential
|
|
171
|
+
const materialiseResult = await materialiseCredential(
|
|
172
|
+
parseResult.handle.type,
|
|
173
|
+
request.credentialHandle,
|
|
174
|
+
deps,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (!materialiseResult.ok) {
|
|
178
|
+
const audit = generateHttpAuditSummary({
|
|
179
|
+
credentialHandle: request.credentialHandle,
|
|
180
|
+
grantId,
|
|
181
|
+
sessionId: deps.sessionId,
|
|
182
|
+
method: request.method,
|
|
183
|
+
url: request.url,
|
|
184
|
+
success: false,
|
|
185
|
+
errorMessage: materialiseResult.error,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
success: false,
|
|
190
|
+
error: {
|
|
191
|
+
code: "MATERIALISATION_FAILED",
|
|
192
|
+
message: materialiseResult.error,
|
|
193
|
+
},
|
|
194
|
+
auditId: audit.auditId,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { credential, secrets } = materialiseResult;
|
|
199
|
+
|
|
200
|
+
// 4. Build the outbound request with injected auth
|
|
201
|
+
const outboundHeaders = buildOutboundHeaders(
|
|
202
|
+
request.headers ?? {},
|
|
203
|
+
credential,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// 5. Perform the HTTP request with redirect enforcement
|
|
207
|
+
let rawResponse: RawHttpResponse;
|
|
208
|
+
try {
|
|
209
|
+
rawResponse = await performHttpRequest(
|
|
210
|
+
request.method,
|
|
211
|
+
request.url,
|
|
212
|
+
outboundHeaders,
|
|
213
|
+
request.body,
|
|
214
|
+
policyResult,
|
|
215
|
+
request.credentialHandle,
|
|
216
|
+
deps,
|
|
217
|
+
);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
220
|
+
// Sanitise error messages to avoid leaking secrets
|
|
221
|
+
const safeError = sanitiseErrorMessage(errorMessage, secrets);
|
|
222
|
+
|
|
223
|
+
const audit = generateHttpAuditSummary({
|
|
224
|
+
credentialHandle: request.credentialHandle,
|
|
225
|
+
grantId,
|
|
226
|
+
sessionId: deps.sessionId,
|
|
227
|
+
method: request.method,
|
|
228
|
+
url: request.url,
|
|
229
|
+
success: false,
|
|
230
|
+
errorMessage: safeError,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
success: false,
|
|
235
|
+
error: {
|
|
236
|
+
code: "HTTP_REQUEST_FAILED",
|
|
237
|
+
message: safeError,
|
|
238
|
+
},
|
|
239
|
+
auditId: audit.auditId,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 6. Filter the response through the sanitisation pipeline
|
|
244
|
+
const filtered = filterHttpResponse(rawResponse, secrets);
|
|
245
|
+
|
|
246
|
+
// 7. Generate audit summary
|
|
247
|
+
const audit = generateHttpAuditSummary({
|
|
248
|
+
credentialHandle: request.credentialHandle,
|
|
249
|
+
grantId,
|
|
250
|
+
sessionId: deps.sessionId,
|
|
251
|
+
method: request.method,
|
|
252
|
+
url: request.url,
|
|
253
|
+
success: true,
|
|
254
|
+
statusCode: rawResponse.statusCode,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
logger.log(
|
|
258
|
+
`[ces-http] ${request.method} ${request.url} -> ${rawResponse.statusCode} (grant=${grantId})`,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
success: true,
|
|
263
|
+
statusCode: filtered.statusCode,
|
|
264
|
+
responseHeaders: filtered.headers,
|
|
265
|
+
responseBody: filtered.body,
|
|
266
|
+
auditId: audit.auditId,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Credential materialisation dispatch
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
interface MaterialiseSuccess {
|
|
275
|
+
ok: true;
|
|
276
|
+
credential: MaterialisedCredential;
|
|
277
|
+
/** Secret values to scrub from response bodies (defense-in-depth). */
|
|
278
|
+
secrets: string[];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface MaterialiseFailure {
|
|
282
|
+
ok: false;
|
|
283
|
+
error: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
type MaterialiseResult = MaterialiseSuccess | MaterialiseFailure;
|
|
287
|
+
|
|
288
|
+
async function materialiseCredential(
|
|
289
|
+
handleType: string,
|
|
290
|
+
rawHandle: string,
|
|
291
|
+
deps: HttpExecutorDeps,
|
|
292
|
+
): Promise<MaterialiseResult> {
|
|
293
|
+
switch (handleType) {
|
|
294
|
+
case HandleType.LocalStatic:
|
|
295
|
+
case HandleType.LocalOAuth: {
|
|
296
|
+
// Resolve local subject
|
|
297
|
+
const subjectResult = resolveLocalSubject(rawHandle, deps.localSubjectDeps);
|
|
298
|
+
if (!subjectResult.ok) {
|
|
299
|
+
return { ok: false, error: subjectResult.error };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Materialise through the local materialiser
|
|
303
|
+
const matResult = await deps.localMaterialiser.materialise(subjectResult.subject);
|
|
304
|
+
if (!matResult.ok) {
|
|
305
|
+
return { ok: false, error: matResult.error };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
ok: true,
|
|
310
|
+
credential: matResult.credential,
|
|
311
|
+
secrets: [matResult.credential.value],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case HandleType.PlatformOAuth: {
|
|
316
|
+
if (!deps.managedSubjectOptions || !deps.managedMaterializerOptions) {
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
error: "Managed OAuth is not configured. Platform URL and API key are required.",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Resolve managed subject
|
|
324
|
+
const subjectResult = await resolveManagedSubject(
|
|
325
|
+
rawHandle,
|
|
326
|
+
deps.managedSubjectOptions,
|
|
327
|
+
);
|
|
328
|
+
if (!subjectResult.ok) {
|
|
329
|
+
return { ok: false, error: subjectResult.error.message };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Materialise through the managed materialiser
|
|
333
|
+
const matResult = await materializeManagedToken(
|
|
334
|
+
subjectResult.subject,
|
|
335
|
+
deps.managedMaterializerOptions,
|
|
336
|
+
);
|
|
337
|
+
if (!matResult.ok) {
|
|
338
|
+
return { ok: false, error: matResult.error.message };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
ok: true,
|
|
343
|
+
credential: {
|
|
344
|
+
value: matResult.token.accessToken,
|
|
345
|
+
handleType: HandleType.PlatformOAuth,
|
|
346
|
+
expiresAt: matResult.token.expiresAt,
|
|
347
|
+
},
|
|
348
|
+
secrets: [matResult.token.accessToken],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
default:
|
|
353
|
+
return {
|
|
354
|
+
ok: false,
|
|
355
|
+
error: `Unsupported handle type "${handleType}" for HTTP execution`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// Auth header injection
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Build the outbound request headers by:
|
|
366
|
+
* 1. Stripping any caller-supplied auth headers (defense-in-depth).
|
|
367
|
+
* 2. Injecting the credential as an Authorization header.
|
|
368
|
+
*/
|
|
369
|
+
function buildOutboundHeaders(
|
|
370
|
+
callerHeaders: Record<string, string>,
|
|
371
|
+
credential: MaterialisedCredential,
|
|
372
|
+
): Record<string, string> {
|
|
373
|
+
const headers: Record<string, string> = {};
|
|
374
|
+
|
|
375
|
+
// Copy caller headers, stripping any auth headers
|
|
376
|
+
for (const [key, value] of Object.entries(callerHeaders)) {
|
|
377
|
+
if (!AUTH_HEADERS_TO_STRIP.has(key.toLowerCase())) {
|
|
378
|
+
headers[key] = value;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Inject credential based on handle type
|
|
383
|
+
switch (credential.handleType) {
|
|
384
|
+
case HandleType.LocalStatic:
|
|
385
|
+
// Static secrets are injected as Bearer tokens by default.
|
|
386
|
+
// The subject metadata could specify a different injection strategy
|
|
387
|
+
// in the future, but for now Bearer is the safe default.
|
|
388
|
+
headers["Authorization"] = `Bearer ${credential.value}`;
|
|
389
|
+
break;
|
|
390
|
+
|
|
391
|
+
case HandleType.LocalOAuth:
|
|
392
|
+
case HandleType.PlatformOAuth:
|
|
393
|
+
// OAuth tokens are always Bearer tokens.
|
|
394
|
+
headers["Authorization"] = `Bearer ${credential.value}`;
|
|
395
|
+
break;
|
|
396
|
+
|
|
397
|
+
default:
|
|
398
|
+
// Unknown type — inject as Bearer (fail-open on injection is OK
|
|
399
|
+
// because the grant policy already vetted the request).
|
|
400
|
+
headers["Authorization"] = `Bearer ${credential.value}`;
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return headers;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// HTTP request execution with redirect enforcement
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Perform an HTTP request, following redirects only when each hop
|
|
413
|
+
* independently satisfies the grant policy.
|
|
414
|
+
*/
|
|
415
|
+
async function performHttpRequest(
|
|
416
|
+
method: string,
|
|
417
|
+
url: string,
|
|
418
|
+
headers: Record<string, string>,
|
|
419
|
+
body: unknown | undefined,
|
|
420
|
+
originalPolicy: PolicyResult & { allowed: true },
|
|
421
|
+
credentialHandle: string,
|
|
422
|
+
deps: HttpExecutorDeps,
|
|
423
|
+
): Promise<RawHttpResponse> {
|
|
424
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
425
|
+
|
|
426
|
+
let currentUrl = url;
|
|
427
|
+
let currentMethod = method;
|
|
428
|
+
let currentHeaders = headers;
|
|
429
|
+
let currentBody = body;
|
|
430
|
+
let redirectCount = 0;
|
|
431
|
+
|
|
432
|
+
while (true) {
|
|
433
|
+
// Build fetch options — disable automatic redirect following so we
|
|
434
|
+
// can enforce grant policy on each hop.
|
|
435
|
+
const fetchOptions: RequestInit = {
|
|
436
|
+
method: currentMethod,
|
|
437
|
+
headers: currentHeaders,
|
|
438
|
+
redirect: "manual",
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
if (currentBody !== undefined && currentBody !== null) {
|
|
442
|
+
fetchOptions.body =
|
|
443
|
+
typeof currentBody === "string"
|
|
444
|
+
? currentBody
|
|
445
|
+
: JSON.stringify(currentBody);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const response = await fetchFn(currentUrl, fetchOptions);
|
|
449
|
+
|
|
450
|
+
// Check for redirect
|
|
451
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
452
|
+
redirectCount++;
|
|
453
|
+
if (redirectCount > MAX_REDIRECTS) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
`Too many redirects (exceeded ${MAX_REDIRECTS}). Aborting.`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const locationHeader = response.headers.get("location");
|
|
460
|
+
if (!locationHeader) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`Redirect response (${response.status}) missing Location header.`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Resolve the redirect URL (may be relative)
|
|
467
|
+
const redirectUrl = new URL(locationHeader, currentUrl).toString();
|
|
468
|
+
|
|
469
|
+
// Enforce grant policy on the redirect target — the redirect must
|
|
470
|
+
// independently satisfy the same credential handle's grant policy.
|
|
471
|
+
const redirectPolicy = evaluateHttpPolicy(
|
|
472
|
+
{
|
|
473
|
+
credentialHandle,
|
|
474
|
+
method: currentMethod,
|
|
475
|
+
url: redirectUrl,
|
|
476
|
+
purpose: `redirect from ${currentUrl}`,
|
|
477
|
+
},
|
|
478
|
+
deps.persistentGrantStore,
|
|
479
|
+
deps.temporaryGrantStore,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
if (!redirectPolicy.allowed) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`Redirect to ${sanitiseUrl(redirectUrl)} denied: the redirect target does not satisfy the grant policy for credential handle "${credentialHandle}".`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// For 303 redirects, convert to GET
|
|
489
|
+
if (response.status === 303) {
|
|
490
|
+
currentMethod = "GET";
|
|
491
|
+
currentBody = undefined;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
currentUrl = redirectUrl;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Not a redirect — read the response
|
|
499
|
+
const responseBody = await response.text();
|
|
500
|
+
const responseHeaders: Record<string, string> = {};
|
|
501
|
+
response.headers.forEach((value, key) => {
|
|
502
|
+
responseHeaders[key] = value;
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
statusCode: response.status,
|
|
507
|
+
headers: responseHeaders,
|
|
508
|
+
body: responseBody,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Helpers
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Sanitise a URL for error messages by stripping query parameters
|
|
519
|
+
* (which may contain sensitive values).
|
|
520
|
+
*/
|
|
521
|
+
function sanitiseUrl(url: string): string {
|
|
522
|
+
try {
|
|
523
|
+
const parsed = new URL(url);
|
|
524
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
525
|
+
} catch {
|
|
526
|
+
return "[invalid-url]";
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Sanitise error messages to avoid leaking secret values.
|
|
532
|
+
*/
|
|
533
|
+
function sanitiseErrorMessage(message: string, secrets: string[]): string {
|
|
534
|
+
let result = message;
|
|
535
|
+
for (const secret of secrets) {
|
|
536
|
+
if (secret.length < 8) continue;
|
|
537
|
+
result = result.replaceAll(secret, "[CES:REDACTED]");
|
|
538
|
+
}
|
|
539
|
+
return result;
|
|
540
|
+
}
|