@codemation/core-nodes 0.7.1 → 0.8.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/dist/index.cjs +957 -70
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +527 -61
  5. package/dist/index.d.ts +527 -61
  6. package/dist/index.js +936 -69
  7. package/dist/index.js.map +1 -1
  8. package/dist/metadata.json +162 -0
  9. package/package.json +5 -4
  10. package/src/authoring/defineRestNode.types.ts +17 -2
  11. package/src/chatModels/CodemationChatModelConfig.ts +47 -0
  12. package/src/chatModels/CodemationChatModelFactory.ts +103 -0
  13. package/src/chatModels/ManagedModelFetcher.ts +23 -0
  14. package/src/http/HttpRequestExecutor.ts +10 -2
  15. package/src/http/SSRFBlockedError.ts +16 -0
  16. package/src/http/SsrfGuard.ts +141 -0
  17. package/src/http/httpRequest.types.ts +6 -0
  18. package/src/index.ts +4 -0
  19. package/src/nodes/AIAgentConfig.ts +66 -0
  20. package/src/nodes/AIAgentNode.ts +205 -27
  21. package/src/nodes/BM25Index.ts +90 -0
  22. package/src/nodes/CallbackNodeFactory.ts +7 -0
  23. package/src/nodes/CronTriggerFactory.ts +9 -1
  24. package/src/nodes/DeferredMetaToolStrategy.ts +200 -0
  25. package/src/nodes/DeferredMetaToolStrategyFactory.ts +18 -0
  26. package/src/nodes/HttpRequestNodeFactory.ts +10 -3
  27. package/src/nodes/ManualTriggerFactory.ts +16 -1
  28. package/src/nodes/ToolLoadingStrategy.ts +28 -0
  29. package/src/nodes/WebhookTriggerFactory.ts +16 -2
  30. package/src/nodes/aggregate.ts +13 -2
  31. package/src/nodes/aiAgent.ts +9 -0
  32. package/src/nodes/assertion.ts +14 -1
  33. package/src/nodes/collections/collectionDeleteNode.types.ts +6 -0
  34. package/src/nodes/collections/collectionFindOneNode.types.ts +6 -0
  35. package/src/nodes/collections/collectionGetNode.types.ts +6 -0
  36. package/src/nodes/collections/collectionInsertNode.types.ts +6 -0
  37. package/src/nodes/collections/collectionListNode.types.ts +6 -0
  38. package/src/nodes/collections/collectionUpdateNode.types.ts +6 -0
  39. package/src/nodes/filter.ts +14 -2
  40. package/src/nodes/httpRequest.ts +72 -8
  41. package/src/nodes/if.ts +14 -2
  42. package/src/nodes/mapData.ts +13 -2
  43. package/src/nodes/merge.ts +9 -2
  44. package/src/nodes/noOp.ts +0 -1
  45. package/src/nodes/split.ts +13 -2
  46. package/src/nodes/subWorkflow.ts +15 -2
  47. package/src/nodes/switch.ts +18 -2
  48. package/src/nodes/testTrigger.ts +13 -0
  49. package/src/nodes/wait.ts +7 -1
  50. package/src/workflowAuthoring/WorkflowChatModelFactory.types.ts +4 -0
  51. package/src/workflows/AIAgentConnectionWorkflowExpander.ts +6 -3
  52. package/tsconfig.json +3 -1
