@mininglamp-oss/cc-channel-octo 1.0.1-dev.0ac574a

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 (93) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/LICENSE +191 -0
  3. package/README.md +577 -0
  4. package/config.bot.example.json +15 -0
  5. package/config.example.json +33 -0
  6. package/dist/agent-bridge.d.ts +91 -0
  7. package/dist/agent-bridge.js +397 -0
  8. package/dist/agent-bridge.js.map +1 -0
  9. package/dist/cli.d.ts +109 -0
  10. package/dist/cli.js +467 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/commands.d.ts +57 -0
  13. package/dist/commands.js +121 -0
  14. package/dist/commands.js.map +1 -0
  15. package/dist/config.d.ts +294 -0
  16. package/dist/config.js +344 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/configure.d.ts +11 -0
  19. package/dist/configure.js +106 -0
  20. package/dist/configure.js.map +1 -0
  21. package/dist/cron-evaluator.d.ts +53 -0
  22. package/dist/cron-evaluator.js +191 -0
  23. package/dist/cron-evaluator.js.map +1 -0
  24. package/dist/cron-fire-marker.d.ts +24 -0
  25. package/dist/cron-fire-marker.js +25 -0
  26. package/dist/cron-fire-marker.js.map +1 -0
  27. package/dist/cron-scheduler.d.ts +46 -0
  28. package/dist/cron-scheduler.js +114 -0
  29. package/dist/cron-scheduler.js.map +1 -0
  30. package/dist/cron-store.d.ts +62 -0
  31. package/dist/cron-store.js +63 -0
  32. package/dist/cron-store.js.map +1 -0
  33. package/dist/cron-tool.d.ts +44 -0
  34. package/dist/cron-tool.js +151 -0
  35. package/dist/cron-tool.js.map +1 -0
  36. package/dist/cwd-resolver.d.ts +72 -0
  37. package/dist/cwd-resolver.js +166 -0
  38. package/dist/cwd-resolver.js.map +1 -0
  39. package/dist/db-adapter.d.ts +21 -0
  40. package/dist/db-adapter.js +64 -0
  41. package/dist/db-adapter.js.map +1 -0
  42. package/dist/file-inline-wrap.d.ts +94 -0
  43. package/dist/file-inline-wrap.js +243 -0
  44. package/dist/file-inline-wrap.js.map +1 -0
  45. package/dist/gateway.d.ts +105 -0
  46. package/dist/gateway.js +425 -0
  47. package/dist/gateway.js.map +1 -0
  48. package/dist/group-config.d.ts +41 -0
  49. package/dist/group-config.js +104 -0
  50. package/dist/group-config.js.map +1 -0
  51. package/dist/group-context.d.ts +81 -0
  52. package/dist/group-context.js +466 -0
  53. package/dist/group-context.js.map +1 -0
  54. package/dist/inbound.d.ts +136 -0
  55. package/dist/inbound.js +667 -0
  56. package/dist/inbound.js.map +1 -0
  57. package/dist/index.d.ts +65 -0
  58. package/dist/index.js +1026 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/media-inbound.d.ts +38 -0
  61. package/dist/media-inbound.js +131 -0
  62. package/dist/media-inbound.js.map +1 -0
  63. package/dist/mention-utils.d.ts +108 -0
  64. package/dist/mention-utils.js +199 -0
  65. package/dist/mention-utils.js.map +1 -0
  66. package/dist/octo/api.d.ts +148 -0
  67. package/dist/octo/api.js +320 -0
  68. package/dist/octo/api.js.map +1 -0
  69. package/dist/octo/socket.d.ts +102 -0
  70. package/dist/octo/socket.js +793 -0
  71. package/dist/octo/socket.js.map +1 -0
  72. package/dist/octo/types.d.ts +126 -0
  73. package/dist/octo/types.js +35 -0
  74. package/dist/octo/types.js.map +1 -0
  75. package/dist/prompt-safety.d.ts +78 -0
  76. package/dist/prompt-safety.js +148 -0
  77. package/dist/prompt-safety.js.map +1 -0
  78. package/dist/session-router.d.ts +144 -0
  79. package/dist/session-router.js +490 -0
  80. package/dist/session-router.js.map +1 -0
  81. package/dist/session-store.d.ts +89 -0
  82. package/dist/session-store.js +297 -0
  83. package/dist/session-store.js.map +1 -0
  84. package/dist/skill-linker.d.ts +31 -0
  85. package/dist/skill-linker.js +160 -0
  86. package/dist/skill-linker.js.map +1 -0
  87. package/dist/stream-relay.d.ts +42 -0
  88. package/dist/stream-relay.js +243 -0
  89. package/dist/stream-relay.js.map +1 -0
  90. package/dist/url-policy.d.ts +103 -0
  91. package/dist/url-policy.js +290 -0
  92. package/dist/url-policy.js.map +1 -0
  93. package/package.json +79 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Stream Relay — typing heartbeat + message splitting + plain sendMessage delivery.
