| 1 | /** |
| 2 | * The outbound handler's side of brokered secrets (broker.ts): `egress` |
| 3 | * (guard.ts) asks `brokered` once for each request to a host the sandbox |
| 4 | * may reach, before forwarding it itself. |
| 5 | */ |
| 6 | import type { OutboundHandlerContext } from "@cloudflare/containers"; |
| 7 | |
| 8 | import type { ServiceBinding } from "@g1t/contracts"; |
| 9 | |
| 10 | import { type BrokeredJob, brokerFor, fill, scrub, scrubHeaders, scrubbable, standInsIn } from "./broker.ts"; |
| 11 | import { sandboxNamespace } from "./egress.ts"; |
| 12 | import { forward } from "./forward.ts"; |
| 13 | |
| 14 | /** |
| 15 | * What the outbound handler is given for brokering: `open` for a job whose |
| 16 | * network is open but which may be given brokered secrets (every host is |
| 17 | * reached, as it would be), and for a workflow job, the job, whose |
| 18 | * stand-ins it fills in. |
| 19 | */ |
| 20 | export type BrokeredParams = { open?: boolean; job?: BrokeredJob }; |
| 21 | |
| 22 | /** |
| 23 | * Sends a workflow job's request with its brokered secrets filled in: a |
| 24 | * stand-in becomes the real value on a request to one of its secret's |
| 25 | * hosts, and the answer has the value swapped back should it echo it. |
| 26 | * Null when there is nothing to fill in, and `egress` forwards the |
| 27 | * request as it is. A filled-in request is not followed through |
| 28 | * redirects here, so the value never goes wherever a redirect points: the |
| 29 | * job follows it itself, and that request comes through here again. A |
| 30 | * stand-in sent to a host it is not for goes as it is, and the job's log |
| 31 | * says so once. |
| 32 | */ |
| 33 | export async function brokered( |
| 34 | request: Request, |
| 35 | env: { SANDBOX: DurableObjectNamespace; ACTIONS?: ServiceBinding }, |
| 36 | ctx: OutboundHandlerContext<BrokeredParams>, |
| 37 | ): Promise<Response | null> { |
| 38 | const job = ctx.params?.job; |
| 39 | if (!job || !env.ACTIONS) return null; |
| 40 | const wanted = standInsIn(request.url, request.headers); |
| 41 | if (wanted.length === 0) return null; |
| 42 | const secrets = await brokerFor(env.ACTIONS, job, wanted); |
| 43 | const { url, headers, filled, misdirected } = fill(request.url, request.headers, secrets); |
| 44 | for (const secret of misdirected) { |
| 45 | try { |
| 46 | const namespace = sandboxNamespace(env, ctx.className); |
| 47 | const sandbox = namespace.get(namespace.idFromString(ctx.containerId)) as unknown as { |
| 48 | noteMisdirected(name: string, host: string): Promise<void>; |
| 49 | }; |
| 50 | await sandbox.noteMisdirected(secret.name, new URL(request.url).host); |
| 51 | } catch (error) { |
| 52 | console.log("misdirected secret not reported", secret.name, String(error)); |
| 53 | } |
| 54 | } |
| 55 | if (filled.length === 0) return null; |
| 56 | const response = await forward(new Request(url, { method: request.method, headers, body: request.body, redirect: "manual" })); |
| 57 | const answered = scrubHeaders(response.headers, filled); |
| 58 | const init = { status: response.status, statusText: response.statusText, headers: answered }; |
| 59 | if (!scrubbable(response.headers)) return new Response(response.body, init); |
| 60 | answered.delete("content-length"); |
| 61 | return new Response(scrub(await response.text(), filled), init); |
| 62 | } |