~james/core

core/web/src/lib/publicFetch.test.ts -rw-r--r-- 3.8 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import { describe, expect, it, vi } from "vitest";
import {
	fetchPublic,
	isPublicHost,
	isPublicUrl,
	publicOnly,
	readTextCapped,
	UnsafeTargetError
} from "#lib/publicFetch.ts";

const prod = false;

describe("isPublicHost", () => {
	it("allows public names", () => {
		expect(isPublicHost("bsky.social")).toBe(true);
		expect(isPublicHost("pds.tngl.boltless.dev")).toBe(true);
	});

	it("blocks ip literals, service names and internal suffixes", () => {
		for (const host of [
			"127.0.0.1",
			"169.254.169.254",
			"[::1]",
			"localhost",
			"bobbin",
			"metadata.google.internal",
			"app.localhost",
			"nas.local",
			"vm.home.arpa",
			""
		])
			expect(isPublicHost(host), host).toBe(false);
	});

	it("treats a trailing dot as the same name", () => {
		expect(isPublicHost("localhost.")).toBe(false);
		expect(isPublicHost("metadata.google.internal.")).toBe(false);
	});
});

describe("isPublicUrl", () => {
	it("needs https outside dev", () => {
		expect(isPublicUrl(new URL("https://pds.example.com"), prod)).toBe(true);
		expect(isPublicUrl(new URL("http://pds.example.com"), prod)).toBe(false);
		// the url parser normalises numeric hosts to dotted ipv4
		expect(isPublicUrl(new URL("https://2130706433"), prod)).toBe(false);
	});

	it("lets anything through in dev", () => {
		expect(isPublicUrl(new URL("http://localhost:2583"), true)).toBe(true);
	});
});

const redirect = (to: string) => new Response(null, { status: 302, headers: { location: to } });

describe("fetchPublic", () => {
	it("refuses a private target without fetching", async () => {
		const fetch = vi.fn();
		await expect(
			fetchPublic(fetch, "http://127.0.0.1:8090/x", {}, prod)
		).rejects.toBeInstanceOf(UnsafeTargetError);
		expect(fetch).not.toHaveBeenCalled();
	});

	it("follows a redirect to another public host", async () => {
		const fetch = vi
			.fn()
			.mockResolvedValueOnce(redirect("https://cdn.example.com/blob"))
			.mockResolvedValueOnce(new Response("ok"));
		const res = await fetchPublic(fetch, "https://pds.example.com/x", {}, prod);
		expect(await res.text()).toBe("ok");
		expect(fetch.mock.calls.map(([u]) => String(u))).toEqual([
			"https://pds.example.com/x",
			"https://cdn.example.com/blob"
		]);
		expect(fetch.mock.calls[0][1]).toMatchObject({ redirect: "manual" });
	});

	it("refuses a redirect into the private network", async () => {
		const fetch = vi.fn().mockResolvedValueOnce(redirect("http://169.254.169.254/latest"));
		await expect(
			fetchPublic(fetch, "https://pds.example.com/x", {}, prod)
		).rejects.toBeInstanceOf(UnsafeTargetError);
		expect(fetch).toHaveBeenCalledTimes(1);
	});

	it("gives up on a redirect loop", async () => {
		const fetch = vi.fn(() => Promise.resolve(redirect("https://pds.example.com/x")));
		await expect(fetchPublic(fetch, "https://pds.example.com/x", {}, prod)).rejects.toThrow(
			/too many redirects/
		);
	});
});

describe("publicOnly", () => {
	it("rejects private targets and forbids redirects for the rest", async () => {
		const fetch = vi.fn(() => Promise.resolve(new Response("ok")));
		const guarded = publicOnly(fetch, prod);
		await expect(guarded("http://10.0.0.5:9000/xrpc/x")).rejects.toBeInstanceOf(
			UnsafeTargetError
		);
		await guarded("https://spindle.example.com/xrpc/x");
		expect(fetch).toHaveBeenCalledTimes(1);
		expect(fetch.mock.calls[0]).toEqual([
			"https://spindle.example.com/xrpc/x",
			{ redirect: "error" }
		]);
	});
});

describe("readTextCapped", () => {
	it("reads a body under the cap", async () => {
		expect(await readTextCapped(new Response("héllo"), 100)).toBe("héllo");
	});

	it("fails past the cap, declared or streamed", async () => {
		await expect(
			readTextCapped(new Response("x", { headers: { "content-length": "999" } }), 10)
		).rejects.toThrow(/over 10 bytes/);
		await expect(readTextCapped(new Response("x".repeat(50)), 10)).rejects.toThrow(
			/over 10 bytes/
		);
	});
});