@retinue/agentkit 0.2.0 → 0.3.0
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/README.md +1 -1
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +78 -1
- package/dist/agents/agent.js +118 -5
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +20 -0
- package/dist/agents/engine.js +177 -6
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/content-parts.d.ts +18 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +44 -2
- package/dist/core/events.js +31 -2
- package/dist/core/validation.js +19 -0
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/knowledge.d.ts +8 -0
- package/dist/entries/knowledge.js +8 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +3 -1
- package/dist/graphql/resolvers.js +14 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +24 -0
- package/dist/knowledge/index.js +36 -1
- package/dist/knowledge/retrieval.d.ts +26 -1
- package/dist/knowledge/retrieval.js +77 -1
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +89 -0
- package/dist/models/streaming.js +179 -2
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +46 -0
- package/dist/server/bin.d.ts +19 -0
- package/dist/server/bin.js +226 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +7 -4
- package/dist/server/doctor.d.ts +74 -0
- package/dist/server/doctor.js +280 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +5 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +27 -1
- package/dist/toolkit/index.js +24 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/tools/credentials.d.ts +265 -6
- package/dist/tools/credentials.js +318 -3
- package/dist/tools/define.d.ts +14 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/index.d.ts +8 -0
- package/dist/tools/library/index.js +7 -0
- package/dist/tools/registry.d.ts +33 -0
- package/dist/tools/registry.js +72 -2
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +29 -2
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 5322 messages, built once for every toolkit that sends mail — REQ-056 (#240), task #241.
|
|
3
|
+
*
|
|
4
|
+
* This started in `tools-google` for Gmail (#234) and moved here when `tools-email` needed the same thing. The
|
|
5
|
+
* reason is the one that moved `ssrf.ts`: the parts that are silent when wrong must have exactly one
|
|
6
|
+
* implementation, because a second copy is the one that ends up not encoding a header.
|
|
7
|
+
*
|
|
8
|
+
* Three things here are wrong *silently*, which is why they are here rather than per package:
|
|
9
|
+
*
|
|
10
|
+
* **A non-ASCII subject.** Headers are ASCII by the spec. Putting an umlaut in one raw does not throw — it
|
|
11
|
+
* arrives as mojibake and nobody tells you.
|
|
12
|
+
*
|
|
13
|
+
* **A header carrying a line break.** A subject of `Update` + CRLF + `Bcc: attacker@example.com` is two
|
|
14
|
+
* headers, and the second silently copies the message to somebody. Reachable from untrusted content: an agent
|
|
15
|
+
* composing a subject from a page it scraped or a mail it read is exactly the path.
|
|
16
|
+
*
|
|
17
|
+
* **Part ordering in `multipart/alternative`.** The spec orders parts least-faithful first, so `text/plain`
|
|
18
|
+
* precedes `text/html`. Reversed, a client showing the *last* part it understands displays the plain-text
|
|
19
|
+
* fallback and the HTML is never seen — mail that looks broken to the recipient and fine to the sender.
|
|
20
|
+
*
|
|
21
|
+
* ## Determinism, and why there is no `Date` or `Message-ID`
|
|
22
|
+
*
|
|
23
|
+
* `email_compose_preview` has to produce **byte-identical** output to what a send transmits, or the rehearsal
|
|
24
|
+
* is of a different message. A `Date` stamped at compose time makes that impossible by construction, and a
|
|
25
|
+
* `Message-ID` generated per call makes it impossible too. Both are added by the sending MTA or the provider's
|
|
26
|
+
* API, which is where they belong — they describe the act of sending, not the message the caller wrote. The
|
|
27
|
+
* multipart boundary is derived from a hash of the content for the same reason: a random boundary would make
|
|
28
|
+
* two composes of the same message differ.
|
|
29
|
+
*/
|
|
30
|
+
/** A header value, RFC 2047 encoded when it needs to be — and left alone when it does not. */
|
|
31
|
+
export declare const encodeHeader: (value: string) => string;
|
|
32
|
+
export declare class HeaderInjectionError extends Error {
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Refuses a header value that would inject another header.
|
|
36
|
+
*
|
|
37
|
+
* Refusing rather than stripping: no legitimate subject or address contains a newline, so nothing correct is
|
|
38
|
+
* lost, and silently removing it would hide an attempt somebody should see.
|
|
39
|
+
*/
|
|
40
|
+
export declare const assertHeaderSafe: (field: string, value: string) => void;
|
|
41
|
+
export type Attachment = {
|
|
42
|
+
readonly filename: string;
|
|
43
|
+
readonly contentType: string;
|
|
44
|
+
/** Base64. The caller encodes, because the bytes may never have been a string. */
|
|
45
|
+
readonly contentBase64: string;
|
|
46
|
+
};
|
|
47
|
+
export type OutgoingMessage = {
|
|
48
|
+
readonly to: readonly string[];
|
|
49
|
+
readonly subject: string;
|
|
50
|
+
readonly cc?: readonly string[];
|
|
51
|
+
readonly bcc?: readonly string[];
|
|
52
|
+
readonly from?: string;
|
|
53
|
+
readonly replyTo?: string;
|
|
54
|
+
/** Plain text. At least one of `text` and `html` is required. */
|
|
55
|
+
readonly text?: string;
|
|
56
|
+
readonly html?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The single-body form, kept because `tools-google` was written against it.
|
|
59
|
+
*
|
|
60
|
+
* Equivalent to `text`. Both exist rather than one being renamed, so moving this module did not change the
|
|
61
|
+
* bytes any existing caller produces — which is the property that made the move safe to make.
|
|
62
|
+
*/
|
|
63
|
+
readonly body?: string;
|
|
64
|
+
readonly attachments?: readonly Attachment[];
|
|
65
|
+
/** The `Message-ID` of the message being replied to. Both threading headers derive from it. */
|
|
66
|
+
readonly inReplyTo?: string;
|
|
67
|
+
/** The original's `References`, so a long thread keeps its whole chain rather than just the last hop. */
|
|
68
|
+
readonly references?: string;
|
|
69
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* A boundary that depends only on the message.
|
|
73
|
+
*
|
|
74
|
+
* Deterministic so `email_compose_preview` and `email_send` produce the same bytes, and content-derived so two
|
|
75
|
+
* different messages do not share one. The loop rules out the remaining case where the chosen boundary appears
|
|
76
|
+
* inside a part — astronomically unlikely with a hash, and cheaper to exclude than to reason about.
|
|
77
|
+
*/
|
|
78
|
+
export declare const boundaryFor: (parts: readonly string[]) => string;
|
|
79
|
+
/**
|
|
80
|
+
* An RFC 5322 message.
|
|
81
|
+
*
|
|
82
|
+
* CRLF line endings, not bare newlines: the spec says CRLF and some servers are strict. Getting it wrong
|
|
83
|
+
* produces a message that works with most providers and is rejected by one, which is the worst kind of bug to
|
|
84
|
+
* find in production.
|
|
85
|
+
*/
|
|
86
|
+
export declare const buildMessage: (message: OutgoingMessage) => string;
|
|
87
|
+
/**
|
|
88
|
+
* Removes the `Bcc` header from a composed message.
|
|
89
|
+
*
|
|
90
|
+
* For SMTP the envelope carries every recipient and the header must not, or each blind recipient can read the
|
|
91
|
+
* whole blind list — the single most embarrassing mail bug there is. The header is kept in the composed form
|
|
92
|
+
* so a preview can show it, and stripped at the transport that would otherwise leak it.
|
|
93
|
+
*/
|
|
94
|
+
export declare const stripBcc: (raw: string) => string;
|
|
95
|
+
/** The value of a header in a composed message, for tests and for reading a provider's echo. */
|
|
96
|
+
export declare const headerOf: (raw: string, name: string) => string | undefined;
|
|
97
|
+
//# sourceMappingURL=mime.d.ts.map
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 5322 messages, built once for every toolkit that sends mail — REQ-056 (#240), task #241.
|
|
3
|
+
*
|
|
4
|
+
* This started in `tools-google` for Gmail (#234) and moved here when `tools-email` needed the same thing. The
|
|
5
|
+
* reason is the one that moved `ssrf.ts`: the parts that are silent when wrong must have exactly one
|
|
6
|
+
* implementation, because a second copy is the one that ends up not encoding a header.
|
|
7
|
+
*
|
|
8
|
+
* Three things here are wrong *silently*, which is why they are here rather than per package:
|
|
9
|
+
*
|
|
10
|
+
* **A non-ASCII subject.** Headers are ASCII by the spec. Putting an umlaut in one raw does not throw — it
|
|
11
|
+
* arrives as mojibake and nobody tells you.
|
|
12
|
+
*
|
|
13
|
+
* **A header carrying a line break.** A subject of `Update` + CRLF + `Bcc: attacker@example.com` is two
|
|
14
|
+
* headers, and the second silently copies the message to somebody. Reachable from untrusted content: an agent
|
|
15
|
+
* composing a subject from a page it scraped or a mail it read is exactly the path.
|
|
16
|
+
*
|
|
17
|
+
* **Part ordering in `multipart/alternative`.** The spec orders parts least-faithful first, so `text/plain`
|
|
18
|
+
* precedes `text/html`. Reversed, a client showing the *last* part it understands displays the plain-text
|
|
19
|
+
* fallback and the HTML is never seen — mail that looks broken to the recipient and fine to the sender.
|
|
20
|
+
*
|
|
21
|
+
* ## Determinism, and why there is no `Date` or `Message-ID`
|
|
22
|
+
*
|
|
23
|
+
* `email_compose_preview` has to produce **byte-identical** output to what a send transmits, or the rehearsal
|
|
24
|
+
* is of a different message. A `Date` stamped at compose time makes that impossible by construction, and a
|
|
25
|
+
* `Message-ID` generated per call makes it impossible too. Both are added by the sending MTA or the provider's
|
|
26
|
+
* API, which is where they belong — they describe the act of sending, not the message the caller wrote. The
|
|
27
|
+
* multipart boundary is derived from a hash of the content for the same reason: a random boundary would make
|
|
28
|
+
* two composes of the same message differ.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
/** A header value, RFC 2047 encoded when it needs to be — and left alone when it does not. */
|
|
32
|
+
export const encodeHeader = (value) => {
|
|
33
|
+
// Only when needed: an ASCII subject must stay readable in the raw message, because half of debugging mail
|
|
34
|
+
// is reading it with your eyes. An encoded-word on everything would be correct and unreadable.
|
|
35
|
+
if (!/[^ -~]/.test(value))
|
|
36
|
+
return value;
|
|
37
|
+
// 75 is the RFC limit for an encoded-word *including* its wrapper, so the payload budget is smaller; base64
|
|
38
|
+
// expands by 4/3, hence a multiple of 3.
|
|
39
|
+
const budget = 45;
|
|
40
|
+
const bytes = Buffer.from(value, "utf8");
|
|
41
|
+
const words = [];
|
|
42
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
43
|
+
// Never split a multi-byte character across two encoded-words: each word must decode on its own.
|
|
44
|
+
let take = Math.min(budget, bytes.length - offset);
|
|
45
|
+
while (take > 0 && offset + take < bytes.length && (bytes[offset + take] & 0xc0) === 0x80)
|
|
46
|
+
take -= 1;
|
|
47
|
+
if (take === 0)
|
|
48
|
+
take = Math.min(budget, bytes.length - offset);
|
|
49
|
+
words.push(`=?UTF-8?B?${bytes.subarray(offset, offset + take).toString("base64")}?=`);
|
|
50
|
+
offset += take;
|
|
51
|
+
}
|
|
52
|
+
// Folded with a space between words, which is how a decoder is told they are one value.
|
|
53
|
+
return words.join(" ");
|
|
54
|
+
};
|
|
55
|
+
export class HeaderInjectionError extends Error {
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Refuses a header value that would inject another header.
|
|
59
|
+
*
|
|
60
|
+
* Refusing rather than stripping: no legitimate subject or address contains a newline, so nothing correct is
|
|
61
|
+
* lost, and silently removing it would hide an attempt somebody should see.
|
|
62
|
+
*/
|
|
63
|
+
export const assertHeaderSafe = (field, value) => {
|
|
64
|
+
if (/[\r\n]/.test(value)) {
|
|
65
|
+
throw new HeaderInjectionError(`The ${field} contains a line break, which would inject a new mail header. Refused rather than sent — ` +
|
|
66
|
+
"this is how a Bcc gets added to a message nobody meant to copy.");
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const CRLF = "\r\n";
|
|
70
|
+
/** Base64, folded at 76 characters — an unfolded base64 body is non-conformant and some servers reject it. */
|
|
71
|
+
const foldedBase64 = (value) => {
|
|
72
|
+
const encoded = Buffer.from(value, "utf8").toString("base64");
|
|
73
|
+
return encoded.match(/.{1,76}/g)?.join(CRLF) ?? "";
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* A boundary that depends only on the message.
|
|
77
|
+
*
|
|
78
|
+
* Deterministic so `email_compose_preview` and `email_send` produce the same bytes, and content-derived so two
|
|
79
|
+
* different messages do not share one. The loop rules out the remaining case where the chosen boundary appears
|
|
80
|
+
* inside a part — astronomically unlikely with a hash, and cheaper to exclude than to reason about.
|
|
81
|
+
*/
|
|
82
|
+
export const boundaryFor = (parts) => {
|
|
83
|
+
const digest = createHash("sha256").update(parts.join(" "), "utf8").digest("hex");
|
|
84
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
85
|
+
const candidate = `=_retinue_${digest.slice(attempt * 4, attempt * 4 + 32)}`;
|
|
86
|
+
if (!parts.some((part) => part.includes(candidate)))
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
|
89
|
+
return `=_retinue_${digest}`;
|
|
90
|
+
};
|
|
91
|
+
const renderPart = (headers, content) => `${headers.join(CRLF)}${CRLF}${CRLF}${content}`;
|
|
92
|
+
const textPart = (text) => renderPart(['Content-Type: text/plain; charset="UTF-8"', "Content-Transfer-Encoding: base64"], foldedBase64(text));
|
|
93
|
+
const htmlPart = (html) => renderPart(['Content-Type: text/html; charset="UTF-8"', "Content-Transfer-Encoding: base64"], foldedBase64(html));
|
|
94
|
+
const attachmentPart = (attachment) => {
|
|
95
|
+
assertHeaderSafe("attachment filename", attachment.filename);
|
|
96
|
+
assertHeaderSafe("attachment content type", attachment.contentType);
|
|
97
|
+
return renderPart([
|
|
98
|
+
`Content-Type: ${attachment.contentType}; name="${encodeHeader(attachment.filename)}"`,
|
|
99
|
+
"Content-Transfer-Encoding: base64",
|
|
100
|
+
`Content-Disposition: attachment; filename="${encodeHeader(attachment.filename)}"`,
|
|
101
|
+
], attachment.contentBase64.replace(/\s+/g, "").match(/.{1,76}/g)?.join(CRLF) ?? "");
|
|
102
|
+
};
|
|
103
|
+
const multipart = (subtype, parts) => {
|
|
104
|
+
const boundary = boundaryFor(parts);
|
|
105
|
+
return {
|
|
106
|
+
contentType: `multipart/${subtype}; boundary="${boundary}"`,
|
|
107
|
+
body: [
|
|
108
|
+
// A preamble, for clients that show nothing when they cannot parse the structure. Never displayed by one
|
|
109
|
+
// that can.
|
|
110
|
+
"This is a message in MIME format.",
|
|
111
|
+
...parts.map((body) => `--${boundary}${CRLF}${body}`),
|
|
112
|
+
`--${boundary}--`,
|
|
113
|
+
"",
|
|
114
|
+
].join(CRLF),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/** Splits a rendered part into its header lines and its content. */
|
|
118
|
+
const splitPart = (rendered) => {
|
|
119
|
+
const separator = rendered.indexOf(`${CRLF}${CRLF}`);
|
|
120
|
+
if (separator === -1)
|
|
121
|
+
return [[], rendered];
|
|
122
|
+
return [rendered.slice(0, separator).split(CRLF), rendered.slice(separator + 4)];
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* An RFC 5322 message.
|
|
126
|
+
*
|
|
127
|
+
* CRLF line endings, not bare newlines: the spec says CRLF and some servers are strict. Getting it wrong
|
|
128
|
+
* produces a message that works with most providers and is rejected by one, which is the worst kind of bug to
|
|
129
|
+
* find in production.
|
|
130
|
+
*/
|
|
131
|
+
export const buildMessage = (message) => {
|
|
132
|
+
const text = message.text ?? message.body;
|
|
133
|
+
const hasText = text !== undefined && text !== "";
|
|
134
|
+
const hasHtml = message.html !== undefined && message.html !== "";
|
|
135
|
+
if (!hasText && !hasHtml) {
|
|
136
|
+
throw new Error("A message needs a text body, an HTML body, or both.");
|
|
137
|
+
}
|
|
138
|
+
// Every header value, before any of them is written.
|
|
139
|
+
for (const [field, value] of [
|
|
140
|
+
["subject", message.subject],
|
|
141
|
+
["from", message.from ?? ""],
|
|
142
|
+
["reply-to", message.replyTo ?? ""],
|
|
143
|
+
...message.to.map((address) => ["recipient", address]),
|
|
144
|
+
...(message.cc ?? []).map((address) => ["cc recipient", address]),
|
|
145
|
+
...(message.bcc ?? []).map((address) => ["bcc recipient", address]),
|
|
146
|
+
...Object.entries(message.headers ?? {}).map(([name, value]) => [`${name} header`, value]),
|
|
147
|
+
]) {
|
|
148
|
+
assertHeaderSafe(field, value);
|
|
149
|
+
}
|
|
150
|
+
const headers = [];
|
|
151
|
+
if (message.from !== undefined)
|
|
152
|
+
headers.push(`From: ${message.from}`);
|
|
153
|
+
headers.push(`To: ${message.to.join(", ")}`);
|
|
154
|
+
if (message.cc !== undefined && message.cc.length > 0)
|
|
155
|
+
headers.push(`Cc: ${message.cc.join(", ")}`);
|
|
156
|
+
/**
|
|
157
|
+
* `Bcc` **is** written into the message this function returns.
|
|
158
|
+
*
|
|
159
|
+
* Correct for an API that takes a composed message and reads the recipients out of it, and wrong for SMTP,
|
|
160
|
+
* where the envelope carries the recipients and a `Bcc` header would show every blind recipient to all of
|
|
161
|
+
* them. The SMTP transport strips it — see `stripBcc` — rather than this function guessing which kind of
|
|
162
|
+
* caller it has. A preview shows it, because somebody inspecting a rehearsal should see who is on it.
|
|
163
|
+
*/
|
|
164
|
+
if (message.bcc !== undefined && message.bcc.length > 0)
|
|
165
|
+
headers.push(`Bcc: ${message.bcc.join(", ")}`);
|
|
166
|
+
if (message.replyTo !== undefined)
|
|
167
|
+
headers.push(`Reply-To: ${message.replyTo}`);
|
|
168
|
+
headers.push(`Subject: ${encodeHeader(message.subject)}`);
|
|
169
|
+
/**
|
|
170
|
+
* Both threading headers, from the original's `Message-ID`.
|
|
171
|
+
*
|
|
172
|
+
* `In-Reply-To` is what most clients thread on and `References` is what the rest use, so sending one without
|
|
173
|
+
* the other threads correctly in some inboxes and starts a new conversation in others.
|
|
174
|
+
*/
|
|
175
|
+
if (message.inReplyTo !== undefined) {
|
|
176
|
+
headers.push(`In-Reply-To: ${message.inReplyTo}`);
|
|
177
|
+
const chain = message.references === undefined || message.references.trim() === ""
|
|
178
|
+
? message.inReplyTo
|
|
179
|
+
: `${message.references.trim()} ${message.inReplyTo}`;
|
|
180
|
+
headers.push(`References: ${chain}`);
|
|
181
|
+
}
|
|
182
|
+
for (const [name, value] of Object.entries(message.headers ?? {}))
|
|
183
|
+
headers.push(`${name}: ${encodeHeader(value)}`);
|
|
184
|
+
headers.push("MIME-Version: 1.0");
|
|
185
|
+
const bodyParts = [];
|
|
186
|
+
if (hasText)
|
|
187
|
+
bodyParts.push(textPart(text));
|
|
188
|
+
// Plain text first. The spec orders parts least-faithful first, and a client showing the last part it
|
|
189
|
+
// understands would otherwise display the fallback and never the HTML.
|
|
190
|
+
if (hasHtml)
|
|
191
|
+
bodyParts.push(htmlPart(message.html));
|
|
192
|
+
const attachments = message.attachments ?? [];
|
|
193
|
+
if (bodyParts.length === 1 && attachments.length === 0) {
|
|
194
|
+
// The single-body case, byte-identical to what this produced before multipart existed.
|
|
195
|
+
const [partHeaders, content] = splitPart(bodyParts[0]);
|
|
196
|
+
return `${[...headers, ...partHeaders].join(CRLF)}${CRLF}${CRLF}${content}`;
|
|
197
|
+
}
|
|
198
|
+
const alternative = bodyParts.length > 1 ? multipart("alternative", bodyParts) : undefined;
|
|
199
|
+
const bodySection = alternative === undefined
|
|
200
|
+
? bodyParts[0]
|
|
201
|
+
: renderPart([`Content-Type: ${alternative.contentType}`], alternative.body);
|
|
202
|
+
if (attachments.length === 0) {
|
|
203
|
+
const only = alternative;
|
|
204
|
+
return `${headers.join(CRLF)}${CRLF}Content-Type: ${only.contentType}${CRLF}${CRLF}${only.body}`;
|
|
205
|
+
}
|
|
206
|
+
const mixed = multipart("mixed", [bodySection, ...attachments.map(attachmentPart)]);
|
|
207
|
+
return `${headers.join(CRLF)}${CRLF}Content-Type: ${mixed.contentType}${CRLF}${CRLF}${mixed.body}`;
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Removes the `Bcc` header from a composed message.
|
|
211
|
+
*
|
|
212
|
+
* For SMTP the envelope carries every recipient and the header must not, or each blind recipient can read the
|
|
213
|
+
* whole blind list — the single most embarrassing mail bug there is. The header is kept in the composed form
|
|
214
|
+
* so a preview can show it, and stripped at the transport that would otherwise leak it.
|
|
215
|
+
*/
|
|
216
|
+
export const stripBcc = (raw) => {
|
|
217
|
+
const separator = raw.indexOf(`${CRLF}${CRLF}`);
|
|
218
|
+
if (separator === -1)
|
|
219
|
+
return raw;
|
|
220
|
+
const kept = raw
|
|
221
|
+
.slice(0, separator)
|
|
222
|
+
.split(CRLF)
|
|
223
|
+
.filter((line) => !/^bcc:/i.test(line));
|
|
224
|
+
return `${kept.join(CRLF)}${raw.slice(separator)}`;
|
|
225
|
+
};
|
|
226
|
+
/** The value of a header in a composed message, for tests and for reading a provider's echo. */
|
|
227
|
+
export const headerOf = (raw, name) => {
|
|
228
|
+
const separator = raw.indexOf(`${CRLF}${CRLF}`);
|
|
229
|
+
const head = separator === -1 ? raw : raw.slice(0, separator);
|
|
230
|
+
const match = new RegExp(`^${name}:\\s*(.*)$`, "im").exec(head);
|
|
231
|
+
return match?.[1]?.trim();
|
|
232
|
+
};
|
|
233
|
+
//# sourceMappingURL=mime.js.map
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF, closed at the point of connection — REQ-055 (#237), task #238, AC-3.
|
|
3
|
+
*
|
|
4
|
+
* This is the substance of the package. An agent that fetches URLs a model chose is a server-side request
|
|
5
|
+
* forgery primitive unless something stops it, and the naive implementation — check the hostname, then call
|
|
6
|
+
* `fetch` — is defeated three separate ways:
|
|
7
|
+
*
|
|
8
|
+
* 1. **A private or link-local literal.** `http://169.254.169.254/latest/meta-data/iam/security-credentials/`
|
|
9
|
+
* returns cloud credentials in plain text on EC2. `[::ffff:169.254.169.254]` is the same address wearing a
|
|
10
|
+
* different hat, and so is `0177.0.0.1`.
|
|
11
|
+
* 2. **A public name that resolves to a private address.** `metadata.attacker.example` with an `A` record of
|
|
12
|
+
* `169.254.169.254` passes every hostname check ever written, because the hostname is genuinely public.
|
|
13
|
+
* 3. **A redirect.** The check ran on the URL that was requested, and the response says to go somewhere else.
|
|
14
|
+
*
|
|
15
|
+
* Each needs its own defence, and each has its own test.
|
|
16
|
+
*
|
|
17
|
+
* ## Why this resolves DNS itself rather than trusting a check-then-fetch
|
|
18
|
+
*
|
|
19
|
+
* Vector 2's obvious fix — resolve the name, look at the addresses, then call `fetch` — leaves a window: the
|
|
20
|
+
* fetch resolves the name a *second* time, and a DNS record with a one-second TTL can answer differently. That
|
|
21
|
+
* is DNS rebinding, it is not theoretical, and a check that a determined caller can lose a race against is a
|
|
22
|
+
* check that reports safety it does not provide.
|
|
23
|
+
*
|
|
24
|
+
* So the resolution and the connection are the same event. `node:https` accepts a `lookup` function, called at
|
|
25
|
+
* connect time with the hostname; this module supplies one that returns **only** an address it has just
|
|
26
|
+
* validated. There is no second resolution to poison. TLS still verifies the certificate against the hostname
|
|
27
|
+
* from the URL, so pinning the address costs nothing in transport security.
|
|
28
|
+
*
|
|
29
|
+
* This is why the package uses `node:https` rather than the shared `createHttpClient`: `fetch` has no way to
|
|
30
|
+
* say "connect to this address". The trade is written down in `check:transport`'s exemption list.
|
|
31
|
+
*
|
|
32
|
+
* ## What is deliberately *not* claimed
|
|
33
|
+
*
|
|
34
|
+
* This closes access to private network space. It does not make fetching arbitrary URLs safe in general — the
|
|
35
|
+
* content that comes back is still untrusted, which is a separate problem handled by the untrusted-content
|
|
36
|
+
* fence rather than here.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* How this identifies itself, honestly.
|
|
40
|
+
*
|
|
41
|
+
* A real name and a URL that explains what it is, because the alternative — impersonating a browser — is the
|
|
42
|
+
* bot-detection evasion this package declines to do, and because a site that wants to refuse a crawler should
|
|
43
|
+
* be able to. Several sites answer a *missing* User-Agent with a 429 that is not a rate limit, so sending
|
|
44
|
+
* nothing is not an option either.
|
|
45
|
+
*/
|
|
46
|
+
export declare const DEFAULT_USER_AGENT = "RetinueBot/1.0 (+https://retinue.dev/integrations/scrape)";
|
|
47
|
+
/** Why an address or host was refused. `null` means it is acceptable. */
|
|
48
|
+
export type Refusal = string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Whether an IPv4 address is somewhere a fetch must not go.
|
|
51
|
+
*
|
|
52
|
+
* Broader than "the RFC 1918 ranges", because the ranges that matter are the ones that reach something. In
|
|
53
|
+
* particular `100.64/10` (carrier-grade NAT) is where several hosting providers put internal services, and
|
|
54
|
+
* `0.0.0.0/8` is a well-known way of writing "this host" that a naive `127.` check misses entirely.
|
|
55
|
+
*/
|
|
56
|
+
export declare const isPrivateV4: (host: string) => boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Whether an IPv6 address is somewhere a fetch must not go.
|
|
59
|
+
*
|
|
60
|
+
* The mapped and embedded forms are the interesting part: `::ffff:169.254.169.254` and `::169.254.169.254` are
|
|
61
|
+
* both the metadata address, and `2002:a9fe:a9fe::` is it again inside a 6to4 prefix. A v6 check that does not
|
|
62
|
+
* look inside those is a v4 check with extra steps.
|
|
63
|
+
*/
|
|
64
|
+
export declare const isPrivateV6: (host: string) => boolean;
|
|
65
|
+
export declare const isPrivateAddress: (address: string) => boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Refuses a URL on its face, before any resolution.
|
|
68
|
+
*
|
|
69
|
+
* Scheme and shape only. A hostname that merely *looks* fine still has to survive resolution, which is the
|
|
70
|
+
* next function — this one exists so an obviously bad URL costs no DNS query and gives a specific message.
|
|
71
|
+
*/
|
|
72
|
+
export declare const refuseUrl: (url: URL) => Refusal;
|
|
73
|
+
/** Resolves a hostname to addresses. Injectable so the DNS vector can be tested without a DNS server. */
|
|
74
|
+
export type Resolve = (hostname: string) => Promise<readonly string[]>;
|
|
75
|
+
export declare const systemResolve: Resolve;
|
|
76
|
+
export declare class BlockedError extends Error {
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolves a host and refuses it if **any** address is private — then returns the addresses to connect to.
|
|
80
|
+
*
|
|
81
|
+
* *Any*, not *all*. A name with two `A` records, one public and one link-local, is a rebinding attack with the
|
|
82
|
+
* work done up front: connecting to whichever the resolver happened to order first is a coin flip, and a check
|
|
83
|
+
* that passes on a coin flip has not checked anything.
|
|
84
|
+
*/
|
|
85
|
+
export declare const resolvePublicly: (hostname: string, resolve: Resolve) => Promise<readonly string[]>;
|
|
86
|
+
export type SafeResponse = {
|
|
87
|
+
readonly url: string;
|
|
88
|
+
readonly status: number;
|
|
89
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
90
|
+
readonly body: string;
|
|
91
|
+
/** True when `maxBytes` stopped the read before the body ended. */
|
|
92
|
+
readonly truncated: boolean;
|
|
93
|
+
/** Every URL in the chain, first to last. One entry when there was no redirect. */
|
|
94
|
+
readonly chain: readonly string[];
|
|
95
|
+
};
|
|
96
|
+
export type SafeFetchOptions = {
|
|
97
|
+
readonly maxBytes?: number;
|
|
98
|
+
readonly timeoutMs?: number;
|
|
99
|
+
readonly maxRedirects?: number;
|
|
100
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
101
|
+
readonly resolve?: Resolve;
|
|
102
|
+
/** Injected by tests, so "no request left the process" can be asserted rather than hoped for. */
|
|
103
|
+
readonly transport?: SafeTransport;
|
|
104
|
+
};
|
|
105
|
+
/** One request to one already-validated address. Injectable for tests. */
|
|
106
|
+
export type SafeTransport = (input: {
|
|
107
|
+
readonly url: URL;
|
|
108
|
+
readonly addresses: readonly string[];
|
|
109
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
110
|
+
readonly timeoutMs: number;
|
|
111
|
+
readonly maxBytes: number;
|
|
112
|
+
}) => Promise<{
|
|
113
|
+
status: number;
|
|
114
|
+
headers: Record<string, string>;
|
|
115
|
+
body: string;
|
|
116
|
+
truncated: boolean;
|
|
117
|
+
}>;
|
|
118
|
+
/**
|
|
119
|
+
* The real transport: connects to a validated address with `lookup` pinned.
|
|
120
|
+
*
|
|
121
|
+
* The `lookup` callback is what makes this sound. Node calls it at connect time instead of resolving the name,
|
|
122
|
+
* so the address that was checked is the address that is used — there is no second resolution between the
|
|
123
|
+
* check and the socket. The hostname still goes out in SNI and in the `Host` header, so the certificate is
|
|
124
|
+
* verified against the name the caller asked for, not against the IP.
|
|
125
|
+
*/
|
|
126
|
+
export declare const nodeTransport: SafeTransport;
|
|
127
|
+
/**
|
|
128
|
+
* Fetches a URL with every vector closed, following redirects **by re-running the whole check on each hop**.
|
|
129
|
+
*
|
|
130
|
+
* The shared `createHttpClient` refuses redirects outright, which is the right answer for a vendor API and the
|
|
131
|
+
* wrong one here: a great many real pages are one redirect away from their canonical URL, and a scraper that
|
|
132
|
+
* cannot follow `http → https` or `example.com → www.example.com` is a scraper that fails on ordinary input.
|
|
133
|
+
*
|
|
134
|
+
* So redirects are followed, and the price is that each hop is a fresh, untrusted URL. It gets `refuseUrl` and
|
|
135
|
+
* `resolvePublicly` exactly as the first one did. A chain that starts public and ends at `169.254.169.254` is
|
|
136
|
+
* refused at the hop that turns private, which is the only place it can be caught.
|
|
137
|
+
*/
|
|
138
|
+
export declare const safeFetch: (target: string, options?: SafeFetchOptions) => Promise<SafeResponse>;
|
|
139
|
+
//# sourceMappingURL=ssrf.d.ts.map
|