Merge branch 'runner-egress-reset'
4 files+34−110/4 viewed
| 606 | 606 | the toolchains' download sites (`nodejs.org`, `go.dev`, | |
| 607 | 607 | `static.rust-lang.org`), and the public container registries (Docker Hub, | |
| 608 | 608 | GitHub's, Quay, and `mirror.gcr.io`, the mirror of Docker Hub that a job's | |
| 609 | − | Engine asks first). A request anywhere else gets `403` with | |
| 610 | − | the reason. To reach another host, someone with the Maintain [role](/guides/access-and-roles/) or | |
| 609 | + | Engine asks first). A request anywhere else gets `403`, and its status | |
| 610 | + | line names the host (`403 Forbidden by g1t guardrails: example.com is not | |
| 611 | + | an allowed domain`), so even a tool that reports only the status says which | |
| 612 | + | host to allow. To reach another host, someone with the Maintain [role](/guides/access-and-roles/) or | |
| 611 | 613 | higher adds it to the project's | |
| 612 | 614 | allowed domains under **Settings → Guardrails**; a project whose guardrails | |
| 613 | 615 | turn the network restriction off runs its jobs with an open network. |
| 70 | 70 | and HTTPS (443) has any route out: SSH, raw TCP and UDP connections fail. | |
| 71 | 71 | - Every HTTP and HTTPS request it makes is handed to g1t's runner Worker | |
| 72 | 72 | before it leaves. The Worker forwards requests to allowed hosts and | |
| 73 | − | answers every other with `403` and a line saying the host is not allowed | |
| 74 | − | and where to allow it. | |
| 73 | + | answers every other with `403`. The refusal names the host three times | |
| 74 | + | over, since tools differ in what they show of a failed request: in its | |
| 75 | + | status line (`403 Forbidden by g1t guardrails: example.com is not an | |
| 76 | + | allowed domain`, which is what a tool that prints only the status shows), | |
| 77 | + | in an `x-g1t-blocked-host` header beside `x-g1t-guardrails: blocked`, and | |
| 78 | + | in a plain-text body saying where to allow it: **Allowed domains** for | |
| 79 | + | every sandbox, or **Workflow-only domains** for the jobs of one workflow | |
| 80 | + | and environment. | |
| 75 | 81 | - To see the host of an HTTPS request, the connection is re-encrypted with | |
| 76 | 82 | a certificate the sandbox is given when it starts, which g1t adds to the | |
| 77 | 83 | sandbox's trusted certificates (and points Node.js, Python, curl, Cargo |
| 50 | 50 | const response = refusal("Evil.com:443"); | |
| 51 | 51 | assert.equal(response.status, 403); | |
| 52 | 52 | assert.equal(response.headers.get("x-g1t-guardrails"), "blocked"); | |
| 53 | + | assert.equal(response.headers.get("x-g1t-blocked-host"), "evil.com"); | |
| 54 | + | // Tools that print only the status line still name the host. | |
| 55 | + | assert.equal(response.statusText, "Forbidden by g1t guardrails: evil.com is not an allowed domain"); | |
| 53 | 56 | const text = await response.text(); | |
| 54 | − | assert.match(text, /evil\.com is not on this project's allowed domains/); | |
| 55 | − | assert.match(text, /Settings, Guardrails/); | |
| 57 | + | assert.match(text, /^g1t guardrails refused evil\.com: it is not on this project's allowed domains/); | |
| 58 | + | assert.match(text, /Settings, Guardrails: under Allowed domains/); | |
| 59 | + | assert.match(text, /Workflow-only domains/); | |
| 56 | 60 | assert.equal(blockedStep("Evil.com"), "Blocked: evil.com (not an allowed domain)"); | |
| 57 | 61 | }); | |
| 58 | 62 |
| 74 | 74 | return [...hosts]; | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | − | /** What a refused request gets back, so the agent knows why. */ | |
| 77 | + | /** | |
| 78 | + | * What a refused request gets back. Many tools show only the status line | |
| 79 | + | * of a failed request (a GraphQL client prints its reason phrase, a | |
| 80 | + | * package manager "HTTP 403: Forbidden"), so the reason phrase names the | |
| 81 | + | * host too, and the body and a header say the rest. | |
| 82 | + | */ | |
| 78 | 83 | export function refusal(host: string): Response { | |
| 79 | − | const name = normalizeHost(host); | |
| 84 | + | // A reason phrase is printable ASCII; a host from a URL already is. | |
| 85 | + | const name = normalizeHost(host).replace(/[^\x21-\x7e]/g, ""); | |
| 80 | 86 | return new Response( | |
| 81 | − | `g1t guardrails: ${name} is not on this project's allowed domains, so this sandbox cannot reach it. ` + | |
| 82 | − | "A member can allow it under the project's Settings, Guardrails.\n", | |
| 83 | − | { status: 403, headers: { "content-type": "text/plain; charset=utf-8", "x-g1t-guardrails": "blocked" } }, | |
| 87 | + | `g1t guardrails refused ${name}: it is not on this project's allowed domains, so this sandbox cannot reach it. ` + | |
| 88 | + | "A member can allow it in the project's Settings, Guardrails: under Allowed domains for every sandbox, " + | |
| 89 | + | "or under Workflow-only domains for the jobs of one workflow and environment.\n", | |
| 90 | + | { | |
| 91 | + | status: 403, | |
| 92 | + | statusText: `Forbidden by g1t guardrails: ${name} is not an allowed domain`, | |
| 93 | + | headers: { "content-type": "text/plain; charset=utf-8", "x-g1t-guardrails": "blocked", "x-g1t-blocked-host": name }, | |
| 94 | + | }, | |
| 84 | 95 | ); | |
| 85 | 96 | } | |
| 86 | 97 |