@opengeni/api-router 0.7.3 → 0.11.1

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createApp
3
- } from "./chunk-EYYTFA7N.js";
3
+ } from "./chunk-7PQKPKW5.js";
4
4
 
5
5
  // src/index.ts
6
6
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.7.3",
3
+ "version": "0.11.1",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,17 +43,18 @@
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "@opengeni/agent-proto": "^0.3.0",
46
- "@opengeni/codex": "^0.2.5",
47
- "@opengeni/config": "^0.6.2",
48
- "@opengeni/contracts": "^0.15.0",
49
- "@opengeni/core": "^0.8.0",
50
- "@opengeni/db": "^0.9.3",
51
- "@opengeni/documents": "^0.2.19",
52
- "@opengeni/events": "^0.3.10",
53
- "@opengeni/github": "^0.3.3",
46
+ "@opengeni/codex": "^0.2.7",
47
+ "@opengeni/config": "^0.7.0",
48
+ "@opengeni/contracts": "^0.19.0",
49
+ "@opengeni/core": "^0.11.1",
50
+ "@opengeni/db": "^0.12.0",
51
+ "@opengeni/documents": "^0.2.30",
52
+ "@opengeni/events": "^0.3.21",
53
+ "@opengeni/github": "^0.3.12",
54
+ "@opengeni/network": "^0.1.1",
54
55
  "@opengeni/observability": "^0.3.0",
55
- "@opengeni/runtime": "^0.11.0",
56
- "@opengeni/storage": "^0.2.15",
56
+ "@opengeni/runtime": "^0.13.3",
57
+ "@opengeni/storage": "^0.2.24",
57
58
  "@temporalio/client": "^1.17.0",
58
59
  "better-auth": "^1.6.14",
59
60
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ canonicalizeConfiguredModelId,
2
3
  configuredAllowedModels,
3
4
  configuredAllowedReasoningEfforts,
4
5
  configuredModels,
@@ -37,6 +38,7 @@ import { createApiSandboxClient, makeResumeBoxById } from "./sandbox/access";
37
38
  import { requireLimit } from "@opengeni/core";
38
39
  import { buildOpenGeniMcpServer } from "./mcp/server";
39
40
  import { isToolspaceGrant, prepareToolspaceMcpSurface } from "./mcp/toolspace";
41
+ import { boundedMcpRequest, McpPayloadTooLargeError } from "@opengeni/runtime/mcp-network";
40
42
  import { requireAccessKey } from "./http/auth";
41
43
  import { registerCapabilityRoutes } from "./routes/capabilities";
42
44
  import { registerCatalogAssetRoutes } from "./routes/catalog-assets";
@@ -57,6 +59,7 @@ import { registerScheduledTaskRoutes } from "./routes/scheduled-tasks";
57
59
  import { registerSessionRoutes } from "./routes/sessions";
58
60
  import { registerSocialRoutes } from "./routes/social";
59
61
  import { registerWorkspaceRoutes } from "./routes/workspaces";
62
+ import { projectClientModel } from "./model-catalog";
60
63
 
