@mininglamp-oss/cc-channel-octo 1.0.1-dev.60b73f3
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/CHANGELOG.md +349 -0
- package/LICENSE +191 -0
- package/README.md +577 -0
- package/config.bot.example.json +15 -0
- package/config.example.json +33 -0
- package/dist/agent-bridge.d.ts +79 -0
- package/dist/agent-bridge.js +392 -0
- package/dist/agent-bridge.js.map +1 -0
- package/dist/commands.d.ts +57 -0
- package/dist/commands.js +121 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +287 -0
- package/dist/config.js +332 -0
- package/dist/config.js.map +1 -0
- package/dist/cron-evaluator.d.ts +53 -0
- package/dist/cron-evaluator.js +191 -0
- package/dist/cron-evaluator.js.map +1 -0
- package/dist/cron-fire-marker.d.ts +24 -0
- package/dist/cron-fire-marker.js +25 -0
- package/dist/cron-fire-marker.js.map +1 -0
- package/dist/cron-scheduler.d.ts +46 -0
- package/dist/cron-scheduler.js +114 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/cron-store.d.ts +62 -0
- package/dist/cron-store.js +63 -0
- package/dist/cron-store.js.map +1 -0
- package/dist/cron-tool.d.ts +44 -0
- package/dist/cron-tool.js +151 -0
- package/dist/cron-tool.js.map +1 -0
- package/dist/cwd-resolver.d.ts +72 -0
- package/dist/cwd-resolver.js +166 -0
- package/dist/cwd-resolver.js.map +1 -0
- package/dist/db-adapter.d.ts +21 -0
- package/dist/db-adapter.js +64 -0
- package/dist/db-adapter.js.map +1 -0
- package/dist/file-inline-wrap.d.ts +94 -0
- package/dist/file-inline-wrap.js +243 -0
- package/dist/file-inline-wrap.js.map +1 -0
- package/dist/gateway.d.ts +100 -0
- package/dist/gateway.js +420 -0
- package/dist/gateway.js.map +1 -0
- package/dist/group-config.d.ts +41 -0
- package/dist/group-config.js +104 -0
- package/dist/group-config.js.map +1 -0
- package/dist/group-context.d.ts +81 -0
- package/dist/group-context.js +466 -0
- package/dist/group-context.js.map +1 -0
- package/dist/inbound.d.ts +136 -0
- package/dist/inbound.js +667 -0
- package/dist/inbound.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +932 -0
- package/dist/index.js.map +1 -0
- package/dist/media-inbound.d.ts +38 -0
- package/dist/media-inbound.js +131 -0
- package/dist/media-inbound.js.map +1 -0
- package/dist/mention-utils.d.ts +108 -0
- package/dist/mention-utils.js +199 -0
- package/dist/mention-utils.js.map +1 -0
- package/dist/octo/api.d.ts +148 -0
- package/dist/octo/api.js +320 -0
- package/dist/octo/api.js.map +1 -0
- package/dist/octo/socket.d.ts +102 -0
- package/dist/octo/socket.js +793 -0
- package/dist/octo/socket.js.map +1 -0
- package/dist/octo/types.d.ts +126 -0
- package/dist/octo/types.js +35 -0
- package/dist/octo/types.js.map +1 -0
- package/dist/prompt-safety.d.ts +78 -0
- package/dist/prompt-safety.js +148 -0
- package/dist/prompt-safety.js.map +1 -0
- package/dist/session-router.d.ts +144 -0
- package/dist/session-router.js +490 -0
- package/dist/session-router.js.map +1 -0
- package/dist/session-store.d.ts +89 -0
- package/dist/session-store.js +297 -0
- package/dist/session-store.js.map +1 -0
- package/dist/skill-linker.d.ts +31 -0
- package/dist/skill-linker.js +160 -0
- package/dist/skill-linker.js.map +1 -0
- package/dist/stream-relay.d.ts +42 -0
- package/dist/stream-relay.js +243 -0
- package/dist/stream-relay.js.map +1 -0
- package/dist/url-policy.d.ts +103 -0
- package/dist/url-policy.js +290 -0
- package/dist/url-policy.js.map +1 -0
- 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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"}
|