@@ -0,0 +1,162 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "packageName": "@codemation/core-nodes",
4
+ "packageVersion": "0.8.1",
5
+ "description": "",
6
+ "kind": "nodes",
7
+ "nodes": [
8
+ {
9
+ "name": "Collection: Delete",
10
+ "kind": "node",
11
+ "description": "Delete a row by id from a collection.",
12
+ "inputPorts": [
13
+ "main"
14
+ ],
15
+ "outputPorts": [
16
+ "main"
17
+ ],
18
+ "sourcePath": "src/nodes/collections/collectionDeleteNode.types.ts"
19
+ },
20
+ {
21
+ "name": "Collection: Find One",
22
+ "kind": "node",
23
+ "description": "Find a single row matching a filter in a collection.",
24
+ "inputPorts": [
25
+ "main"
26
+ ],
27
+ "outputPorts": [
28
+ "main"
29
+ ],
30
+ "sourcePath": "src/nodes/collections/collectionFindOneNode.types.ts"
31
+ },
32
+ {
33
+ "name": "Collection: Get",
34
+ "kind": "node",
35
+ "description": "Get a single row by id from a collection.",
36
+ "inputPorts": [
37
+ "main"
38
+ ],
39
+ "outputPorts": [
40
+ "main"
41
+ ],
42
+ "sourcePath": "src/nodes/collections/collectionGetNode.types.ts"
43
+ },
44
+ {
45
+ "name": "Collection: Insert",
46
+ "kind": "node",
47
+ "description": "Insert a new row into a collection.",
48
+ "inputPorts": [
49
+ "main"
50
+ ],
51
+ "outputPorts": [
52
+ "main"
53
+ ],
54
+ "sourcePath": "src/nodes/collections/collectionInsertNode.types.ts"
55
+ },
56
+ {
57
+ "name": "Collection: List",
58
+ "kind": "node",
59
+ "description": "List rows from a collection with optional pagination and filtering.",
60
+ "inputPorts": [
61
+ "main"
62
+ ],
63
+ "outputPorts": [
64
+ "main"
65
+ ],
66
+ "sourcePath": "src/nodes/collections/collectionListNode.types.ts"
67
+ },
68
+ {
69
+ "name": "Collection: Update",
70
+ "kind": "node",
71
+ "description": "Update a row by id in a collection.",
72
+ "inputPorts": [
73
+ "main"
74
+ ],
75
+ "outputPorts": [
76
+ "main"
77
+ ],
78
+ "sourcePath": "src/nodes/collections/collectionUpdateNode.types.ts"
79
+ }
80
+ ],
81
+ "credentials": [
82
+ {
83
+ "name": "core-nodes.api-key",
84
+ "description": "Authenticates requests by injecting an API key into a header or query parameter.",
85
+ "fields": [
86
+ {
87
+ "key": "placement",
88
+ "type": "string",
89
+ "required": true
90
+ },
91
+ {
92
+ "key": "name",
93
+ "type": "string",
94
+ "required": true
95
+ },
96
+ {
97
+ "key": "apiKey",
98
+ "type": "password",
99
+ "required": true
100
+ }
101
+ ]
102
+ },
103
+ {
104
+ "name": "core-nodes.basic-auth",
105
+ "description": "Authenticates requests using HTTP Basic Authentication (username + password).",
106
+ "fields": [
107
+ {
108
+ "key": "username",
109
+ "type": "string",
110
+ "required": true
111
+ },
112
+ {
113
+ "key": "password",
114
+ "type": "password",
115
+ "required": true
116
+ }
117
+ ]
118
+ },
119
+ {
120
+ "name": "core-nodes.bearer-token",
121
+ "description": "Authenticates requests using a static Bearer token in the Authorization header.",
122
+ "fields": [
123
+ {
124
+ "key": "token",
125
+ "type": "password",
126
+ "required": true
127
+ }
128
+ ]
129
+ },
130
+ {
131
+ "name": "core-nodes.oauth2-client-credentials",
132
+ "description": "Machine-to-machine OAuth2 using the client_credentials grant. Exchanges client ID and secret for a bearer token before each workflow execution.",
133
+ "fields": [
134
+ {
135
+ "key": "tokenUrl",
136
+ "type": "string",
137
+ "required": true
138
+ },
139
+ {
140
+ "key": "scopes",
141
+ "type": "string",
142
+ "required": true
143
+ },
144
+ {
145
+ "key": "audience",
146
+ "type": "string",
147
+ "required": true
148
+ },
149
+ {
150
+ "key": "clientId",
151
+ "type": "string",
152
+ "required": true
153
+ },
154
+ {
155
+ "key": "clientSecret",
156
+ "type": "password",
157
+ "required": true
158
+ }
159
+ ]
160
+ }
161
+ ]
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemation/core-nodes",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -32,11 +32,11 @@
32
32
  "@ai-sdk/provider": "^3.0.8",
