@syengup/friday-channel-next 0.1.27 → 0.1.28

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.
@@ -0,0 +1,234 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("node:dns/promises", () => ({
4
+ default: { lookup: vi.fn() },
5
+ }));
6
+
7
+ import dns from "node:dns/promises";
8
+ import {
9
+ BlockedUrlError,
10
+ assertPublicHttpUrl,
11
+ assertResolvesPublic,
12
+ fetchPublicUrl,
13
+ isPrivateAddress,
14
+ parseHttpUrl,
15
+ } from "./ssrf-guard.js";
16
+
17
+ const lookupMock = vi.mocked(dns.lookup);
18
+
19
+ function mockPublicDns(): void {
20
+ lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }] as never);
21
+ }
22
+
23
+ beforeEach(() => {
24
+ lookupMock.mockReset();
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.unstubAllGlobals();
29
+ });
30
+
31
+ describe("parseHttpUrl", () => {
32
+ it("accepts absolute http/https URLs", () => {
33
+ expect(parseHttpUrl("https://example.com/a?b=1")?.hostname).toBe("example.com");
34
+ expect(parseHttpUrl(" http://example.com ")?.protocol).toBe("http:");
35
+ });
36
+
37
+ it("rejects non-http schemes and garbage", () => {
38
+ expect(parseHttpUrl("ftp://example.com/a")).toBeNull();
39
+ expect(parseHttpUrl("file:///etc/passwd")).toBeNull();
40
+ expect(parseHttpUrl("javascript:alert(1)")).toBeNull();
41
+ expect(parseHttpUrl("not a url")).toBeNull();
42
+ expect(parseHttpUrl("/relative/path")).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("assertPublicHttpUrl", () => {
47
+ it("rejects non-default ports", () => {
48
+ expect(() => assertPublicHttpUrl(new URL("http://example.com:8080/"))).toThrow(BlockedUrlError);
49
+ expect(() => assertPublicHttpUrl(new URL("https://example.com:8443/"))).toThrow(BlockedUrlError);
50
+ });
51
+
52
+ it("allows default and explicit 80/443 ports", () => {
53
+ expect(() => assertPublicHttpUrl(new URL("https://example.com/"))).not.toThrow();
54
+ expect(() => assertPublicHttpUrl(new URL("http://example.com:80/"))).not.toThrow();
55
+ expect(() => assertPublicHttpUrl(new URL("https://example.com:443/"))).not.toThrow();
56
+ });
57
+
58
+ it("rejects localhost and internal-suffix hostnames", () => {
59
+ expect(() => assertPublicHttpUrl(new URL("http://localhost/"))).toThrow(BlockedUrlError);
60
+ expect(() => assertPublicHttpUrl(new URL("http://gateway.local/"))).toThrow(BlockedUrlError);
61
+ expect(() => assertPublicHttpUrl(new URL("http://db.internal/"))).toThrow(BlockedUrlError);
62
+ expect(() => assertPublicHttpUrl(new URL("http://nas.home.arpa/"))).toThrow(BlockedUrlError);
63
+ });
64
+
65
+ it("rejects private IP literals including bracketed IPv6", () => {
66
+ expect(() => assertPublicHttpUrl(new URL("http://127.0.0.1/"))).toThrow(BlockedUrlError);
67
+ expect(() => assertPublicHttpUrl(new URL("http://10.0.0.5/"))).toThrow(BlockedUrlError);
68
+ expect(() => assertPublicHttpUrl(new URL("http://[::1]/"))).toThrow(BlockedUrlError);
69
+ expect(() => assertPublicHttpUrl(new URL("http://[fc00::1]/"))).toThrow(BlockedUrlError);
70
+ });
71
+
72
+ it("allows public IP literals", () => {
73
+ expect(() => assertPublicHttpUrl(new URL("http://93.184.216.34/"))).not.toThrow();
74
+ });
75
+ });
76
+
77
+ describe("isPrivateAddress", () => {
78
+ it("flags private/reserved IPv4 ranges", () => {
79
+ for (const ip of [
80
+ "0.0.0.0",
81
+ "10.1.2.3",
82
+ "100.64.0.1",
83
+ "100.127.255.255",
84
+ "127.0.0.1",
85
+ "169.254.1.1",
86
+ "172.16.0.1",
87
+ "172.31.255.255",
88
+ "192.0.0.1",
89
+ "192.168.1.1",
90
+ "224.0.0.1",
91
+ "255.255.255.255",
92
+ ]) {
93
+ expect(isPrivateAddress(ip), ip).toBe(true);
94
+ }
95
+ });
96
+
97
+ it("passes public IPv4", () => {
98
+ for (const ip of ["8.8.8.8", "93.184.216.34", "100.63.0.1", "100.128.0.1", "172.32.0.1", "198.20.0.1"]) {
99
+ expect(isPrivateAddress(ip), ip).toBe(false);
100
+ }
101
+ });
102
+
103
+ it("passes 198.18/15 (fake-IP DNS range used by clash/surge tun mode)", () => {
104
+ expect(isPrivateAddress("198.18.1.156")).toBe(false);
105
+ expect(isPrivateAddress("198.19.255.255")).toBe(false);
106
+ });
107
+
108
+ it("flags private/reserved IPv6 and mapped IPv4", () => {
109
+ for (const ip of ["::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1"]) {
110
+ expect(isPrivateAddress(ip), ip).toBe(true);
111
+ }
112
+ });
113
+
114
+ it("passes public IPv6 and mapped public IPv4", () => {
115
+ expect(isPrivateAddress("2606:2800:220:1:248:1893:25c8:1946")).toBe(false);
116
+ expect(isPrivateAddress("::ffff:93.184.216.34")).toBe(false);
117
+ });
118
+
119
+ it("treats non-IP strings as unsafe", () => {
120
+ expect(isPrivateAddress("not-an-ip")).toBe(true);
121
+ });
122
+ });
123
+
124
+ describe("assertResolvesPublic", () => {
125
+ it("passes when all resolved addresses are public", async () => {
126
+ lookupMock.mockResolvedValue([
127
+ { address: "93.184.216.34", family: 4 },
128
+ { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
129
+ ] as never);
130
+ await expect(assertResolvesPublic(new URL("https://example.com/"))).resolves.toBeUndefined();
131
+ });
132
+
133
+ it("throws BlockedUrlError when any resolved address is private", async () => {
134
+ lookupMock.mockResolvedValue([
135
+ { address: "93.184.216.34", family: 4 },
136
+ { address: "10.0.0.5", family: 4 },
137
+ ] as never);
138
+ await expect(assertResolvesPublic(new URL("https://rebind.example.com/"))).rejects.toThrow(BlockedUrlError);
139
+ });
140
+
141
+ it("validates IP-literal hosts without a DNS lookup", async () => {
142
+ await expect(assertResolvesPublic(new URL("http://127.0.0.1/"))).rejects.toThrow(BlockedUrlError);
143
+ await expect(assertResolvesPublic(new URL("http://93.184.216.34/"))).resolves.toBeUndefined();
144
+ expect(lookupMock).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it("throws a plain error (not BlockedUrlError) on DNS failure", async () => {
148
+ lookupMock.mockRejectedValue(new Error("ENOTFOUND"));
149
+ const err = await assertResolvesPublic(new URL("https://nope.example.com/")).catch((e) => e);
150
+ expect(err).toBeInstanceOf(Error);
151
+ expect(err).not.toBeInstanceOf(BlockedUrlError);
152
+ });
153
+ });
154
+
155
+ describe("fetchPublicUrl", () => {
156
+ const opts = { maxBytes: 1024, timeoutMs: 5000 };
157
+
158
+ it("fetches a public URL and returns body + finalUrl", async () => {
159
+ mockPublicDns();
160
+ vi.stubGlobal(
161
+ "fetch",
162
+ vi.fn(async () => new Response("<html>hi</html>", { status: 200, headers: { "content-type": "text/html; charset=utf-8" } })),
163
+ );
164
+ const result = await fetchPublicUrl("https://example.com/page", opts);
165
+ expect(result?.finalUrl).toBe("https://example.com/page");
166
+ expect(result?.contentType).toContain("text/html");
167
+ expect(result?.body.toString()).toBe("<html>hi</html>");
168
+ });
169
+
170
+ it("follows redirects and re-validates each hop", async () => {
171
+ mockPublicDns();
172
+ const fetchMock = vi
173
+ .fn()
174
+ .mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "https://other.example.com/final" } }))
175
+ .mockResolvedValueOnce(new Response("ok", { status: 200, headers: { "content-type": "text/html" } }));
176
+ vi.stubGlobal("fetch", fetchMock);
177
+ const result = await fetchPublicUrl("https://example.com/start", opts);
178
+ expect(result?.finalUrl).toBe("https://other.example.com/final");
179
+ expect(fetchMock).toHaveBeenCalledTimes(2);
180
+ expect(lookupMock).toHaveBeenCalledTimes(2);
181
+ });
182
+
183
+ it("throws BlockedUrlError when a redirect targets a private address", async () => {
184
+ mockPublicDns();
185
+ vi.stubGlobal(
186
+ "fetch",
187
+ vi.fn(async () => new Response(null, { status: 302, headers: { location: "http://127.0.0.1/admin" } })),
188
+ );
189
+ await expect(fetchPublicUrl("https://example.com/start", opts)).rejects.toThrow(BlockedUrlError);
190
+ });
191
+
192
+ it("throws BlockedUrlError for a directly-blocked URL", async () => {
193
+ await expect(fetchPublicUrl("http://192.168.1.1/", opts)).rejects.toThrow(BlockedUrlError);
194
+ });
195
+
196
+ it("returns null when the body exceeds maxBytes (ignoring content-length)", async () => {
197
+ mockPublicDns();
198
+ const big = "x".repeat(2048);
199
+ vi.stubGlobal(
200
+ "fetch",
201
+ vi.fn(async () => new Response(big, { status: 200, headers: { "content-type": "text/html", "content-length": "10" } })),
202
+ );
203
+ expect(await fetchPublicUrl("https://example.com/big", opts)).toBeNull();
204
+ });
205
+
206
+ it("returns null when content-type does not match the required prefixes", async () => {
207
+ mockPublicDns();
208
+ vi.stubGlobal(
209
+ "fetch",
210
+ vi.fn(async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } })),
211
+ );
212
+ expect(
213
+ await fetchPublicUrl("https://example.com/api", { ...opts, requireContentTypePrefixes: ["text/html", "application/xhtml+xml"] }),
214
+ ).toBeNull();
215
+ });
216
+
217
+ it("returns null on non-2xx and on DNS failure", async () => {
218
+ mockPublicDns();
219
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 404 })));
220
+ expect(await fetchPublicUrl("https://example.com/missing", opts)).toBeNull();
221
+
222
+ lookupMock.mockRejectedValue(new Error("ENOTFOUND"));
223
+ expect(await fetchPublicUrl("https://nope.example.com/", opts)).toBeNull();
224
+ });
225
+
226
+ it("gives up after too many redirects", async () => {
227
+ mockPublicDns();
228
+ vi.stubGlobal(
229
+ "fetch",
230
+ vi.fn(async () => new Response(null, { status: 302, headers: { location: "https://example.com/loop" } })),
231
+ );
232
+ expect(await fetchPublicUrl("https://example.com/loop", opts)).toBeNull();
233
+ });
234
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * SSRF guard + restricted fetch for the link-preview endpoint.
3
+ *
4
+ * Unlike `downloadRemoteMedia` (agent-supplied URLs for outbound media), link-preview fetches
5
+ * URLs that originate from arbitrary message text, so every hop must be validated: protocol,
6
+ * port, hostname literals, and the full set of DNS-resolved addresses. Redirects are followed
7
+ * manually so each target is re-checked before the next request.
8
+ *
9
+ * Known residual risk: DNS rebinding TOCTOU — we validate resolved addresses, then `fetch`
10
+ * resolves again. Closing that gap requires dialing by IP with SNI/Host rewriting, which is
11
+ * disproportionate for preview metadata; accepted at this threat level.
12
+ */
13
+
14
+ import dns from "node:dns/promises";
15
+ import net from "node:net";
16
+
17
+ const MAX_REDIRECTS = 5;
18
+
19
+ export class BlockedUrlError extends Error {
20
+ readonly reason: string;
21
+ constructor(reason: string) {
22
+ super(`Blocked URL: ${reason}`);
23
+ this.name = "BlockedUrlError";
24
+ this.reason = reason;
25
+ }
26
+ }
27
+
28
+ /** Parse an absolute http/https URL. Returns null for anything else (caller maps to invalid_url). */
29
+ export function parseHttpUrl(raw: string): URL | null {
30
+ let url: URL;
31
+ try {
32
+ url = new URL(raw.trim());
33
+ } catch {
34
+ return null;
35
+ }
36
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
37
+ return url;
38
+ }
39
+
40
+ const BLOCKED_HOST_SUFFIXES = [".local", ".internal", ".home.arpa", ".localhost"];
41
+
42
+ /** Synchronous literal checks: port, hostname blocklist, IP-literal hosts. Throws BlockedUrlError. */
43
+ export function assertPublicHttpUrl(url: URL): void {
44
+ if (url.port && url.port !== "80" && url.port !== "443") {
45
+ throw new BlockedUrlError(`port ${url.port} not allowed`);
46
+ }
47
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
48
+ if (!host) throw new BlockedUrlError("empty host");
49
+ if (host === "localhost" || BLOCKED_HOST_SUFFIXES.some((s) => host.endsWith(s))) {
50
+ throw new BlockedUrlError(`host "${host}" is not public`);
51
+ }
52
+ // IPv6 literal in a URL comes bracketed: strip for net.isIP.
53
+ const bareHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
54
+ if (net.isIP(bareHost) && isPrivateAddress(bareHost)) {
55
+ throw new BlockedUrlError(`address ${bareHost} is private/reserved`);
56
+ }
57
+ }
58
+
59
+ /** True when the IP (v4 or v6, including ::ffff: mapped v4) is private, loopback, or reserved. */
60
+ export function isPrivateAddress(ip: string): boolean {
61
+ const version = net.isIP(ip);
62
+ if (version === 4) return isPrivateIPv4(ip);
63
+ if (version === 6) return isPrivateIPv6(ip);
64
+ return true; // not an IP at all — treat as unsafe
65
+ }
66
+
67
+ function isPrivateIPv4(ip: string): boolean {
68
+ const parts = ip.split(".").map(Number);
69
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
70
+ const [a, b] = parts;
71
+ if (a === 0) return true; // 0.0.0.0/8
72
+ if (a === 10) return true; // 10/8
73
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 CGNAT
74
+ if (a === 127) return true; // loopback
75
+ if (a === 169 && b === 254) return true; // link-local
76
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
77
+ if (a === 192 && b === 0 && parts[2] === 0) return true; // 192.0.0/24
78
+ if (a === 192 && b === 168) return true; // 192.168/16
79
+ // 198.18/15 (benchmarking) intentionally NOT blocked: fake-IP DNS setups (clash/surge tun
80
+ // mode, common on gateway hosts) resolve EVERY domain into this range, so blocking it kills
81
+ // all lookups. The range is not used for real LAN services, so the SSRF exposure is nil.
82
+ if (a >= 224) return true; // multicast 224/4 + reserved 240/4 + broadcast
83
+ return false;
84
+ }
85
+
86
+ function isPrivateIPv6(ip: string): boolean {
87
+ const lower = ip.toLowerCase();
88
+ // ::ffff:a.b.c.d mapped IPv4 → validate the embedded v4.
89
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
90
+ if (mapped) return isPrivateIPv4(mapped[1]);
91
+ if (lower === "::" || lower === "::1") return true; // unspecified / loopback
92
+ const head = lower.split(":")[0];
93
+ if (head.startsWith("fc") || head.startsWith("fd")) return true; // fc00::/7 ULA
94
+ if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local
95
+ return false;
96
+ }
97
+
98
+ /** Resolve the hostname and require every returned address to be public. Throws BlockedUrlError. */
99
+ export async function assertResolvesPublic(url: URL): Promise<void> {
100
+ const host = url.hostname.replace(/^\[|\]$/g, "");
101
+ if (net.isIP(host)) {
102
+ if (isPrivateAddress(host)) throw new BlockedUrlError(`address ${host} is private/reserved`);
103
+ return;
104
+ }
105
+ let addresses: { address: string }[];
106
+ try {
107
+ addresses = await dns.lookup(host, { all: true, verbatim: true });
108
+ } catch {
109
+ throw new Error(`DNS lookup failed for ${host}`);
110
+ }
111
+ if (!addresses.length) throw new Error(`DNS lookup returned no addresses for ${host}`);
112
+ for (const { address } of addresses) {
113
+ if (isPrivateAddress(address)) {
114
+ throw new BlockedUrlError(`${host} resolves to private/reserved address ${address}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ export interface FetchPublicUrlOptions {
120
+ maxBytes: number;
121
+ timeoutMs: number;
122
+ /** Sent as the Accept header. */
123
+ accept?: string;
124
+ /** When set, the response Content-Type must start with one of these prefixes. */
125
+ requireContentTypePrefixes?: string[];
126
+ }
127
+
128
+ export interface FetchPublicUrlResult {
129
+ finalUrl: string;
130
+ contentType: string;
131
+ body: Buffer;
132
+ }
133
+
134
+ const PREVIEW_USER_AGENT = "Mozilla/5.0 (compatible; OpenClawLinkPreview/1.0)";
135
+
136
+ /**
137
+ * Fetch a public http/https URL with manual redirects (≤5 hops, each hop re-validated) and a
138
+ * streamed size cap (Content-Length is not trusted). Returns null on ordinary failures (non-2xx,
139
+ * oversize, timeout, bad content type, DNS error); throws BlockedUrlError on SSRF rejection.
140
+ */
141
+ export async function fetchPublicUrl(
142
+ rawUrl: string,
143
+ opts: FetchPublicUrlOptions,
144
+ ): Promise<FetchPublicUrlResult | null> {
145
+ const controller = new AbortController();
146
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
147
+ try {
148
+ let current = rawUrl;
149
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
150
+ const url = parseHttpUrl(current);
151
+ if (!url) return null;
152
+ assertPublicHttpUrl(url);
153
+ await assertResolvesPublic(url);
154
+
155
+ const res = await fetch(url, {
156
+ redirect: "manual",
157
+ signal: controller.signal,
158
+ headers: {
159
+ "User-Agent": PREVIEW_USER_AGENT,
160
+ ...(opts.accept ? { Accept: opts.accept } : {}),
161
+ },
162
+ });
163
+
164
+ if (res.status >= 300 && res.status < 400) {
165
+ const location = res.headers.get("location");
166
+ await res.body?.cancel().catch(() => {});
167
+ if (!location) return null;
168
+ try {
169
+ current = new URL(location, url).toString();
170
+ } catch {
171
+ return null;
172
+ }
173
+ continue;
174
+ }
175
+ if (!res.ok) {
176
+ await res.body?.cancel().catch(() => {});
177
+ return null;
178
+ }
179
+
180
+ const contentType = res.headers.get("content-type")?.trim().toLowerCase() ?? "";
181
+ if (
182
+ opts.requireContentTypePrefixes &&
183
+ !opts.requireContentTypePrefixes.some((p) => contentType.startsWith(p))
184
+ ) {
185
+ await res.body?.cancel().catch(() => {});
186
+ return null;
187
+ }
188
+
189
+ const body = await readBodyCapped(res, opts.maxBytes);
190
+ if (body === null) return null;
191
+ return { finalUrl: url.toString(), contentType, body };
192
+ }
193
+ return null; // too many redirects
194
+ } catch (err) {
195
+ if (err instanceof BlockedUrlError) throw err;
196
+ return null; // timeout / network / DNS failure
197
+ } finally {
198
+ clearTimeout(timer);
199
+ }
200
+ }
201
+
202
+ /** Stream the response body, aborting once it exceeds maxBytes. */
203
+ async function readBodyCapped(res: Response, maxBytes: number): Promise<Buffer | null> {
204
+ if (!res.body) {
205
+ const buffer = Buffer.from(await res.arrayBuffer());
206
+ return buffer.length <= maxBytes ? buffer : null;
207
+ }
208
+ const reader = res.body.getReader();
209
+ const chunks: Buffer[] = [];
210
+ let total = 0;
211
+ try {
212
+ for (;;) {
213
+ const { done, value } = await reader.read();
214
+ if (done) break;
215
+ if (value) {
216
+ total += value.byteLength;
217
+ if (total > maxBytes) {
218
+ await reader.cancel().catch(() => {});
219
+ return null;
220
+ }
221
+ chunks.push(Buffer.from(value));
222
+ }
223
+ }
224
+ } catch {
225
+ return null;
226
+ }
227
+ const buffer = Buffer.concat(chunks);
228
+ return buffer.length ? buffer : null;
229
+ }
package/src/version.ts CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from "node:fs";
11
11
  import { fileURLToPath } from "node:url";
12
12
 
13
13
  /** Keep in sync with package.json "version" as a last-resort fallback. */
14
- const FALLBACK_VERSION = "0.1.27";
14
+ const FALLBACK_VERSION = "0.1.28";
15
15
 
16
16
  function resolvePluginVersion(): string {
17
17
  // dist layout: <root>/dist/src/version.js → ../../package.json = <root>/package.json