61
64
  export type {
62
65
  ApiRouteDeps,
@@ -292,22 +295,13 @@ export function createApp(deps: AppDependencies): Hono {
292
295
  deploymentRevision: deps.settings.deploymentRevision,
293
296
  apiContractRevision: OPENGENI_API_CONTRACT_REVISION,
294
297
  ...(deps.settings.serverVersion ? { serverVersion: deps.settings.serverVersion } : {}),
295
- defaultModel: deps.settings.openaiModel,
298
+ defaultModel: canonicalizeConfiguredModelId(deps.settings, deps.settings.openaiModel),
296
299
  allowedModels: configuredAllowedModels(deps.settings),
297
300
  // Provider-grouped model list for the picker. configuredModels() carries the
298
301
  // union of the built-in allow-list and every registry provider's models, in
299
302
  // selection order (default model first); project each to the client-safe
300
303
  // ClientModel shape (ConfiguredModel.providerId → ClientModel.provider).
301
- models: configuredModels(deps.settings).map((model) => ({
302
- id: model.id,
303
- label: model.label,
304
- provider: model.providerId,
305
- providerLabel: model.providerLabel,
306
- api: model.api,
307
- ...(model.contextWindowTokens === undefined
308
- ? {}
309
- : { contextWindowTokens: model.contextWindowTokens }),
310
- })),
304
+ models: configuredModels(deps.settings).map(projectClientModel),
311
305
  defaultReasoningEffort: deps.settings.openaiReasoningEffort,
312
306
  allowedReasoningEfforts: configuredAllowedReasoningEfforts(deps.settings),
313
307
  mcpServers: deps.settings.mcpServers.map((server) => ({
@@ -330,6 +324,15 @@ export function createApp(deps: AppDependencies): Hono {
330
324
 
331
325
  app.all("/v1/workspaces/:workspaceId/mcp", async (c) => {
332
326
  const workspaceId = c.req.param("workspaceId");
327
+ let boundedRequest: Request;
328
+ try {
329
+ boundedRequest = await boundedMcpRequest(c.req.raw);
330
+ } catch (error) {
331
+ if (error instanceof McpPayloadTooLargeError) {
332
+ throw new HTTPException(413, { message: "MCP request body exceeds the safety limit" });
333
+ }
334
+ throw error;
335
+ }
333
336
  const grant = await requireMcpAccessGrant(c, routeDeps, workspaceId);
334
337
  const toolspaceGrant = isToolspaceGrant(routeDeps.settings, grant);
335
338
  const boundSessionId = grant.metadata?.sessionId;
@@ -353,9 +356,17 @@ export function createApp(deps: AppDependencies): Hono {
353
356
  throw error;
354
357
  }
355
358
  }
356
- const toolspace = toolspaceGrant
357
- ? await prepareToolspaceMcpSurface({ deps: routeDeps, grant })
358
- : null;
359
+ let toolspace: Awaited<ReturnType<typeof prepareToolspaceMcpSurface>> = null;
360
+ if (toolspaceGrant) {
361
+ try {
362
+ toolspace = await prepareToolspaceMcpSurface({ deps: routeDeps, grant });
363
+ } catch (error) {
364
+ if (error instanceof McpPayloadTooLargeError) {
365
+ throw new HTTPException(413, { message: "MCP tool list exceeds the safety limit" });
366
+ }
367
+ throw error;
368
+ }
369
+ }
359
370
  const workspace = await getWorkspace(routeDeps.db, workspaceId);
360
371
  const workspaceMemoryEnabled = resolveWorkspaceMemoryEnabled(workspace?.settings);
361
372
  const transport = new WebStandardStreamableHTTPServerTransport({
@@ -368,7 +379,7 @@ export function createApp(deps: AppDependencies): Hono {
368
379
  });
369
380
  try {
370
381
  await mcp.connect(transport);
371
- return await transport.handleRequest(c.req.raw);
382
+ return await transport.handleRequest(boundedRequest);
372
383
  } finally {
373
384
  await toolspace?.close().catch(() => undefined);
374
385
  }
@@ -450,6 +461,9 @@ export function httpStatusForError(error: unknown): number {
450
461
  if (error instanceof HTTPException) {
451
462
  return error.status;
452
463
  }
464
+ if (error instanceof McpPayloadTooLargeError) {
465
+ return 413;
466
+ }
453
467
  return 500;
454
468
  }
455
469
 
@@ -662,6 +676,14 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
662
676
  pattern: /^\/v1\/workspaces\/[^/]+\/files\/[^/]+$/,
663
677
  label: "/v1/workspaces/:workspaceId/files/:id",
664
678
  },
679
+ {
680
+ pattern: /^\/v1\/workspaces\/[^/]+\/artifacts\/[^/]+\/content$/,
681
+ label: "/v1/workspaces/:workspaceId/artifacts/:id/content",
682
+ },
683
+ {
684
+ pattern: /^\/v1\/workspaces\/[^/]+\/artifacts\/[^/]+$/,
685
+ label: "/v1/workspaces/:workspaceId/artifacts/:id",
686
+ },
665
687
  {
666
688
  pattern: /^\/v1\/workspaces\/[^/]+\/api-keys$/,
667
689
  label: "/v1/workspaces/:workspaceId/api-keys",
@@ -0,0 +1,96 @@
1
+ type CodexRedemptionConfirmationClaims = {
2
+ version: 1;
3
+ attemptId: string;
4
+ workspaceId: string;
5
+ credentialId: string;
6
+ creditId: string;
7
+ subjectId: string;
8
+ browserSessionHash: string;
9
+ expiresAt: number;
10
+ };
11
+
12
+ const encoder = new TextEncoder();
13
+
14
+ function base64UrlEncode(bytes: Uint8Array): string {
15
+ return Buffer.from(bytes).toString("base64url");
16
+ }
17
+
18
+ function base64UrlDecode(value: string): Uint8Array | null {
19
+ try {
20
+ return new Uint8Array(Buffer.from(value, "base64url"));
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ async function hmac(secret: string, payload: string): Promise<Uint8Array> {
27
+ const key = await crypto.subtle.importKey(
28
+ "raw",
29
+ encoder.encode(secret),
30
+ { name: "HMAC", hash: "SHA-256" },
31
+ false,
32
+ ["sign"],
33
+ );
34
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
35
+ }
36
+
37
+ function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean {
38
+ if (left.length !== right.length) return false;
39
+ let diff = 0;
40
+ for (let index = 0; index < left.length; index += 1) {
41
+ diff |= left[index]! ^ right[index]!;
42
+ }
43
+ return diff === 0;
44
+ }
45
+
46
+ export async function hashCodexBrowserSession(sessionId: string): Promise<string> {
47
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(sessionId));
48
+ return base64UrlEncode(new Uint8Array(digest));
49
+ }
50
+
51
+ /** Five-minute, session-bound, HMAC-confirmed browser mutation token. */
52
+ export async function signCodexRedemptionConfirmation(
53
+ secret: string,
54
+ claims: CodexRedemptionConfirmationClaims,
55
+ ): Promise<string> {
56
+ const payload = base64UrlEncode(encoder.encode(JSON.stringify(claims)));
57
+ return `${payload}.${base64UrlEncode(await hmac(secret, payload))}`;
58
+ }
59
+
60
+ export async function verifyCodexRedemptionConfirmation(
61
+ secret: string,
62
+ token: string,
63
+ now = Date.now(),
64
+ ): Promise<CodexRedemptionConfirmationClaims | null> {
65
+ const [payload, signature, extra] = token.split(".");
66
+ if (!payload || !signature || extra !== undefined) return null;
67
+ const supplied = base64UrlDecode(signature);
68
+ const encodedClaims = base64UrlDecode(payload);
69
+ if (!supplied || !encodedClaims) return null;
70
+ if (!constantTimeEqual(supplied, await hmac(secret, payload))) return null;
71
+ let claims: unknown;
72
+ try {
73
+ claims = JSON.parse(new TextDecoder().decode(encodedClaims));
74
+ } catch {
75
+ return null;
76
+ }
77
+ if (!claims || typeof claims !== "object") return null;
78
+ const value = claims as Record<string, unknown>;
79
+ if (
80
+ value.version !== 1 ||
81
+ typeof value.attemptId !== "string" ||
82
+ typeof value.workspaceId !== "string" ||
83
+ typeof value.credentialId !== "string" ||
84
+ typeof value.creditId !== "string" ||
85
+ typeof value.subjectId !== "string" ||
86
+ typeof value.browserSessionHash !== "string" ||
87
+ typeof value.expiresAt !== "number" ||
88
+ !Number.isFinite(value.expiresAt) ||
89
+ value.expiresAt * 1000 <= now
90
+ ) {
91
+ return null;
92
+ }
93
+ return value as CodexRedemptionConfirmationClaims;
94
+ }
95
+
96
+ export type { CodexRedemptionConfirmationClaims };
@@ -11,7 +11,6 @@ import {
11
11
  decryptEnvironmentValue,
12
12
  encryptEnvironmentValue,
13
13
  getConnectionMetadata,
14
- isPrivateAddress,
15
14
  listConnectionsMetadata,
16
15
  loadIntegrationOAuthClient,
17
16
  normalizeBearerScheme,
@@ -20,14 +19,21 @@ import {
20
19
  type Database,
21
20
  } from "@opengeni/db";
22
21
  import { createSignedState, readSignedState } from "@opengeni/github";
22
+ import {
23
+ DestinationPolicyError,
24
+ OAUTH_MAX_RESPONSE_BYTES,
25
+ isLocalTestEnvironment,
26
+ pinnedFetch,
27
+ readResponseJsonBounded,
28
+ validateHttpUrl,
29
+ } from "@opengeni/network";
23
30
  import { Buffer } from "node:buffer";
24
31
  import { createHash, randomBytes } from "node:crypto";
25
- import { lookup } from "node:dns/promises";
26
- import { isIP } from "node:net";
27
32
  import { HTTPException } from "hono/http-exception";
28
33
  import { canonicalProviderDomain } from "./provider-domain";
29
34
 
30
35
  export const oauthStateTtlMs = 10 * 60 * 1000;
36
+ export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
31
37
 
32
38
  type OAuthClientDeps = {
33
39
  db: Database;
@@ -192,6 +198,7 @@ export async function startMcpOAuth(
192
198
  });
193
199
  const authorizationUrl = buildAuthorizationUrl({
194
200
  endpoint: discovery.as.authorizationEndpoint,
201
+ settings,
195
202
  clientId: client.clientId,
196
203
  redirectUri,
197
204
  state,
@@ -386,10 +393,14 @@ async function probeMcpChallenge(
386
393
  method: "GET",
387
394
  headers: { accept: "application/json" },
388
395
  });
389
- if (response.status !== 401) {
390
- return {};
396
+ try {
397
+ if (response.status !== 401) {
398
+ return {};
399
+ }
400
+ return parseWwwAuthenticate(response.headers.get("www-authenticate"));
401
+ } finally {
402
+ await cancelResponseBody(response);
391
403
  }
392
- return parseWwwAuthenticate(response.headers.get("www-authenticate"));
393
404
  }
394
405
 
395
406
  async function discoverProtectedResourceMetadata(
@@ -429,10 +440,15 @@ async function discoverAuthorizationServerMetadata(
429
440
  authorizationServer: string,
430
441
  settings: Settings,
431
442
  ): Promise<AuthorizationServerMetadata> {
432
- const candidates = uniqueStrings([
443
+ const safeAuthorizationServer = oauthEndpointUrl(
433
444
  authorizationServer,
434
- ...wellKnownCandidates(authorizationServer, "oauth-authorization-server"),
435
- ...wellKnownCandidates(authorizationServer, "openid-configuration"),
445
+ settings,
446
+ "OAuth authorization server",
447
+ ).replace(/\/+$/, "");
448
+ const candidates = uniqueStrings([
449
+ safeAuthorizationServer,
450
+ ...wellKnownCandidates(safeAuthorizationServer, "oauth-authorization-server"),
451
+ ...wellKnownCandidates(safeAuthorizationServer, "openid-configuration"),
436
452
  ]);
437
453
  for (const candidate of candidates) {
438
454
  const payload = await fetchJsonObject(candidate, settings).catch((error) => {
@@ -449,18 +465,31 @@ async function discoverAuthorizationServerMetadata(
449
465
  if (!authorizationEndpoint || !tokenEndpoint) {
450
466
  continue;
451
467
  }
452
- return {
453
- issuer: stringValue(payload.issuer) ?? authorizationServer.replace(/\/+$/, ""),
454
- authorizationServer: authorizationServer.replace(/\/+$/, ""),
468
+ const safeAuthorizationEndpoint = oauthEndpointUrl(
455
469
  authorizationEndpoint,
456
- tokenEndpoint,
470
+ settings,
471
+ "OAuth authorization endpoint",
472
+ );
473
+ const safeTokenEndpoint = oauthEndpointUrl(tokenEndpoint, settings, "OAuth token endpoint");
474
+ const registrationEndpoint = stringValue(payload.registration_endpoint);
475
+ const issuer = oauthEndpointUrl(
476
+ stringValue(payload.issuer) ?? safeAuthorizationServer,
477
+ settings,
478
+ "OAuth issuer",
479
+ );
480
+ const safeRegistrationEndpoint = registrationEndpoint
481
+ ? oauthEndpointUrl(registrationEndpoint, settings, "OAuth registration endpoint")
482
+ : undefined;
483
+ return {
484
+ issuer,
485
+ authorizationServer: safeAuthorizationServer,
486
+ authorizationEndpoint: safeAuthorizationEndpoint,
487
+ tokenEndpoint: safeTokenEndpoint,
457
488
  clientIdMetadataDocumentSupported: payload.client_id_metadata_document_supported === true,
458
489
  tokenEndpointAuthMethodsSupported: stringArray(payload.token_endpoint_auth_methods_supported),
459
490
  codeChallengeMethodsSupported: stringArray(payload.code_challenge_methods_supported),
460
491
  raw: payload,
461
- ...(stringValue(payload.registration_endpoint)
462
- ? { registrationEndpoint: stringValue(payload.registration_endpoint)! }
463
- : {}),
492
+ ...(safeRegistrationEndpoint ? { registrationEndpoint: safeRegistrationEndpoint } : {}),
464
493
  };
465
494
  }
466
495
  throw new HTTPException(422, {
@@ -661,7 +690,6 @@ async function dynamicClientRegistration(
661
690
  message: "authorization server does not support dynamic client registration",
662
691
  });
663
692
  }
664
- await assertOAuthFetchAllowed(as.registrationEndpoint, settings);
665
693
  const response = await fetchOAuth(as.registrationEndpoint, settings, {
666
694
  method: "POST",
667
695
  headers: { "content-type": "application/json", accept: "application/json" },
@@ -675,11 +703,16 @@ async function dynamicClientRegistration(
675
703
  }),
676
704
  });
677
705
  if (!response.ok) {
706
+ await cancelResponseBody(response);
678
707
  throw new HTTPException(422, {
679
708
  message: `dynamic client registration failed with HTTP ${response.status}`,
680
709
  });
681
710
  }
682
- const payload = (await response.json()) as Record<string, unknown>;
711
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
712
+ response,
713
+ OAUTH_MAX_RESPONSE_BYTES,
714
+ "OAuth dynamic registration response",
715
+ );
683
716
  const clientId = stringValue(payload.client_id);
684
717
  if (!clientId) {
685
718
  throw new HTTPException(422, {
@@ -726,6 +759,7 @@ async function existingOAuthConnectionForStart(
726
759
 
727
760
  function buildAuthorizationUrl(input: {
728
761
  endpoint: string;
762
+ settings: Settings;
729
763
  clientId: string;
730
764
  redirectUri: string;
731
765
  state: string;
@@ -733,7 +767,8 @@ function buildAuthorizationUrl(input: {
733
767
  verifier: string;
734
768
  scopes: string[];
735
769
  }): string {
736
- const url = new URL(input.endpoint);
770
+ const endpoint = oauthEndpointUrl(input.endpoint, input.settings, "OAuth authorization endpoint");
771
+ const url = new URL(endpoint);
737
772
  url.searchParams.set("response_type", "code");
738
773
  url.searchParams.set("client_id", input.clientId);
739
774
  url.searchParams.set("redirect_uri", input.redirectUri);
@@ -775,9 +810,21 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
775
810
  "state.encryptedPkceVerifier",
776
811
  ),
777
812
  clientId: requiredString(payload.clientId, "state.clientId"),
778
- tokenEndpoint: requiredString(payload.tokenEndpoint, "state.tokenEndpoint"),
779
- authorizationServer: requiredString(payload.authorizationServer, "state.authorizationServer"),
780
- issuer: requiredString(payload.issuer, "state.issuer"),
813
+ tokenEndpoint: oauthEndpointUrl(
814
+ requiredString(payload.tokenEndpoint, "state.tokenEndpoint"),
815
+ settings,
816
+ "OAuth token endpoint",
817
+ ),
818
+ authorizationServer: oauthEndpointUrl(
819
+ requiredString(payload.authorizationServer, "state.authorizationServer"),
820
+ settings,
821
+ "OAuth authorization server",
822
+ ).replace(/\/+$/, ""),
823
+ issuer: oauthEndpointUrl(
824
+ requiredString(payload.issuer, "state.issuer"),
825
+ settings,
826
+ "OAuth issuer",
827
+ ),
781
828
  clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
782
829
  tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
783
830
  ...(stringValue(payload.encryptedClientSecret)
@@ -825,7 +872,12 @@ async function clientForState(
825
872
  }
826
873
  if (state.clientRegistrationMethod === "dcr") {
827
874
  const stored = await loadIntegrationOAuthClient(db, settings, state.issuer);
828
- if (!stored || stored.clientId !== state.clientId) {
875
+ if (
876
+ !stored ||
877
+ stored.clientId !== state.clientId ||
878
+ stored.issuer !== state.issuer ||
879
+ stored.authorizationServer !== state.authorizationServer
880
+ ) {
829
881
  throw new HTTPException(400, { message: "OAuth client registration is no longer available" });
830
882
  }
831
883
  return {
@@ -870,7 +922,6 @@ async function exchangeAuthorizationCode(
870
922
  client: OAuthClientRegistration;
871
923
  },
872
924
  ): Promise<TokenResponse> {
873
- await assertOAuthFetchAllowed(input.tokenEndpoint, settings);
874
925
  const body = new URLSearchParams();
875
926
  body.set("grant_type", "authorization_code");
876
927
  body.set("code", input.code);
@@ -905,7 +956,11 @@ async function exchangeAuthorizationCode(
905
956
  new Error(`OAuth token endpoint returned HTTP ${response.status}`),
906
957
  );
907
958
  }
908
- const payload = (await response.json()) as Record<string, unknown>;
959
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
960
+ response,
961
+ OAUTH_MAX_RESPONSE_BYTES,
962
+ "OAuth token response",
963
+ );
909
964
  const accessToken = stringValue(payload.access_token);
910
965
  if (!accessToken) {
911
966
  throw new Error("OAuth token response did not include access_token");
@@ -997,12 +1052,17 @@ function safeHost(rawUrl: string): string | undefined {
997
1052
  async function oauthErrorFromResponse(response: Response): Promise<string | null> {
998
1053
  const contentType = response.headers.get("content-type") ?? "";
999
1054
  if (!contentType.toLowerCase().includes("application/json")) {
1055
+ await cancelResponseBody(response);
1000
1056
  return null;
1001
1057
  }
1002
- const payload = (await response
1003
- .clone()
1004
- .json()
1005
- .catch(() => null)) as Record<string, unknown> | null;
1058
+ // Consume the original response, not a clone. The pinned transport owns a
1059
+ // per-response dispatcher, so leaving the original body unread would retain
1060
+ // its socket pool after a token endpoint error.
1061
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
1062
+ response,
1063
+ OAUTH_MAX_RESPONSE_BYTES,
1064
+ "OAuth token error response",
1065
+ ).catch(() => null);
1006
1066
  const error = stringValue(payload?.error);
1007
1067
  if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
1008
1068
  return null;
@@ -1015,7 +1075,6 @@ async function verifyMcpToolsList(
1015
1075
  resource: string,
1016
1076
  token: TokenResponse,
1017
1077
  ): Promise<Array<{ name: string; description?: string }>> {
1018
- await assertOAuthFetchAllowed(resource, settings);
1019
1078
  const client = new Client(
1020
1079
  { name: "opengeni-integration-verify", version: "0.1.0" },
1021
1080
  { capabilities: {} },
@@ -1158,6 +1217,20 @@ function canonicalOAuthResource(value: string): string {
1158
1217
  }
1159
1218
  }
1160
1219
 
1220
+ function oauthEndpointUrl(rawUrl: string, settings: Settings, label: string): string {
1221
+ try {
1222
+ return validateHttpUrl(rawUrl, {
1223
+ label,
1224
+ allowLoopbackHttp: isLocalTestEnvironment(settings.environment),
1225
+ });
1226
+ } catch (error) {
1227
+ if (error instanceof DestinationPolicyError) {
1228
+ throw new HTTPException(422, { message: error.message });
1229
+ }
1230
+ throw error;
1231
+ }
1232
+ }
1233
+
1161
1234
  function safeReturnPath(value: string): string {
1162
1235
  if (!value.startsWith("/") || value.startsWith("//")) {
1163
1236
  throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
@@ -1172,9 +1245,14 @@ function safeReturnPath(value: string): string {
1172
1245
  async function fetchJsonObject(url: string, settings: Settings): Promise<Record<string, unknown>> {
1173
1246
  const response = await fetchOAuth(url, settings, { headers: { accept: "application/json" } });
1174
1247
  if (!response.ok) {
1248
+ await cancelResponseBody(response);
1175
1249
  throw new Error(`HTTP ${response.status}`);
1176
1250
  }
1177
- const payload = await response.json();
1251
+ const payload = await readResponseJsonBounded<unknown>(
1252
+ response,
1253
+ OAUTH_MAX_RESPONSE_BYTES,
1254
+ "OAuth metadata response",
1255
+ );
1178
1256
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1179
1257
  throw new Error("metadata response was not a JSON object");
1180
1258
  }
@@ -1187,56 +1265,64 @@ async function fetchOAuth(
1187
1265
  init: RequestInit = {},
1188
1266
  hop = 0,
1189
1267
  ): Promise<Response> {
1190
- await assertOAuthFetchAllowed(rawUrl, settings);
1191
- const response = await fetch(rawUrl, { ...init, redirect: "manual" });
1268
+ let response: Response;
1269
+ try {
1270
+ const endpoint = oauthEndpointUrl(rawUrl, settings, "OAuth endpoint");
1271
+ response = await pinnedFetch(endpoint, init, settings, {
1272
+ label: "OAuth discovery",
1273
+ requireHttpsOutsideLocalTest: true,
1274
+ });
1275
+ } catch (error) {
1276
+ if (error instanceof DestinationPolicyError) {
1277
+ throw new HTTPException(422, { message: error.message });
1278
+ }
1279
+ throw error;
1280
+ }
1192
1281
  if (response.status < 300 || response.status >= 400) {
1193
1282
  return response;
1194
1283
  }
1284
+ // Discovery is the only redirectable OAuth traffic. Replaying a token
1285
+ // exchange, dynamic registration, or authenticated MCP request would send
1286
+ // its body and/or credential headers to a provider-controlled Location.
1287
+ // Keep this allowlist deliberately narrow so future credential headers fail
1288
+ // closed instead of silently becoming redirectable.
1289
+ if (!oauthRequestMayFollowRedirect(init)) {
1290
+ await cancelResponseBody(response);
1291
+ throw new HTTPException(422, {
1292
+ message: "OAuth credential-bearing requests may not follow redirects",
1293
+ });
1294
+ }
1195
1295
  if (hop >= 3) {
1296
+ await cancelResponseBody(response);
1196
1297
  throw new HTTPException(422, { message: "OAuth fetch exceeded maximum redirect hops" });
1197
1298
  }
1198
1299
  const location = response.headers.get("location");
1199
1300
  if (!location) {
1301
+ await cancelResponseBody(response);
1200
1302
  throw new HTTPException(422, { message: "OAuth fetch redirect was missing Location" });
1201
1303
  }
1202
1304
  let nextUrl: string;
1203
1305
  try {
1204
1306
  nextUrl = new URL(location, rawUrl).toString();
1205
1307
  } catch {
1308
+ await cancelResponseBody(response);
1206
1309
  throw new HTTPException(422, { message: "OAuth fetch redirect Location was invalid" });
1207
1310
  }
1311
+ await cancelResponseBody(response);
1208
1312
  return await fetchOAuth(nextUrl, settings, init, hop + 1);
1209
1313
  }
1210
1314
 
1211
- async function assertOAuthFetchAllowed(rawUrl: string, settings: Settings): Promise<void> {
1212
- const url = new URL(rawUrl);
1213
- if (!["https:", "http:"].includes(url.protocol)) {
1214
- throw new HTTPException(422, { message: "OAuth discovery only supports http and https URLs" });
1215
- }
1216
- if (
1217
- settings.integrationsAllowPrivateNetworkTargets ||
1218
- ["local", "test"].includes(settings.environment)
1219
- ) {
1220
- return;
1221
- }
1222
- if (url.protocol !== "https:") {
1223
- throw new HTTPException(422, {
1224
- message: "OAuth discovery targets must use https outside local/test",
1225
- });
1226
- }
1227
- const hostname = url.hostname.toLowerCase();
1228
- if (hostname === "localhost" || hostname.endsWith(".localhost")) {
1229
- throw new HTTPException(422, { message: "OAuth discovery may not target localhost" });
1230
- }
1231
- const literal = isIP(hostname);
1232
- const addresses = literal
1233
- ? [hostname]
1234
- : (await lookup(hostname, { all: true })).map((entry) => entry.address);
1235
- if (addresses.some(isPrivateAddress)) {
1236
- throw new HTTPException(422, {
1237
- message: "OAuth discovery may not target private network addresses",
1238
- });
1315
+ function oauthRequestMayFollowRedirect(init: RequestInit): boolean {
1316
+ const method = (init.method ?? "GET").toUpperCase();
1317
+ if ((method !== "GET" && method !== "HEAD") || init.body != null) {
1318
+ return false;
1239
1319
  }
1320
+ const headers = new Headers(init.headers);
1321
+ return [...headers.keys()].every((name) => name === "accept");
1322
+ }
1323
+
1324
+ async function cancelResponseBody(response: Response): Promise<void> {
1325
+ await response.body?.cancel().catch(() => undefined);
1240
1326
  }
1241
1327
 
1242
1328
  function parseWwwAuthenticate(header: string | null): WwwAuthenticateChallenge {