33
33
  "ai": "^6.0.168",
34
34
  "croner": "^10.0.1",
35
- "lucide-react": "^0.577.0",
36
- "@codemation/core": "0.10.2"
35
+ "@codemation/core": "0.11.1"
37
36
  },
38
37
  "devDependencies": {
39
38
  "@types/node": "^25.3.5",
39
+ "lucide-react": "^0.577.0",
40
40
  "eslint": "^10.0.3",
41
41
  "reflect-metadata": "^0.2.2",
42
42
  "tsdown": "^0.15.5",
@@ -48,7 +48,8 @@
48
48
  "scripts": {
49
49
  "changeset:verify": "pnpm --workspace-root run changeset:verify",
50
50
  "dev": "tsdown --watch",
51
- "build": "tsdown",
51
+ "build": "tsdown && pnpm build:metadata",
52
+ "build:metadata": "tsx ../../tooling/discovery/scripts/extract-metadata.ts",
52
53
  "typecheck": "tsc -p tsconfig.json --noEmit",
53
54
  "lint": "eslint .",
54
55
  "test": "pnpm --filter @codemation/core build && pnpm build && vitest run",
@@ -1,10 +1,11 @@
1
1
  import { defineNode } from "@codemation/core";
2
- import type { DefinedNode, DefinedNodeCredentialBindings } from "@codemation/core";
2
+ import type { DefinedNode, DefinedNodeCredentialBindings, NodeInspectorSummaryRow } from "@codemation/core";
3
3
  import type { ZodType } from "zod";
4
4
  import type { HttpBodySpec } from "../http/httpRequest.types";
5
5
  import { HttpRequestExecutor } from "../http/HttpRequestExecutor";
6
6
  import { HttpBodyBuilder } from "../http/HttpBodyBuilder";
7
7
  import { HttpUrlBuilder } from "../http/HttpUrlBuilder";
8
+ import { SsrfGuard } from "../http/SsrfGuard";
8
9
 
9
10
  type MaybePromise<T> = T | Promise<T>;
10
11
 
@@ -96,6 +97,14 @@ export interface DefineRestNodeOptions<
96
97
  * @default "throw"
97
98
  */
98
99
  readonly errorPolicy?: RestNodeErrorPolicy;
100
+ /**
101
+ * Static configuration summary surfaced in the workflow inspector.
102
+ * Receives the static config (empty record for defineRestNode — config lives on item input).
103
+ * Most callers return rows based on the static `api` descriptor instead.
104
+ */
105
+ readonly inspectorSummary?: (
106
+ args: Readonly<{ config: Record<string, never> }>,
107
+ ) => ReadonlyArray<NodeInspectorSummaryRow> | undefined;
99
108
  }
100
109
 
101
110
  /**
@@ -149,6 +158,7 @@ export function defineRestNode<
149
158
  icon: options.icon,
150
159
  credentials: options.credentials,
151
160
  inputSchema: options.inputSchema,
161
+ inspectorSummary: options.inspectorSummary,
152
162
  async execute({ input, item, ctx }, { credentials }) {
153
163
  // Resolve credential if one is bound.
154
164
  const credentialSlot = options.credentials ? Object.keys(options.credentials)[0] : undefined;
@@ -163,7 +173,12 @@ export function defineRestNode<
163
173
  const resolvedPath = substitutePath(options.api.path, pathParams);
164
174
  const resolvedUrl = `${options.api.baseUrl}${resolvedPath}`;
165
175
 
166
- const executor = new HttpRequestExecutor(globalThis.fetch, new HttpBodyBuilder(), new HttpUrlBuilder());
176
+ const executor = new HttpRequestExecutor(
177
+ globalThis.fetch,
178
+ new HttpBodyBuilder(),
179
+ new HttpUrlBuilder(),
180
+ new SsrfGuard(),
181
+ );
167
182
  const result = await executor.execute(
168
183
  {
169
184
  url: resolvedUrl,
@@ -0,0 +1,47 @@
1
+ import type { AgentCanvasPresentation, ChatModelConfig } from "@codemation/core";
2
+
3
+ import type { CanvasIconName } from "../canvasIconName";
4
+ import { CodemationChatModelFactory } from "./CodemationChatModelFactory";
5
+
6
+ /**
7
+ * A platform-managed model entry as returned by GET /api/llm/managed-models.
8
+ */
9
+ export interface ManagedModelDto {
10
+ id: string;
11
+ modelId: string;
12
+ displayName: string;
13
+ providerKey: string;
14
+ inputCostPerMTok: number;
15
+ outputCostPerMTok: number;
16
+ contextWindow: number;
17
+ tier: string;
18
+ }
19
+
20
+ /**
21
+ * Bifrost-namespaced model ID. Kept as `string` so runtime-fetched model IDs
22
+ * (from the CP allowlist) work without compile-time enumeration.
23
+ * Story C replaced the prior hardcoded union with this open type.
24
+ */
25
+ export type CodemationManagedModel = string;
26
+
27
+ export class CodemationChatModelConfig implements ChatModelConfig {
28
+ readonly type = CodemationChatModelFactory;
29
+ readonly presentation: AgentCanvasPresentation<CanvasIconName>;
30
+ readonly provider = "codemation-managed";
31
+ readonly modelName: string;
32
+
33
+ constructor(
34
+ public readonly name: string,
35
+ public readonly model: CodemationManagedModel,
36
+ presentationIn?: AgentCanvasPresentation<CanvasIconName>,
37
+ public readonly options?: Readonly<{
38
+ temperature?: number;
39
+ maxTokens?: number;
40
+ }>,
41
+ ) {
42
+ this.modelName = model;
43
+ this.presentation = presentationIn ?? { icon: "lucide:bot", label: name };
44
+ }
45
+
46
+ // No getCredentialRequirements() — authentication is implicit via workspace pairing secret (D2).
47
+ }
@@ -0,0 +1,103 @@
1
+ import { createHmac, createHash, randomBytes } from "node:crypto";
2
+ import type { ChatLanguageModel, ChatModelFactory, NodeExecutionContext } from "@codemation/core";
3
+ import { chatModel } from "@codemation/core";
4
+
5
+ import { createOpenAI } from "@ai-sdk/openai";
6
+
7
+ import type { CodemationChatModelConfig } from "./CodemationChatModelConfig";
8
+
9
+ @chatModel({ packageName: "@codemation/core-nodes" })
10
+ export class CodemationChatModelFactory implements ChatModelFactory<CodemationChatModelConfig> {
11
+ create(
12
+ args: Readonly<{ config: CodemationChatModelConfig; ctx: NodeExecutionContext<any> }>,
13
+ ): Promise<ChatLanguageModel> {
14
+ // D5: read at session-create time so unpairing or misconfiguration surfaces at workflow run, not boot.
15
+ // eslint-disable-next-line no-restricted-properties -- LLM_GATEWAY_URL is injected by the control-plane provisioner into the workspace process env; reading it at factory-create time is the justified boundary.
16
+ const gatewayUrl = process.env["LLM_GATEWAY_URL"];
17
+ if (!gatewayUrl) {
18
+ throw new Error("Codemation managed AI not available in this environment (LLM_GATEWAY_URL is not set).");
19
+ }
20
+
21
+ // eslint-disable-next-line no-restricted-properties -- workspace pairing vars are injected by the provisioner; factory reading them here is the justified boundary.
22
+ const workspaceId = process.env["WORKSPACE_ID"];
23
+ // eslint-disable-next-line no-restricted-properties -- workspace pairing vars are injected by the provisioner; factory reading them here is the justified boundary.
24
+ const pairingSecret = process.env["WORKSPACE_PAIRING_SECRET"];
25
+ if (!workspaceId || !pairingSecret) {
26
+ throw new Error("Codemation managed AI not available in this environment (workspace pairing is not configured).");
27
+ }
28
+
29
+ const hmacFetch = this.buildHmacSignedFetch(workspaceId, pairingSecret);
30
+ // apiKey is required by the AI SDK but unused — authentication is handled by the HMAC-signed fetch wrapper.
31
+ const provider = createOpenAI({ baseURL: `${gatewayUrl}/v1`, apiKey: "codemation-managed", fetch: hmacFetch });
32
+ const languageModel = provider.chat(args.config.model);
33
+
34
+ return Promise.resolve({
35
+ languageModel,
36
+ modelName: args.config.model,
37
+ provider: "codemation-managed",
38
+ defaultCallOptions: {
39
+ maxOutputTokens: args.config.options?.maxTokens,
40
+ temperature: args.config.options?.temperature,
41
+ },
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Creates an HMAC-signed fetch wrapper for use with AI SDK's createOpenAI.
47
+ * Each call signs the request body with the workspace pairing secret so the
48
+ * LLM broker can authenticate the workspace without a user-managed API key.
49
+ *
50
+ * Mirrors HmacRequestSigner from @codemation/host/pairing without importing
51
+ * that package (which would create a circular dependency since @codemation/host
52
+ * depends on @codemation/core-nodes).
53
+ */
54
+ private buildHmacSignedFetch(workspaceId: string, pairingSecret: string): typeof fetch {
55
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
56
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
57
+ const method = init?.method ?? "POST";
58
+
59
+ // Normalise body to a string for signing. Chat completions are always JSON strings
60
+ // but the fetch spec allows other BodyInit types — handle those defensively.
61
+ let bodyString = "";
62
+ if (init?.body !== undefined && init.body !== null) {
63
+ if (typeof init.body === "string") {
64
+ bodyString = init.body;
65
+ } else {
66
+ bodyString = await new Response(init.body).text();
67
+ }
68
+ }
69
+
70
+ const authHeader = this.buildHmacAuthHeader(workspaceId, pairingSecret, method, url, bodyString);
71
+
72
+ const headers = new Headers(init?.headers as Record<string, string> | undefined);
73
+ headers.set("Authorization", authHeader);
74
+
75
+ // Use the same (possibly normalised) body string that was signed.
76
+ const effectiveBody = bodyString || init?.body;
77
+ return fetch(input, { ...init, body: effectiveBody, headers });
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Produces a Codemation-Hmac v1 Authorization header value.
83
+ * The algorithm must match HmacVerifier.computeSignature() in the control-plane.
84
+ */
85
+ private buildHmacAuthHeader(
86
+ workspaceId: string,
87
+ pairingSecret: string,
88
+ method: string,
89
+ url: string,
90
+ body: string,
91
+ ): string {
92
+ const ts = Math.floor(Date.now() / 1000);
93
+ const nonce = randomBytes(16).toString("base64");
94
+ const parsed = new URL(url);
95
+ const path = (parsed.pathname + parsed.search).toLowerCase();
96
+ const bodyHash = createHash("sha256").update(body, "utf8").digest("hex");
97
+ const baseString = [method.toUpperCase(), path, ts, nonce, bodyHash].join("\n");
98
+ // eslint-disable-next-line codemation/no-buffer-everything -- pairing secret is a fixed 32-byte value, never large
99
+ const secretBytes = Buffer.from(pairingSecret, "base64");
100
+ const sig = createHmac("sha256", secretBytes).update(baseString, "utf8").digest("base64");
101
+ return `Codemation-Hmac v=1,workspaceId=${workspaceId},ts=${ts},nonce=${nonce},sig=${sig}`;
102
+ }
103
+ }
@@ -0,0 +1,23 @@
1
+ import type { ManagedModelDto } from "./CodemationChatModelConfig";
2
+
3
+ /**
4
+ * Fetches the active platform-managed model allowlist from the CP.
5
+ * Reads CONTROL_PLANE_URL from the workspace process env.
6
+ * Returns an empty array if the env var is absent or the fetch fails.
7
+ * Cache the result per session — the allowlist changes infrequently.
8
+ */
9
+ export class ManagedModelFetcher {
10
+ async fetch(): Promise<ManagedModelDto[]> {
11
+ // eslint-disable-next-line no-restricted-properties -- CONTROL_PLANE_URL is injected by the provisioner; this class is the justified boundary.
12
+ const cpUrl = process.env["CONTROL_PLANE_URL"];
13
+ if (!cpUrl) return [];
14
+
15
+ try {
16
+ const res = await globalThis.fetch(`${cpUrl}/api/llm/managed-models`);
17
+ if (!res.ok) return [];
18
+ return (await res.json()) as ManagedModelDto[];
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+ }
@@ -2,6 +2,7 @@ import type { Item } from "@codemation/core";
2
2
  import type { HttpRequestResult, HttpRequestSpec } from "./httpRequest.types";
3
3
  import type { HttpBodyBuilder } from "./HttpBodyBuilder";
4
4
  import type { HttpUrlBuilder } from "./HttpUrlBuilder";
5
+ import type { SsrfGuard } from "./SsrfGuard";
5
6
 
6
7
  /**
7
8
  * Executes a single HTTP request described by {@link HttpRequestSpec}.
@@ -9,27 +10,34 @@ import type { HttpUrlBuilder } from "./HttpUrlBuilder";
9
10
  * - Credential sessions provide header/query deltas via `applyToRequest`.
10
11
  * - Body encoding is delegated to {@link HttpBodyBuilder}.
11
12
  * - URL query merging is delegated to {@link HttpUrlBuilder}.
13
+ * - SSRF protection is delegated to {@link SsrfGuard} (injected).
12
14
  * - Binary response bodies: when `download.mode` triggers binary attach, the
13
15
  * `bodyBinaryName` field is set in the result but the body is NOT read here.
14
16
  * Callers that need binary attachment should use `buildRequest` to get the
15
17
  * resolved URL + init and make the fetch + binary attach themselves.
16
18
  *
17
- * Collaborators (`fetch`, body builder, url builder) are injected so callers
18
- * own construction at composition roots and tests can supply deterministic stubs.
19
+ * Collaborators (`fetch`, body builder, url builder, ssrfGuard) are injected so
20
+ * callers own construction at composition roots and tests can supply deterministic stubs.
19
21
  */
20
22
  export class HttpRequestExecutor {
21
23
  constructor(
22
24
  private readonly fetchFn: typeof globalThis.fetch,
23
25
  private readonly bodyBuilder: HttpBodyBuilder,
24
26
  private readonly urlBuilder: HttpUrlBuilder,
27
+ private readonly ssrfGuard: SsrfGuard,
25
28
  ) {}
26
29
 
27
30
  /**
28
31
  * Builds the fetch init (headers, query, body) from the spec + credential delta,
29
32
  * returning both the resolved URL and the RequestInit so callers can make the
30
33
  * actual fetch call themselves (useful for streaming / binary attach).
34
+ *
35
+ * Also performs SSRF protection via the injected {@link SsrfGuard} before
36
+ * returning — throws {@link SSRFBlockedError} if the target is a private address.
31
37
  */
32
38
  async buildRequest(spec: HttpRequestSpec, item: Item): Promise<Readonly<{ url: string; init: RequestInit }>> {
39
+ await this.ssrfGuard.check(spec.url, spec.allowPrivateNetworkTargets ?? false);
40
+
33
41
  const credentialDelta = spec.credential?.applyToRequest(spec) ?? {};
34
42
 
35
43
  const mergedHeaders: Record<string, string> = {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Thrown when an HTTP request target resolves to a private, link-local, or
3
+ * loopback address and `allowPrivateNetworkTargets` is not set.
4
+ */
5
+ export class SSRFBlockedError extends Error {
6
+ readonly resolvedIp: string;
7
+
8
+ constructor(host: string, resolvedIp: string) {
9
+ super(
10
+ `SSRF protection blocked request to host "${host}" — resolved IP ${resolvedIp} is a private, ` +
11
+ `link-local, or loopback address. Set allowPrivateNetworkTargets: true to allow trusted internal targets.`,
12
+ );
13
+ this.name = "SSRFBlockedError";
14
+ this.resolvedIp = resolvedIp;
15
+ }
16
+ }
@@ -0,0 +1,141 @@
1
+ import dns from "node:dns/promises";
2
+ import { SSRFBlockedError } from "./SSRFBlockedError";
3
+
4
+ export { SSRFBlockedError } from "./SSRFBlockedError";
5
+
6
+ /** Emitted once per process when NODE_ENV=production and no allowedOutboundHosts is set. */
7
+ let _productionNoAllowlistWarned = false;
8
+
9
+ /**
10
+ * Guards HTTP requests against Server-Side Request Forgery (SSRF) by
11
+ * DNS-resolving the target host and rejecting private/link-local/loopback
12
+ * addresses.
13
+ *
14
+ * Blocked ranges:
15
+ * - RFC-1918: 10/8, 172.16/12, 192.168/16
16
+ * - Link-local: 169.254/16
17
+ * - Loopback: 127/8, ::1
18
+ *
19
+ * When `allowedOutboundHosts` is set, every resolved DNS target must match
20
+ * at least one entry in the list (exact hostname or `*.example.com` wildcard).
21
+ * When unset, existing behaviour applies: private ranges blocked, public allowed.
22
+ *
23
+ * Call {@link check} before making any outbound HTTP request.
24
+ * Pass `allowPrivate: true` to bypass the private-network guard for trusted workflows
25
+ * (allowedOutboundHosts allowlist is still applied when set).
26
+ */
27
+ export class SsrfGuard {
28
+ constructor(private readonly allowedOutboundHosts?: ReadonlyArray<string>) {
29
+ if (
30
+ // eslint-disable-next-line no-restricted-properties
31
+ process.env.NODE_ENV === "production" &&
32
+ (allowedOutboundHosts == null || allowedOutboundHosts.length === 0) &&
33
+ !_productionNoAllowlistWarned
34
+ ) {
35
+ _productionNoAllowlistWarned = true;
36
+ console.warn(
37
+ "[SsrfGuard] WARNING: NODE_ENV=production but no allowedOutboundHosts is configured for HttpRequest. " +
38
+ "All public destinations are permitted. Set allowedOutboundHosts to restrict outbound traffic.",
39
+ );
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Resolves the host of `url` via DNS and throws {@link SSRFBlockedError}
45
+ * if any resolved address falls in a blocked range, or if the host does not
46
+ * match the operator-configured allowlist (when set).
47
+ *
48
+ * @param url - Fully-qualified URL of the intended request target.
49
+ * @param allowPrivate - When `true`, the private-network check is skipped.
50
+ * The allowedOutboundHosts check is still applied when set.
51
+ */
52
+ async check(url: string, allowPrivate: boolean): Promise<void> {
53
+ if (allowPrivate && !this.allowedOutboundHosts?.length) return;
54
+
55
+ let host: string;
56
+ try {
57
+ host = new URL(url).hostname;
58
+ } catch {
59
+ // Malformed URL — let the fetch call surface the error.
60
+ return;
61
+ }
62
+
63
+ // Check allowedOutboundHosts allowlist first (hostname match, no DNS needed).
64
+ if (this.allowedOutboundHosts?.length) {
65
+ if (!this.isHostAllowed(host)) {
66
+ throw new SSRFBlockedError(host, host);
67
+ }
68
+ // Host is in the allowlist — skip private-network checks (host is trusted).
69
+ return;
70
+ }
71
+
72
+ // No allowlist: apply the standard private-network SSRF guard.
73
+ if (allowPrivate) return;
74
+
75
+ // Strip IPv6 brackets for the check below.
76
+ const bareHost = host.startsWith("[") ? host.slice(1, -1) : host;
77
+
78
+ // If the host is already a bare IP address, check directly without DNS.
79
+ if (this.isPrivateAddress(bareHost)) {
80
+ throw new SSRFBlockedError(host, bareHost);
81
+ }
82
+
83
+ let addresses: ReadonlyArray<{ address: string; family: number }>;
84
+ try {
85
+ addresses = (await dns.lookup(host, { all: true })) as ReadonlyArray<{ address: string; family: number }>;
86
+ } catch {
87
+ // DNS failure — let the fetch call surface the real error (ENOTFOUND etc.).
88
+ return;
89
+ }
90
+
91
+ for (const { address } of addresses) {
92
+ if (this.isPrivateAddress(address)) {
93
+ throw new SSRFBlockedError(host, address);
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Returns true when `host` matches at least one entry in `allowedOutboundHosts`.
100
+ * Supports exact hostnames (`api.example.com`) and wildcard prefixes (`*.example.com`).
101
+ */
102
+ private isHostAllowed(host: string): boolean {
103
+ for (const allowed of this.allowedOutboundHosts ?? []) {
104
+ if (allowed.startsWith("*.")) {
105
+ // Wildcard: *.example.com matches sub.example.com but NOT example.com itself.
106
+ const suffix = allowed.slice(1); // ".example.com"
107
+ if (host.endsWith(suffix) && host.length > suffix.length) return true;
108
+ } else {
109
+ if (host === allowed) return true;
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+
115
+ private isPrivateAddress(ip: string): boolean {
116
+ return this.isPrivateIPv4(ip) || this.isPrivateIPv6(ip);
117
+ }
118
+
119
+ private isPrivateIPv4(ip: string): boolean {
120
+ const parts = ip.split(".").map(Number);
121
+ if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) {
122
+ return false;
123
+ }
124
+ const [a, b] = parts as [number, number, number, number];
125
+ if (a === 127) return true; // loopback 127/8
126
+ if (a === 10) return true; // RFC-1918 10/8
127
+ if (a === 172 && b >= 16 && b <= 31) return true; // RFC-1918 172.16/12
128
+ if (a === 192 && b === 168) return true; // RFC-1918 192.168/16
129
+ if (a === 169 && b === 254) return true; // link-local 169.254/16
130
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGN 100.64.0.0/10
131
+ return false;
132
+ }
133
+
134
+ private isPrivateIPv6(ip: string): boolean {
135
+ const lower = ip.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
136
+ if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return true; // loopback
137
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7 ULA
138
+ if (lower.startsWith("fe80")) return true; // fe80::/10 link-local
139
+ return false;
140
+ }
141
+ }
@@ -67,6 +67,12 @@ export type HttpRequestSpec = Readonly<{
67
67
  responseBinarySlot?: string;
68
68
  /** Maximum allowed response size in bytes (checked against Content-Length before allocating). Defaults to 100 MiB. */
69
69
  responseSizeCapBytes?: number;
70
+ /**
71
+ * When `false` (default), requests whose target host resolves to an RFC-1918,
72
+ * link-local (169.254/16), or loopback address are blocked to prevent SSRF attacks.
73
+ * Set to `true` only for workflows that intentionally reach private infrastructure.
74
+ */
75
+ allowPrivateNetworkTargets?: boolean;
70
76
  /** Execution context — needed for binary attach. */
71
77
  ctx: NodeExecutionContext<RunnableNodeConfig<unknown, unknown>>;
72
78
  }>;
package/src/index.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  export * from "./canvasIconName";
2
2
  export * from "./credentials/index";
3
3
  export * from "./http/httpRequest.types";
4
+ export { SSRFBlockedError, SsrfGuard } from "./http/SsrfGuard";
4
5
  export * from "./authoring/defineRestNode.types";
5
6
  export * from "./chatModels/OpenAIChatModelFactory";
6
7
  export * from "./chatModels/OpenAiStrictJsonSchemaFactory";
7
8
  export * from "./chatModels/OpenAiCredentialSession";
8
9
  export * from "./chatModels/openAiChatModelConfig";
9
10
  export * from "./chatModels/OpenAiChatModelPresetsFactory";
11
+ export * from "./chatModels/CodemationChatModelFactory";
12
+ export * from "./chatModels/CodemationChatModelConfig";
13
+ export * from "./chatModels/ManagedModelFetcher";
10
14
  export * from "./nodes/aiAgent";
11
15
  export * from "./nodes/assertion";
12
16
  export * from "./nodes/CallbackNodeFactory";