@syengup/friday-channel-next 0.1.26 → 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.
- package/dist/index.js +2 -0
- package/dist/src/http/handlers/files.js +1 -0
- package/dist/src/http/handlers/health.d.ts +1 -0
- package/dist/src/http/handlers/health.js +2 -0
- package/dist/src/http/handlers/link-preview.d.ts +9 -0
- package/dist/src/http/handlers/link-preview.js +41 -0
- package/dist/src/http/handlers/messages.d.ts +5 -0
- package/dist/src/http/handlers/messages.js +19 -8
- package/dist/src/http/handlers/plugin-info.d.ts +11 -0
- package/dist/src/http/handlers/plugin-info.js +32 -0
- package/dist/src/http/handlers/plugin-upgrade.d.ts +11 -0
- package/dist/src/http/handlers/plugin-upgrade.js +94 -0
- package/dist/src/http/handlers/sse.js +2 -0
- package/dist/src/http/handlers/status.js +2 -0
- package/dist/src/http/server.js +15 -0
- package/dist/src/link-preview/og-parse.d.ts +21 -0
- package/dist/src/link-preview/og-parse.js +232 -0
- package/dist/src/link-preview/preview-service.d.ts +31 -0
- package/dist/src/link-preview/preview-service.js +216 -0
- package/dist/src/link-preview/ssrf-guard.d.ts +43 -0
- package/dist/src/link-preview/ssrf-guard.js +223 -0
- package/dist/src/plugin-install-info.d.ts +15 -0
- package/dist/src/plugin-install-info.js +87 -0
- package/dist/src/upgrade-runtime.d.ts +39 -0
- package/dist/src/upgrade-runtime.js +27 -0
- package/dist/src/version.d.ts +5 -0
- package/dist/src/version.js +37 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/http/handlers/files.ts +1 -0
- package/src/http/handlers/health.ts +3 -0
- package/src/http/handlers/link-preview.test.ts +242 -0
- package/src/http/handlers/link-preview.ts +47 -0
- package/src/http/handlers/messages.test.ts +75 -1
- package/src/http/handlers/messages.ts +19 -7
- package/src/http/handlers/plugin-info.ts +51 -0
- package/src/http/handlers/plugin-upgrade.ts +112 -0
- package/src/http/handlers/sse.ts +2 -0
- package/src/http/handlers/status.ts +2 -0
- package/src/http/server.ts +18 -0
- package/src/link-preview/og-parse.test.ts +168 -0
- package/src/link-preview/og-parse.ts +249 -0
- package/src/link-preview/preview-service.ts +247 -0
- package/src/link-preview/ssrf-guard.test.ts +234 -0
- package/src/link-preview/ssrf-guard.ts +229 -0
- package/src/plugin-install-info.test.ts +28 -0
- package/src/plugin-install-info.ts +95 -0
- package/src/upgrade-runtime.ts +69 -0
- package/src/version.ts +41 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-preview orchestration: fetch page → parse Open Graph → re-host the cover image through
|
|
3
|
+
* the gateway's stored files → cache.
|
|
4
|
+
*
|
|
5
|
+
* The cover image is downloaded server-side and served from /friday-next/files/ so the app only
|
|
6
|
+
* ever talks to the trusted gateway host (same rationale as `downloadRemoteMedia` for outbound
|
|
7
|
+
* media). Failures degrade to "no card" on the app side, so every error path returns a typed
|
|
8
|
+
* error instead of throwing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createFridayNextLogger } from "../logging.js";
|
|
12
|
+
import { storeFile } from "../http/handlers/files.js";
|
|
13
|
+
import { parseOpenGraph } from "./og-parse.js";
|
|
14
|
+
import { BlockedUrlError, fetchPublicUrl, parseHttpUrl } from "./ssrf-guard.js";
|
|
15
|
+
|
|
16
|
+
const HTML_MAX_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
const HTML_TIMEOUT_MS = 10_000;
|
|
18
|
+
const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
const IMAGE_TIMEOUT_MS = 10_000;
|
|
20
|
+
|
|
21
|
+
const SUCCESS_TTL_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
const FAILURE_TTL_MS = 10 * 60 * 1000;
|
|
23
|
+
const MAX_CACHE_ENTRIES = 1000;
|
|
24
|
+
|
|
25
|
+
const logger = createFridayNextLogger("link-preview");
|
|
26
|
+
|
|
27
|
+
export interface LinkPreviewPayload {
|
|
28
|
+
url: string;
|
|
29
|
+
finalUrl: string;
|
|
30
|
+
siteName: string | null;
|
|
31
|
+
title: string;
|
|
32
|
+
description: string | null;
|
|
33
|
+
/** Gateway-relative cover URL ("/friday-next/files/{token}") or null. */
|
|
34
|
+
imageUrl: string | null;
|
|
35
|
+
/** Gateway-relative favicon URL ("/friday-next/files/{token}") or null. */
|
|
36
|
+
iconUrl: string | null;
|
|
37
|
+
fetchedAt: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type LinkPreviewError = "invalid_url" | "blocked_url" | "fetch_failed" | "no_metadata";
|
|
41
|
+
|
|
42
|
+
export type LinkPreviewResult =
|
|
43
|
+
| { ok: true; preview: LinkPreviewPayload }
|
|
44
|
+
| { ok: false; error: LinkPreviewError };
|
|
45
|
+
|
|
46
|
+
interface CacheEntry {
|
|
47
|
+
result: LinkPreviewResult;
|
|
48
|
+
cachedAt: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cache = new Map<string, CacheEntry>();
|
|
52
|
+
const inFlight = new Map<string, Promise<LinkPreviewResult>>();
|
|
53
|
+
|
|
54
|
+
export function resetLinkPreviewCacheForTest(): void {
|
|
55
|
+
cache.clear();
|
|
56
|
+
inFlight.clear();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function getLinkPreview(rawUrl: string): Promise<LinkPreviewResult> {
|
|
60
|
+
const parsed = parseHttpUrl(rawUrl);
|
|
61
|
+
if (!parsed) return { ok: false, error: "invalid_url" };
|
|
62
|
+
const key = parsed.toString();
|
|
63
|
+
|
|
64
|
+
const cached = cache.get(key);
|
|
65
|
+
if (cached) {
|
|
66
|
+
const ttl = cached.result.ok ? SUCCESS_TTL_MS : FAILURE_TTL_MS;
|
|
67
|
+
if (Date.now() - cached.cachedAt < ttl) return cached.result;
|
|
68
|
+
cache.delete(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pending = inFlight.get(key);
|
|
72
|
+
if (pending) return pending;
|
|
73
|
+
|
|
74
|
+
const task = buildPreview(key)
|
|
75
|
+
.then((result) => {
|
|
76
|
+
writeCache(key, result);
|
|
77
|
+
return result;
|
|
78
|
+
})
|
|
79
|
+
.finally(() => {
|
|
80
|
+
inFlight.delete(key);
|
|
81
|
+
});
|
|
82
|
+
inFlight.set(key, task);
|
|
83
|
+
return task;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeCache(key: string, result: LinkPreviewResult): void {
|
|
87
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
88
|
+
let oldestKey: string | null = null;
|
|
89
|
+
let oldestAt = Infinity;
|
|
90
|
+
for (const [k, entry] of cache) {
|
|
91
|
+
if (entry.cachedAt < oldestAt) {
|
|
92
|
+
oldestAt = entry.cachedAt;
|
|
93
|
+
oldestKey = k;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (oldestKey) cache.delete(oldestKey);
|
|
97
|
+
}
|
|
98
|
+
cache.set(key, { result, cachedAt: Date.now() });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function buildPreview(pageUrl: string): Promise<LinkPreviewResult> {
|
|
102
|
+
let page;
|
|
103
|
+
try {
|
|
104
|
+
page = await fetchPublicUrl(pageUrl, {
|
|
105
|
+
maxBytes: HTML_MAX_BYTES,
|
|
106
|
+
timeoutMs: HTML_TIMEOUT_MS,
|
|
107
|
+
accept: "text/html,application/xhtml+xml",
|
|
108
|
+
requireContentTypePrefixes: ["text/html", "application/xhtml+xml"],
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err instanceof BlockedUrlError) {
|
|
112
|
+
logger.warn(`link-preview blocked: ${pageUrl} (${err.reason})`);
|
|
113
|
+
return { ok: false, error: "blocked_url" };
|
|
114
|
+
}
|
|
115
|
+
page = null; // network/timeout — fall through to a favicon-only minimal card
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const finalUrl = page?.finalUrl ?? pageUrl;
|
|
119
|
+
const og = page ? parseOpenGraph(page.body.toString("utf8"), finalUrl) : null;
|
|
120
|
+
const hostname = (() => {
|
|
121
|
+
try {
|
|
122
|
+
return new URL(finalUrl).hostname;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
|
|
128
|
+
// Favicon: parsed <link rel icon> first, then the conventional /favicon.ico (which is reachable
|
|
129
|
+
// even for pages that block bots, e.g. zhihu → redirects to its CDN icon).
|
|
130
|
+
const iconUrl = await resolveFavicon(og?.iconUrl ?? null, finalUrl);
|
|
131
|
+
|
|
132
|
+
// A failed page fetch only yields a (minimal) card when the favicon resolved — that proves the
|
|
133
|
+
// domain is real/reachable (e.g. bot-blocked zhihu). A dead domain (favicon also fails) collapses.
|
|
134
|
+
const reachable = page !== null || iconUrl !== null;
|
|
135
|
+
const title = og?.title ?? hostname;
|
|
136
|
+
if (!reachable || !title) {
|
|
137
|
+
return { ok: false, error: page ? "no_metadata" : "fetch_failed" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const imageUrl = og?.imageUrl ? await rehostCoverImage(og.imageUrl) : null;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
preview: {
|
|
145
|
+
url: pageUrl,
|
|
146
|
+
finalUrl,
|
|
147
|
+
siteName: og?.siteName ?? hostname,
|
|
148
|
+
title: title ?? hostname ?? pageUrl,
|
|
149
|
+
description: og?.description ?? null,
|
|
150
|
+
imageUrl,
|
|
151
|
+
iconUrl,
|
|
152
|
+
fetchedAt: Date.now(),
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Re-host a favicon: try the parsed `<link rel icon>`, then `<origin>/favicon.ico`. */
|
|
158
|
+
async function resolveFavicon(parsedIconUrl: string | null, finalUrl: string): Promise<string | null> {
|
|
159
|
+
const candidates: string[] = [];
|
|
160
|
+
if (parsedIconUrl) candidates.push(parsedIconUrl);
|
|
161
|
+
try {
|
|
162
|
+
candidates.push(new URL("/favicon.ico", finalUrl).toString());
|
|
163
|
+
} catch {
|
|
164
|
+
// finalUrl unparseable — skip the conventional fallback
|
|
165
|
+
}
|
|
166
|
+
for (const candidate of candidates) {
|
|
167
|
+
const rehosted = await rehostIconImage(candidate);
|
|
168
|
+
if (rehosted) return rehosted;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Download a favicon (full SSRF checks) and re-publish via stored files. Null on any failure. */
|
|
174
|
+
async function rehostIconImage(iconUrl: string): Promise<string | null> {
|
|
175
|
+
let image;
|
|
176
|
+
try {
|
|
177
|
+
image = await fetchPublicUrl(iconUrl, {
|
|
178
|
+
maxBytes: 1024 * 1024,
|
|
179
|
+
timeoutMs: IMAGE_TIMEOUT_MS,
|
|
180
|
+
accept: "image/*",
|
|
181
|
+
requireContentTypePrefixes: ["image/"],
|
|
182
|
+
});
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (!image) return null;
|
|
187
|
+
const sniffed = sniffImageType(image.body);
|
|
188
|
+
if (!sniffed) return null;
|
|
189
|
+
try {
|
|
190
|
+
const stored = storeFile(image.body, `link-preview-icon.${sniffed.ext}`, sniffed.mime);
|
|
191
|
+
return `/friday-next/files/${encodeURIComponent(stored.urlToken)}`;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Download og:image (full SSRF checks) and re-publish via stored files. Null on any failure. */
|
|
198
|
+
async function rehostCoverImage(imageUrl: string): Promise<string | null> {
|
|
199
|
+
let image;
|
|
200
|
+
try {
|
|
201
|
+
image = await fetchPublicUrl(imageUrl, {
|
|
202
|
+
maxBytes: IMAGE_MAX_BYTES,
|
|
203
|
+
timeoutMs: IMAGE_TIMEOUT_MS,
|
|
204
|
+
accept: "image/*",
|
|
205
|
+
requireContentTypePrefixes: ["image/"],
|
|
206
|
+
});
|
|
207
|
+
} catch {
|
|
208
|
+
return null; // blocked og:image just means no cover
|
|
209
|
+
}
|
|
210
|
+
if (!image) return null;
|
|
211
|
+
|
|
212
|
+
const sniffed = sniffImageType(image.body);
|
|
213
|
+
if (!sniffed) return null;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const stored = storeFile(image.body, `link-preview-cover.${sniffed.ext}`, sniffed.mime);
|
|
217
|
+
return `/friday-next/files/${encodeURIComponent(stored.urlToken)}`;
|
|
218
|
+
} catch (err) {
|
|
219
|
+
logger.warn(`link-preview cover store failed for ${imageUrl}: ${String(err)}`);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Magic-byte sniff — second line of defense after the Content-Type check. */
|
|
225
|
+
function sniffImageType(buffer: Buffer): { ext: string; mime: string } | null {
|
|
226
|
+
if (buffer.length < 12) return null;
|
|
227
|
+
// ICO: 00 00 01 00 (favicons are commonly .ico; iOS ImageIO decodes them).
|
|
228
|
+
if (buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0x01 && buffer[3] === 0x00) {
|
|
229
|
+
return { ext: "ico", mime: "image/x-icon" };
|
|
230
|
+
}
|
|
231
|
+
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
|
232
|
+
return { ext: "png", mime: "image/png" };
|
|
233
|
+
}
|
|
234
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
235
|
+
return { ext: "jpg", mime: "image/jpeg" };
|
|
236
|
+
}
|
|
237
|
+
if (buffer.subarray(0, 4).toString("latin1") === "GIF8") {
|
|
238
|
+
return { ext: "gif", mime: "image/gif" };
|
|
239
|
+
}
|
|
240
|
+
if (
|
|
241
|
+
buffer.subarray(0, 4).toString("latin1") === "RIFF" &&
|
|
242
|
+
buffer.subarray(8, 12).toString("latin1") === "WEBP"
|
|
243
|
+
) {
|
|
244
|
+
return { ext: "webp", mime: "image/webp" };
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
@@ -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
|
+
});
|