Skip to content
197 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { BrokeredSecret } from "@g1t/contracts";
5
6import {
7 ANSWER_HEADER,
8 MAX_REQUEST_BYTES,
9 REQUEST_HEADER,
10 type RelayDeps,
11 answerHeaders,
12 capped,
13 readCapped,
14 readRelayed,
15 relay,
16 replaceBytes,
17 scrubStream,
18 scrubbedType,
19} from "./broker-relay.ts";
20
21const NPM: BrokeredSecret = {
22 standIn: `g1tsec_${"a".repeat(48)}`,
23 name: "NPM_TOKEN",
24 value: "npm_realvalue123",
25 hosts: ["registry.npmjs.org"],
26};
27
28const b64 = (value: unknown) => btoa(JSON.stringify(value));
29
30function brokerRequest(
31 wanted: { method?: string; url: string; headers?: [string, string][] },
32 options: { token?: string; job?: string; body?: BodyInit; header?: string } = {},
33): Request {
34 const headers = new Headers({ "x-g1t-broker-job": options.job ?? "job_1" });
35 if (options.token !== "") headers.set("authorization", `Bearer ${options.token ?? "current"}`);
36 headers.set(REQUEST_HEADER, options.header ?? b64({ method: wanted.method ?? "GET", url: wanted.url, headers: wanted.headers ?? [] }));
37 return new Request("https://runner/rpc/broker", { method: "POST", headers, body: options.body });
38}
39
40type Seen = { sent: Request[]; notes: string[] };
41
42function deps(answer: (request: Request) => Response, secrets: BrokeredSecret[] | null = [NPM]): RelayDeps & { seen: Seen } {
43 const seen: Seen = { sent: [], notes: [] };
44 return {
45 seen,
46 async secrets(_job, token) {
47 return token === "current" ? secrets : null;
48 },
49 async note(_job, _token, text) {
50 seen.notes.push(text);
51 },
52 async send(request) {
53 seen.sent.push(request);
54 return answer(request);
55 },
56 };
57}
58
59function answerOf(response: Response): { status: number; headers: [string, string][] } {
60 return JSON.parse(atob(response.headers.get(ANSWER_HEADER) ?? ""));
61}
62
63test("a request carrying a stand-in for its host is filled in, sent, and its answer scrubbed", async () => {
64 const d = deps(
65 (request) =>
66 new Response(`{"token":"${request.headers.get("authorization")?.slice(7)}"}`, {
67 status: 201,
68 headers: { "content-type": "application/json", "x-echo": "npm_realvalue123", "content-encoding": "gzip" },
69 }),
70 );
71 const response = await relay(
72 brokerRequest({ url: "https://registry.npmjs.org/pkg", headers: [["authorization", `Bearer ${NPM.standIn}`], ["connection", "keep-alive"]] }),
73 d,
74 );
75 assert.equal(response.status, 200);
76 assert.equal(d.seen.sent.length, 1);
77 const sent = d.seen.sent[0];
78 assert.equal(sent.headers.get("authorization"), "Bearer npm_realvalue123");
79 assert.equal(sent.headers.get("connection"), null, "the connection's own headers go no further");
80 assert.equal(sent.redirect, "manual");
81 const answer = answerOf(response);
82 assert.equal(answer.status, 201);
83 assert.deepEqual(Object.fromEntries(answer.headers)["x-echo"], NPM.standIn);
84 assert.equal(Object.fromEntries(answer.headers)["content-encoding"], undefined, "the body is relayed decoded");
85 const body = await response.text();
86 assert.equal(body, `{"token":"${NPM.standIn}"}`);
87 assert.ok(!JSON.stringify(answer).includes(NPM.value) && !body.includes(NPM.value));
88 assert.deepEqual(d.seen.notes, []);
89});
90
91test("a body is relayed, and one past the limit is refused", async () => {
92 const d = deps(async () => new Response(null, { status: 204 }));
93 const ok = await relay(
94 brokerRequest({ method: "PUT", url: "https://registry.npmjs.org/pkg", headers: [["authorization", `Bearer ${NPM.standIn}`]] }, { body: "tarball" }),
95 d,
96 );
97 assert.equal(answerOf(ok).status, 204);
98 assert.equal(await d.seen.sent[0].text(), "tarball");
99 const big = new Uint8Array(MAX_REQUEST_BYTES + 1);
100 const tooBig = await relay(
101 brokerRequest({ method: "PUT", url: "https://registry.npmjs.org/pkg", headers: [["authorization", `Bearer ${NPM.standIn}`]] }, { body: big }),
102 d,
103 );
104 assert.equal(tooBig.status, 413);
105 assert.equal(d.seen.sent.length, 1, "nothing more was sent");
106});
107
108test("it is no open proxy: no stand-in, another host, or plain HTTP is refused and noted", async () => {
109 const d = deps(() => new Response("never"));
110 const none = await relay(brokerRequest({ url: "https://registry.npmjs.org/pkg" }), d);
111 assert.equal(none.status, 403);
112 assert.equal(none.headers.get(ANSWER_HEADER), null);
113 const elsewhere = await relay(brokerRequest({ url: "https://evil.example/steal", headers: [["authorization", `Bearer ${NPM.standIn}`]] }), d);
114 assert.equal(elsewhere.status, 403);
115 const plain = await relay(brokerRequest({ url: "http://registry.npmjs.org/pkg", headers: [["authorization", `Bearer ${NPM.standIn}`]] }), d);
116 assert.equal(plain.status, 403);
117 assert.equal(d.seen.sent.length, 0);
118 assert.equal(d.seen.notes.length, 3);
119 assert.match(d.seen.notes[0], /without a stand-in/);
120 assert.match(d.seen.notes[1], /NPM_TOKEN was sent to evil\.example/);
121 // Once per secret and host.
122 await relay(brokerRequest({ url: "https://evil.example/again", headers: [["authorization", `Bearer ${NPM.standIn}`]] }), d);
123 assert.equal(d.seen.notes.length, 3);
124});
125
126test("only the job's current token opens it, and it must come as a header", async () => {
127 const d = deps(() => new Response("never"));
128 const wanted = { url: "https://registry.npmjs.org/pkg", headers: [["authorization", `Bearer ${NPM.standIn}`]] as [string, string][] };
129 assert.equal((await relay(brokerRequest(wanted, { token: "spent" }), d)).status, 401);
130 assert.equal((await relay(brokerRequest(wanted, { token: "" }), d)).status, 401);
131 assert.equal((await relay(brokerRequest(wanted, { header: "not base64 json" }), d)).status, 400);
132 assert.equal((await relay(brokerRequest({ ...wanted, method: "CONNECT" }), d)).status, 400);
133 assert.equal(d.seen.sent.length, 0);
134});
135
136test("a request has a time limit", async () => {
137 const d = deps(() => new Response("ok"));
138 await relay(brokerRequest({ url: "https://registry.npmjs.org/pkg", headers: [["authorization", `Bearer ${NPM.standIn}`]] }), d);
139 assert.ok(d.seen.sent[0].signal instanceof AbortSignal);
140});
141
142test("an answer past the limit is refused by its length, or cut off as it streams", async () => {
143 const d = deps(() => new Response("x", { headers: { "content-length": String(2 * 1024 * 1024 * 1024) } }));
144 const response = await relay(brokerRequest({ url: "https://registry.npmjs.org/pkg", headers: [["authorization", `Bearer ${NPM.standIn}`]] }), d);
145 assert.equal(response.status, 502);
146 const cut = new Response(new Blob(["12345"]).stream().pipeThrough(capped(3)));
147 await assert.rejects(cut.text());
148});
149
150test("a value split across chunks is still scrubbed, and other bytes pass as they are", async () => {
151 const pairs = [{ from: new TextEncoder().encode("secret"), to: new TextEncoder().encode("STAND") }];
152 const one = replaceBytes(new TextEncoder().encode("a secret here"), pairs, true);
153 assert.equal(new TextDecoder().decode(one.out), "a STAND here");
154 // The last bytes that could begin a value wait for the next chunk.
155 const held = replaceBytes(new TextEncoder().encode("xx sec"), pairs, false);
156 assert.equal(new TextDecoder().decode(held.out), "x");
157 assert.equal(new TextDecoder().decode(held.rest), "x sec");
158
159 const chunks = ["{\"t\":\"npm_real", "value123\",\"u\":\"npm_", "realvalue123\"}"];
160 const source = new ReadableStream<Uint8Array>({
161 start(controller) {
162 for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk));
163 controller.close();
164 },
165 });
166 const text = await new Response(source.pipeThrough(scrubStream([NPM]))).text();
167 assert.equal(text, `{"t":"${NPM.standIn}","u":"${NPM.standIn}"}`);
168});
169
170test("text bodies are scrubbed whatever their size; binary ones stream as they are", () => {
171 assert.ok(scrubbedType(new Headers({ "content-type": "application/json; charset=utf-8" })));
172 assert.ok(scrubbedType(new Headers({ "content-type": "text/html" })));
173 assert.ok(scrubbedType(new Headers({ "content-type": "application/vnd.api+json" })));
174 assert.ok(scrubbedType(new Headers()));
175 assert.ok(!scrubbedType(new Headers({ "content-type": "application/octet-stream" })));
176 assert.ok(!scrubbedType(new Headers({ "content-type": "application/gzip" })));
177});
178
179test("the request reads only in its own shape, and answer headers keep each cookie", async () => {
180 assert.deepEqual(readRelayed(b64({ method: "get", url: "https://a.example/", headers: [["a", "1"]] })), {
181 method: "GET",
182 url: "https://a.example/",
183 headers: [["a", "1"]],
184 });
185 assert.equal(readRelayed(b64({ method: "GET", url: "https://a.example/", headers: [["a", 1]] })), null);
186 assert.equal(readRelayed(null), null);
187 const headers = new Headers({ "content-type": "text/plain", "transfer-encoding": "chunked" });
188 headers.append("set-cookie", "a=1");
189 headers.append("set-cookie", "b=2");
190 assert.deepEqual(answerHeaders(headers), [
191 ["content-type", "text/plain"],
192 ["set-cookie", "a=1"],
193 ["set-cookie", "b=2"],
194 ]);
195 assert.deepEqual(await readCapped(new Blob(["abc"]).stream(), 3), new TextEncoder().encode("abc"));
196 assert.equal(await readCapped(new Blob(["abcd"]).stream(), 3), null);
197});