3
+ *
4
+ * Consumes an AsyncIterable<string> of text chunks and delivers them to Octo
5
+ * via plain sendMessage with intelligent splitting.
6
+ *
7
+ * Design constraints:
8
+ * - Knows nothing about Claude SDK — input is a generic async text stream.
9
+ * - All Octo API calls go through the api.ts functions (no raw fetch).
10
+ * - Typing indicators keep the user informed while chunks accumulate.
11
+ */
12
+ import { sendTyping, sendMessage, } from "./octo/api.js";
13
+ import { resolveMentions } from "./mention-utils.js";
14
+ // ─── Constants ──────────────────────────────────────────────────────────────
15
+ /** Interval (ms) between typing indicator pings. */
16
+ const TYPING_INTERVAL_MS = 5_000;
17
+ /** Maximum characters per message segment. */
18
+ const MAX_SEGMENT_CHARS = 3_500;
19
+ /** Default maximum accumulated response length before truncation (Q32). */
20
+ const DEFAULT_MAX_RESPONSE_CHARS = 524_288; // 512 KB
21
+ /** Truncation suffix appended when response exceeds limit. */
22
+ const TRUNCATION_SUFFIX = '\n\n[response truncated]';
23
+ /**
24
+ * Adjust a candidate split position so it does not fall strictly inside any
25
+ * protected range. If splitAt lands inside [start, end), prefer pushing back
26
+ * to `start` (move the protected unit whole to the next segment).
27
+ *
28
+ * Returns the adjusted split position, or null if pulling back would land at 0.
29
+ */
30
+ function adjustSplitForProtectedRanges(splitAt, protectedRanges) {
31
+ for (const range of protectedRanges) {
32
+ if (splitAt > range.start && splitAt < range.end) {
33
+ // Pull BACK to range.start so the protected unit moves whole to next seg.
34
+ if (range.start > 0)
35
+ return range.start;
36
+ // Range starts at 0 — can't pull back. Caller will deal.
37
+ return null;
38
+ }
39
+ }
40
+ return splitAt;
41
+ }
42
+ /**
43
+ * Split a long text into segments at natural boundaries.
44
+ *
45
+ * Priority: paragraph break (\n\n) > newline (\n) > space > hard cut.
46
+ * Each segment is at most `maxChars` characters.
47
+ *
48
+ * `protectedRanges` (P0-1): byte ranges that must NOT be split through. Used
49
+ * by deliver() to keep resolved @name mentions intact — a name like
50
+ * "@John Smith Junior" must be sent as one unit so the corresponding
51
+ * MentionEntity offset/length lands cleanly in one segment.
52
+ */
53
+ export function splitMessage(text, maxChars = MAX_SEGMENT_CHARS, protectedRanges = []) {
54
+ if (maxChars < 1) {
55
+ throw new Error(`splitMessage: maxChars must be >= 1, got ${maxChars}`);
56
+ }
57
+ if (text.length <= maxChars)
58
+ return [text];
59
+ const segments = [];
60
+ let remaining = text;
61
+ let consumed = 0;
62
+ while (remaining.length > 0) {
63
+ if (remaining.length <= maxChars) {
64
+ segments.push(remaining);
65
+ break;
66
+ }
67
+ const chunk = remaining.slice(0, maxChars);
68
+ // Translate global ranges to local offsets relative to remaining.
69
+ const localRanges = protectedRanges
70
+ .filter(r => r.end > consumed && r.start < consumed + remaining.length)
71
+ .map(r => ({ start: r.start - consumed, end: r.end - consumed }));
72
+ let splitAt = -1;
73
+ function tryCandidate(candidate) {
74
+ const adj = adjustSplitForProtectedRanges(candidate, localRanges);
75
+ if (adj === null || adj <= 0 || adj > maxChars)
76
+ return false;
77
+ splitAt = adj;
78
+ return true;
79
+ }
80
+ // 1. Paragraph break (\n\n) — prefer the last one within range.
81
+ const paraIdx = chunk.lastIndexOf("\n\n");
82
+ if (paraIdx > 0)
83
+ tryCandidate(paraIdx + 2);
84
+ // 2. Newline (\n)
85
+ if (splitAt === -1) {
86
+ const nlIdx = chunk.lastIndexOf("\n");
87
+ if (nlIdx > 0)
88
+ tryCandidate(nlIdx + 1);
89
+ }
90
+ // 3. Space
91
+ if (splitAt === -1) {
92
+ const spIdx = chunk.lastIndexOf(" ");
93
+ if (spIdx > 0)
94
+ tryCandidate(spIdx + 1);
95
+ }
96
+ // 4. Hard cut — avoid splitting surrogate pairs AND protected ranges.
97
+ if (splitAt === -1) {
98
+ splitAt = maxChars;
99
+ // If the cut falls between a surrogate pair, back up one code unit.
100
+ const code = remaining.charCodeAt(splitAt - 1);
101
+ if (code >= 0xD800 && code <= 0xDBFF)
102
+ splitAt--;
103
+ // If the cut falls inside a protected range, pull back to its start.
104
+ // Pathological case (range starts at 0 and exceeds maxChars): we fall
105
+ // back to maxChars and accept the broken mention rather than infinite
106
+ // loop. In practice an @[uid:name] is far shorter than maxChars=3500.
107
+ const adj = adjustSplitForProtectedRanges(splitAt, localRanges);
108
+ if (adj !== null && adj > 0)
109
+ splitAt = Math.min(adj, maxChars);
110
+ }
111
+ segments.push(remaining.slice(0, splitAt));
112
+ remaining = remaining.slice(splitAt);
113
+ consumed += splitAt;
114
+ }
115
+ return segments;
116
+ }
117
+ // ─── StreamRelay ────────────────────────────────────────────────────────────
118
+ export class StreamRelay {
119
+ /**
120
+ * Deliver an async stream of text chunks to an Octo channel.
121
+ *
122
+ * 1. Starts a typing indicator heartbeat (5 s interval).
123
+ * 2. Accumulates all chunks from the async iterable.
124
+ * 3. Sends the accumulated text via plain sendMessage with splitting.
125
+ * 4. Always cleans up the typing heartbeat.
126
+ */
127
+ async deliver(channelId, channelType, chunks, apiUrl, botToken, maxResponseChars = DEFAULT_MAX_RESPONSE_CHARS, memberMap, isValidUid) {
128
+ // --- Typing heartbeat ---
129
+ const typingParams = { apiUrl, botToken, channelId, channelType };
130
+ // Fire one immediately — don't wait for the first interval tick.
131
+ sendTyping(typingParams).catch(() => { });
132
+ const typingTimer = setInterval(() => {
133
+ sendTyping(typingParams).catch(() => { });
134
+ }, TYPING_INTERVAL_MS);
135
+ try {
136
+ // Accumulate all chunks, with truncation guard (Q32).
137
+ let accumulated = "";
138
+ let truncated = false;
139
+ // If the agent stream throws mid-way (network blip, non-recoverable SDK
140
+ // error after partial output), we still want to deliver what we have
141
+ // instead of dropping a real partial reply on the floor — then re-throw so
142
+ // the caller's error path still runs. Capture the error and flush below.
143
+ let streamError;
144
+ try {
145
+ for await (const chunk of chunks) {
146
+ accumulated += chunk;
147
+ // Q32: Stop accumulating once limit is reached to prevent unbounded memory.
148
+ if (accumulated.length > maxResponseChars) {
149
+ // P1-6: cut at code-unit boundary, but back off if it lands inside
150
+ // a surrogate pair (would produce an orphan high surrogate = invalid
151
+ // Unicode). Mirrors splitMessage's hard-cut surrogate guard.
152
+ let cutAt = maxResponseChars;
153
+ const code = accumulated.charCodeAt(cutAt - 1);
154
+ if (code >= 0xD800 && code <= 0xDBFF)
155
+ cutAt--;
156
+ accumulated = accumulated.slice(0, cutAt) + TRUNCATION_SUFFIX;
157
+ truncated = true;
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ catch (err) {
163
+ streamError = err;
164
+ console.error(`[stream-relay] agent stream threw after ${accumulated.length} char(s); flushing partial output: ${String(err)}`);
165
+ }
166
+ if (truncated) {
167
+ console.warn(`[stream-relay] Response truncated at ${maxResponseChars} chars`);
168
+ }
169
+ // Send accumulated text via plain messages with splitting.
170
+ if (accumulated.length > 0) {
171
+ // P0-1: Resolve @[uid:name] structured mentions and @name ONCE on the
172
+ // full accumulated text, BEFORE splitting. This guarantees splitMessage
173
+ // cannot break a structured mention across segments — by the time
174
+ // splitMessage runs, @[uid:name] is already @name. We additionally
175
+ // pass each resolved @name as a ProtectedRange so splitMessage avoids
176
+ // cutting through names that contain spaces (e.g. "John Smith").
177
+ const { finalContent, mentionEntities: globalEntities, mentionAll, } = resolveMentions(accumulated, memberMap, isValidUid);
178
+ const protectedRanges = globalEntities.map(e => ({
179
+ start: e.offset,
180
+ end: e.offset + e.length,
181
+ }));
182
+ const segments = splitMessage(finalContent, undefined, protectedRanges);
183
+ let segStart = 0;
184
+ let mentionAllConsumed = false;
185
+ for (const segment of segments) {
186
+ const segEnd = segStart + segment.length;
187
+ // Partition entities falling within this segment, re-rebase to
188
+ // segment-local offsets. Server expects per-message offsets.
189
+ const segEntities = globalEntities
190
+ .filter(e => e.offset >= segStart && e.offset + e.length <= segEnd)
191
+ .map(e => ({ uid: e.uid, offset: e.offset - segStart, length: e.length }));
192
+ const segUids = segEntities.map(e => e.uid);
193
+ // mentionAll only applies to one segment (the first). Avoids spamming
194
+ // @所有人 notifications when a reply spans multiple chunks.
195
+ //
196
+ // Design choice (王大锤 PR#45 review note): we attach mentionAll to the
197
+ // FIRST segment unconditionally, not to the segment that actually
198
+ // contains the resolved "@all"/"@所有人" text. Three reasons:
199
+ // 1. mentionAll on the Octo API is a wire-level notification flag,
200
+ // independent of where the literal "@all" text appears.
201
+ // 2. The first segment is always sent first — attaching the flag
202
+ // there minimizes notification latency.
203
+ // 3. The literal text may have been resolved/rewritten by the LLM
204
+ // into multiple positions; pinning to segment 0 is unambiguous.
205
+ // Alternative considered: scan each segment for @all literal and
206
+ // attach the flag only to segments containing it. Rejected because
207
+ // it would double-notify when @all appears more than once.
208
+ const useMentionAll = mentionAll && !mentionAllConsumed;
209
+ if (useMentionAll)
210
+ mentionAllConsumed = true;
211
+ try {
212
+ await sendMessage({
213
+ apiUrl,
214
+ botToken,
215
+ channelId,
216
+ channelType,
217
+ content: segment,
218
+ ...(segUids.length > 0 ? { mentionUids: segUids } : {}),
219
+ ...(segEntities.length > 0 ? { mentionEntities: segEntities } : {}),
220
+ ...(useMentionAll ? { mentionAll: true } : {}),
221
+ });
222
+ }
223
+ catch (err) {
224
+ console.error(`[stream-relay] sendMessage failed for segment (${segment.length} chars), continuing: ${String(err)}`);
225
+ // Continue sending remaining segments — don't let one failure drop the rest
226
+ }
227
+ segStart = segEnd;
228
+ }
229
+ }
230
+ // If accumulated is empty, the agent produced no output — nothing to send.
231
+ // If the stream threw mid-way, we've now flushed whatever partial text was
232
+ // accumulated; re-throw so the caller's error handling still runs (it will
233
+ // not double-send — the partial was delivered here, the caller only logs /
234
+ // surfaces the failure).
235
+ if (streamError !== undefined)
236
+ throw streamError;
237
+ }
238
+ finally {
239
+ clearInterval(typingTimer);
240
+ }
241
+ }
242
+ }
243
+ //# sourceMappingURL=stream-relay.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream-relay.js","sourceRoot":"","sources":["../src/stream-relay.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * URL policy — shared SSRF defense + URL validation utilities.
3
+ *
4
+ * Stage 6 W1: consolidates URL validation that was previously duplicated and
5
+ * inconsistent across inbound.ts and config.ts.
6
+ *
7
+ * Three public surfaces:
8
+ * - isPrivateOrLocalAddress(address): IP-range check (IPv4 + IPv6 + v4-mapped)
9
+ * - assertPublicUrl(url): throws if a URL resolves to a private/local address
10
+ * - fetchWithRedirectGuard(url, init): fetch() wrapper that re-validates on every redirect
11
+ * - isAllowedApiUrl(url): boot-time apiUrl validation (https any non-private host, http localhost only)
12
+ */
13
+ /**
14
+ * Reject IP literals/resolved IPs in private/loopback/link-local/CGN ranges.
15
+ *
16
+ * Covers IPv4 + IPv6 forms including:
17
+ * - IPv6 dotted-quad v4-mapped: `::ffff:127.0.0.1`
18
+ * - IPv6 hex v4-mapped: `::ffff:7f00:1` (S5 — was a bypass of original PR#34 fix)
19
+ * - IPv4-compatible IPv6: `::7f00:1` (deprecated but resolvable)
20
+ *
21
+ * NOTE: DNS rebinding remains a residual risk. We validate at lookup time;
22
+ * the OS resolver may return a different IP at fetch time. Mitigated by short
23
+ * TTLs and the fact that an attacker would need to control authoritative DNS
24
+ * for a domain we trust. For full protection, callers can pin IP and connect
25
+ * by IP — not done here to preserve TLS SNI and CDN routing.
26
+ */
27
+ export declare function isPrivateOrLocalAddress(address: string): boolean;
28
+ /**
29
+ * Validate a URL's host is publicly routable. Throws on:
30
+ * - non-http(s) scheme
31
+ * - IP literal in private/loopback/link-local range
32
+ * - hostname resolving to ANY private/local address
33
+ */
34
+ export declare function assertPublicUrl(rawUrl: string): Promise<void>;
35
+ /**
36
+ * Per-hop init callback for fetchWithRedirectGuard.
37
+ *
38
+ * Called for each hop with the URL about to be fetched (initial URL on hop
39
+ * 0, redirect Location on later hops). Returns the RequestInit to use for
40
+ * that hop. Allows callers to scope credentials per-hop — e.g. drop
41
+ * Authorization when the target host changes.
42
+ *
43
+ * Receives `previousUrl` so the callback can compare host transitions
44
+ * (initial → hop 1) without re-parsing.
45
+ */
46
+ export type PerHopInit = (currentUrl: string, previousUrl: string | null) => RequestInit & {
47
+ signal?: AbortSignal;
48
+ };
49
+ /**
50
+ * Fetch wrapper that re-validates SSRF on every HTTP redirect (S2).
51
+ *
52
+ * Default `fetch()` follows redirects automatically — an attacker can register
53
+ * `https://attacker.com` that 302's to `http://127.0.0.1/admin`, completely
54
+ * bypassing `assertPublicUrl(originalUrl)`. This wrapper sets
55
+ * `redirect: 'manual'` and manually walks the chain, validating each hop.
56
+ *
57
+ * Callers should still call `assertPublicUrl(url)` once upfront; this wrapper
58
+ * handles ONLY the redirect chain. The reason for the split: assertPublicUrl
59
+ * fails fast before any network I/O for the obvious cases.
60
+ *
61
+ * **Credential safety (S1 follow-up fix, addressed in PR#38 re-review):**
62
+ *
63
+ * The `init` parameter is REUSED on every hop, which means any headers in it
64
+ * (e.g. Authorization) follow the redirect chain. This is the SAME bug as
65
+ * default `fetch()` does — a same-host request that 302s to attacker.com
66
+ * sends the Authorization header to the attacker.
67
+ *
68
+ * Two modes:
69
+ * 1. Static `init` — backward-compatible, headers flow on every hop.
70
+ * Caller is responsible for either (a) only using this for URLs they
71
+ * control end-to-end, or (b) not including sensitive headers.
72
+ * 2. `perHopInit` callback — caller decides per-hop what init to use,
73
+ * typically scoping Authorization to apiUrl host only. Recommended
74
+ * whenever Authorization is involved.
75
+ *
76
+ * If both are provided, `perHopInit` wins. If neither is provided, an
77
+ * empty init is used.
78
+ */
79
+ export declare function fetchWithRedirectGuard(url: string, init?: (RequestInit & {
80
+ signal?: AbortSignal;
81
+ }) | PerHopInit): Promise<Response>;
82
+ /**
83
+ * Boot-time apiUrl SSRF check (S6).
84
+ *
85
+ * Stricter than `assertPublicUrl`: requires either `https:` to a NON-private
86
+ * host, or `http:` to an explicit localhost form (for local development only).
87
+ *
88
+ * S6 fix: previously `https://` was allowed to ANY host including 127.0.0.1
89
+ * because we only checked protocol. Now we also reject private IPs for https.
90
+ */
91
+ export declare function isAllowedApiUrl(url: string): boolean;
92
+ /**
93
+ * Boot-time WebSocket URL validation — the transport-integrity counterpart to
94
+ * isAllowedApiUrl.
95
+ *
96
+ * The WuKongIM payload layer is AES-CBC WITHOUT an authentication tag, so message
97
+ * integrity rests entirely on the transport. An unencrypted `ws://` link lets a
98
+ * network attacker bit-flip ciphertext undetected (silent corruption / DoS).
99
+ * Require `wss://` for any non-loopback host; permit plaintext `ws://` only to an
100
+ * explicit localhost form (local dev / a co-located reverse proxy that terminates
101
+ * TLS). Mirrors the http→localhost-only carve-out in isAllowedApiUrl.
102
+ */
103
+ export declare function isAllowedWsUrl(url: string): boolean;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * URL policy — shared SSRF defense + URL validation utilities.
3
+ *
4
+ * Stage 6 W1: consolidates URL validation that was previously duplicated and
5
+ * inconsistent across inbound.ts and config.ts.
6
+ *
7
+ * Three public surfaces:
8
+ * - isPrivateOrLocalAddress(address): IP-range check (IPv4 + IPv6 + v4-mapped)
9
+ * - assertPublicUrl(url): throws if a URL resolves to a private/local address
10
+ * - fetchWithRedirectGuard(url, init): fetch() wrapper that re-validates on every redirect
11
+ * - isAllowedApiUrl(url): boot-time apiUrl validation (https any non-private host, http localhost only)
12
+ */
13
+ import { lookup as dnsLookup } from "node:dns/promises";
14
+ import { isIP } from "node:net";
15
+ /**
16
+ * Reject IP literals/resolved IPs in private/loopback/link-local/CGN ranges.
17
+ *
18
+ * Covers IPv4 + IPv6 forms including:
19
+ * - IPv6 dotted-quad v4-mapped: `::ffff:127.0.0.1`
20
+ * - IPv6 hex v4-mapped: `::ffff:7f00:1` (S5 — was a bypass of original PR#34 fix)
21
+ * - IPv4-compatible IPv6: `::7f00:1` (deprecated but resolvable)
22
+ *
23
+ * NOTE: DNS rebinding remains a residual risk. We validate at lookup time;
24
+ * the OS resolver may return a different IP at fetch time. Mitigated by short
25
+ * TTLs and the fact that an attacker would need to control authoritative DNS
26
+ * for a domain we trust. For full protection, callers can pin IP and connect
27
+ * by IP — not done here to preserve TLS SNI and CDN routing.
28
+ */
29
+ export function isPrivateOrLocalAddress(address) {
30
+ const fam = isIP(address);
31
+ if (fam === 4) {
32
+ return isPrivateIPv4(address);
33
+ }
34
+ if (fam === 6) {
35
+ const lower = address.toLowerCase();
36
+ // ::1 loopback
37
+ if (lower === "::1" || lower === "0:0:0:0:0:0:0:1")
38
+ return true;
39
+ // :: unspecified
40
+ if (lower === "::" || lower === "0:0:0:0:0:0:0:0")
41
+ return true;
42
+ // fc00::/7 unique local addresses (fc.. and fd..)
43
+ if (lower.startsWith("fc") || lower.startsWith("fd"))
44
+ return true;
45
+ // fe80::/10 link-local (fe80..febf)
46
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") ||
47
+ lower.startsWith("fea") || lower.startsWith("feb"))
48
+ return true;
49
+ // S5 fix: ::ffff:<v4-mapped> — both dotted-quad AND hex forms.
50
+ // URL.hostname normalizes `::ffff:127.0.0.1` → `::ffff:7f00:1` so a regex
51
+ // matching only the dotted-quad form (original PR#34 implementation) was
52
+ // bypassable. Cover both representations.
53
+ const v4MappedDot = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
54
+ if (v4MappedDot)
55
+ return isPrivateIPv4(v4MappedDot[1]);
56
+ const v4MappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
57
+ if (v4MappedHex) {
58
+ const dotted = decodeEmbeddedV4(v4MappedHex[1], v4MappedHex[2]);
59
+ if (dotted)
60
+ return isPrivateIPv4(dotted);
61
+ }
62
+ // IPv4-compatible IPv6: `::a.b.c.d` (deprecated form, but resolvable).
63
+ const v4CompatDot = lower.match(/^::(\d+\.\d+\.\d+\.\d+)$/);
64
+ if (v4CompatDot)
65
+ return isPrivateIPv4(v4CompatDot[1]);
66
+ // IPv4-compatible IPv6 hex form: `::a:b` where a:b decodes to dotted-quad.
67
+ const v4CompatHex = lower.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
68
+ if (v4CompatHex) {
69
+ const dotted = decodeEmbeddedV4(v4CompatHex[1], v4CompatHex[2]);
70
+ if (dotted)
71
+ return isPrivateIPv4(dotted);
72
+ }
73
+ // NAT64 well-known prefix 64:ff9b::/96 — the last 32 bits embed an IPv4
74
+ // address. A name resolving to e.g. `64:ff9b::7f00:1` (= 127.0.0.1) on a
75
+ // NAT64-enabled host would otherwise reach loopback. The embedded v4 is the
76
+ // final two hextets (dotted-quad form `64:ff9b::a.b.c.d` is also accepted).
77
+ if (lower.startsWith("64:ff9b:")) {
78
+ const dotTail = lower.match(/(\d+\.\d+\.\d+\.\d+)$/);
79
+ if (dotTail)
80
+ return isPrivateIPv4(dotTail[1]);
81
+ const hexTail = lower.match(/:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
82
+ if (hexTail) {
83
+ const embedded = decodeEmbeddedV4(hexTail[1], hexTail[2]);
84
+ if (embedded)
85
+ return isPrivateIPv4(embedded);
86
+ }
87
+ }
88
+ // 6to4 2002::/16 — bits 16..47 embed an IPv4 address (2002:V4hi:V4lo::/48).
89
+ const sixToFour = lower.match(/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/);
90
+ if (sixToFour) {
91
+ const embedded = decodeEmbeddedV4(sixToFour[1], sixToFour[2]);
92
+ if (embedded && isPrivateIPv4(embedded))
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+ // Not a valid IP literal — caller should resolve via DNS first.
98
+ return false;
99
+ }
100
+ /** Decode two IPv6 hextets (hex strings) into a dotted-quad IPv4 string. */
101
+ function decodeEmbeddedV4(hiHex, loHex) {
102
+ const high = parseInt(hiHex, 16);
103
+ const low = parseInt(loHex, 16);
104
+ if (Number.isNaN(high) || Number.isNaN(low))
105
+ return null;
106
+ return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
107
+ }
108
+ function isPrivateIPv4(address) {
109
+ const parts = address.split(".").map(Number);
110
+ if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) {
111
+ return false;
112
+ }
113
+ const [a, b] = parts;
114
+ // 127.0.0.0/8 loopback
115
+ if (a === 127)
116
+ return true;
117
+ // 10.0.0.0/8 private
118
+ if (a === 10)
119
+ return true;
120
+ // 172.16.0.0/12 private
121
+ if (a === 172 && b >= 16 && b <= 31)
122
+ return true;
123
+ // 192.168.0.0/16 private
124
+ if (a === 192 && b === 168)
125
+ return true;
126
+ // 169.254.0.0/16 link-local (includes AWS/GCP metadata 169.254.169.254)
127
+ if (a === 169 && b === 254)
128
+ return true;
129
+ // 100.64.0.0/10 CGN (carrier-grade NAT, shared address space)
130
+ if (a === 100 && b >= 64 && b <= 127)
131
+ return true;
132
+ // 0.0.0.0/8 unspecified / current network
133
+ if (a === 0)
134
+ return true;
135
+ return false;
136
+ }
137
+ /**
138
+ * Validate a URL's host is publicly routable. Throws on:
139
+ * - non-http(s) scheme
140
+ * - IP literal in private/loopback/link-local range
141
+ * - hostname resolving to ANY private/local address
142
+ */
143
+ export async function assertPublicUrl(rawUrl) {
144
+ const u = new URL(rawUrl);
145
+ // Reject non-http(s) schemes — gopher://, file://, dict:// etc.
146
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
147
+ throw new Error(`Refusing to fetch non-http(s) URL: ${u.protocol}`);
148
+ }
149
+ const host = u.hostname;
150
+ // IPv6 hostnames come bracketed in URL.hostname — strip for isIP/dns.
151
+ const bareHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
152
+ // Reject IP literals in private/loopback/link-local ranges immediately.
153
+ if (isIP(bareHost)) {
154
+ if (isPrivateOrLocalAddress(bareHost)) {
155
+ throw new Error(`Refusing to fetch private/local address: ${bareHost}`);
156
+ }
157
+ return;
158
+ }
159
+ // Resolve hostname — reject if ANY resolved address is private/local.
160
+ const addresses = await dnsLookup(bareHost, { all: true });
161
+ if (addresses.length === 0) {
162
+ throw new Error(`DNS resolution returned no addresses for: ${bareHost}`);
163
+ }
164
+ for (const { address } of addresses) {
165
+ if (isPrivateOrLocalAddress(address)) {
166
+ throw new Error(`Refusing to fetch ${bareHost}: resolves to private/local address ${address}`);
167
+ }
168
+ }
169
+ }
170
+ /** Maximum HTTP redirects to follow before giving up. */
171
+ const MAX_REDIRECTS = 10;
172
+ /**
173
+ * Fetch wrapper that re-validates SSRF on every HTTP redirect (S2).
174
+ *
175
+ * Default `fetch()` follows redirects automatically — an attacker can register
176
+ * `https://attacker.com` that 302's to `http://127.0.0.1/admin`, completely
177
+ * bypassing `assertPublicUrl(originalUrl)`. This wrapper sets
178
+ * `redirect: 'manual'` and manually walks the chain, validating each hop.
179
+ *
180
+ * Callers should still call `assertPublicUrl(url)` once upfront; this wrapper
181
+ * handles ONLY the redirect chain. The reason for the split: assertPublicUrl
182
+ * fails fast before any network I/O for the obvious cases.
183
+ *
184
+ * **Credential safety (S1 follow-up fix, addressed in PR#38 re-review):**
185
+ *
186
+ * The `init` parameter is REUSED on every hop, which means any headers in it
187
+ * (e.g. Authorization) follow the redirect chain. This is the SAME bug as
188
+ * default `fetch()` does — a same-host request that 302s to attacker.com
189
+ * sends the Authorization header to the attacker.
190
+ *
191
+ * Two modes:
192
+ * 1. Static `init` — backward-compatible, headers flow on every hop.
193
+ * Caller is responsible for either (a) only using this for URLs they
194
+ * control end-to-end, or (b) not including sensitive headers.
195
+ * 2. `perHopInit` callback — caller decides per-hop what init to use,
196
+ * typically scoping Authorization to apiUrl host only. Recommended
197
+ * whenever Authorization is involved.
198
+ *
199
+ * If both are provided, `perHopInit` wins. If neither is provided, an
200
+ * empty init is used.
201
+ */
202
+ export async function fetchWithRedirectGuard(url, init = {}) {
203
+ const buildInit = typeof init === "function" ? init : () => init;
204
+ let currentUrl = url;
205
+ let previousUrl = null;
206
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
207
+ // Re-validate on every hop (including the first, in case of caller mistake).
208
+ await assertPublicUrl(currentUrl);
209
+ const hopInit = buildInit(currentUrl, previousUrl);
210
+ const resp = await fetch(currentUrl, { ...hopInit, redirect: "manual" });
211
+ // 3xx — follow manually after validation.
212
+ if (resp.status >= 300 && resp.status < 400) {
213
+ const location = resp.headers.get("location");
214
+ if (!location) {
215
+ return resp;
216
+ }
217
+ const next = new URL(location, currentUrl).toString();
218
+ previousUrl = currentUrl;
219
+ currentUrl = next;
220
+ try {
221
+ await resp.body?.cancel();
222
+ }
223
+ catch { /* ignore */ }
224
+ continue;
225
+ }
226
+ return resp;
227
+ }
228
+ throw new Error(`Refusing to follow more than ${MAX_REDIRECTS} redirects (started at ${url})`);
229
+ }
230
+ /**
231
+ * Boot-time apiUrl SSRF check (S6).
232
+ *
233
+ * Stricter than `assertPublicUrl`: requires either `https:` to a NON-private
234
+ * host, or `http:` to an explicit localhost form (for local development only).
235
+ *
236
+ * S6 fix: previously `https://` was allowed to ANY host including 127.0.0.1
237
+ * because we only checked protocol. Now we also reject private IPs for https.
238
+ */
239
+ export function isAllowedApiUrl(url) {
240
+ try {
241
+ const parsed = new URL(url);
242
+ const host = parsed.hostname.toLowerCase();
243
+ const bareHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
244
+ if (parsed.protocol === "https:") {
245
+ // Allow https to any non-private host. https://127.0.0.1 is suspicious
246
+ // (could be a self-signed mitmproxy if NODE_TLS_REJECT_UNAUTHORIZED=0).
247
+ if (isIP(bareHost) && isPrivateOrLocalAddress(bareHost))
248
+ return false;
249
+ return true;
250
+ }
251
+ if (parsed.protocol === "http:") {
252
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
253
+ }
254
+ return false;
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ }
260
+ /**
261
+ * Boot-time WebSocket URL validation — the transport-integrity counterpart to
262
+ * isAllowedApiUrl.
263
+ *
264
+ * The WuKongIM payload layer is AES-CBC WITHOUT an authentication tag, so message
265
+ * integrity rests entirely on the transport. An unencrypted `ws://` link lets a
266
+ * network attacker bit-flip ciphertext undetected (silent corruption / DoS).
267
+ * Require `wss://` for any non-loopback host; permit plaintext `ws://` only to an
268
+ * explicit localhost form (local dev / a co-located reverse proxy that terminates
269
+ * TLS). Mirrors the http→localhost-only carve-out in isAllowedApiUrl.
270
+ */
271
+ export function isAllowedWsUrl(url) {
272
+ try {
273
+ const parsed = new URL(url);
274
+ const host = parsed.hostname.toLowerCase();
275
+ const bareHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
276
+ if (parsed.protocol === "wss:") {
277
+ if (isIP(bareHost) && isPrivateOrLocalAddress(bareHost))
278
+ return false;
279
+ return true;
280
+ }
281
+ if (parsed.protocol === "ws:") {
282
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
283
+ }
284
+ return false;
285
+ }
286
+ catch {
287
+ return false;
288
+ }
289
+ }
290
+ //# sourceMappingURL=url-policy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-policy.js","sourceRoot":"","sources":["../src/url-policy.ts"],"names":[],"mappings":"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"}