Public: anyone can read this; the crew can change it.
An app inside a child forge reaches a payment provider when its forge.json declares it #11147
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11142-an-app-inside-a-child-forge-reaches-a-pa → main 4 commits fork 2 threads · 2 unresolved
8 files
changed +105
−7
docs/APPS.md +5 −1
| @@ -588,7 +588,11 @@ Two more fields say what the app's code may use (rule 9): | ||
| 588 | 588 | names, or `*.` for every name below one, at most 32, each reachable on 80 |
| 589 | 589 | and 443 — `db.example.com:5432` names one other port instead. Without it an app |
| 590 | 590 | reaches the forge and nothing else; install, test and check steps also |
| 591 | reach `registry.npmjs.org`. `limits` (MiB, cores, MiB) default to 512 MiB, | |
| 591 | reach `registry.npmjs.org`. In a child forge the cell is held to a ceiling | |
| 592 | (parent's model, registry and payment settings): `api.stripe.com` on 443 | |
| 593 | (`children.paymentEgress`, on by default) is reachable there only by an app | |
| 594 | whose `forge.json` `egress` names it; an app that does not, and the child's | |
| 595 | crew sandbox, cannot reach it. `limits` (MiB, cores, MiB) default to 512 MiB, | |
| 592 | 596 | 1 core and 1 GiB for an app and 2 GiB, 2 cores and 4 GiB for a step, and |
| 593 | 597 | are capped by the machine (`FORGE_CELL_MEMORY_MAX_MIB`, default 4096; the |
| 594 | 598 | number of cores; `FORGE_CELL_DISK_MAX_MIB`, default 16384). A cell's `/tmp` |
docs/features/child-model-egress.md +6
| @@ -21,3 +21,9 @@ Each host is named exactly, on port 443 alone, with no wildcards. It is enforced | ||
| 21 | 21 | ## Cells inside a child |
| 22 | 22 | |
| 23 | 23 | A child forge opens cells of its own for its checks, apps and install steps. Those cells' proxy cannot dial out, since the child's cell has no route but its own proxy, so it tunnels through that proxy with `CONNECT` (`upstream` in `src/apps/egress.ts`). A host opens only when both policies allow it: the step's own egress, and the child cell's list above. Names are resolved and checked by the child cell's proxy. |
| 24 | ||
| 25 | ## Payment providers | |
| 26 | ||
| 27 | `children.paymentEgress` (`FORGE_CHILDREN_PAYMENT_EGRESS`) is `stripe` by default: a child's cell may reach `api.stripe.com` on 443, so an app a customer builds can take a payment. It is only the cell's ceiling: a cell the child opens for an app passes the host only when that app's `forge.json` `egress` declares it, and the crew's sandbox declares none. `off` closes it. Applies from the next child cell started. | |
| 28 | ||
| 29 | Payment hosts are enforce-only in every cell's own proxy (`decide` in `src/apps/egress.ts`): even when the child forge's `cells.egress` is `report`, an app that does not declare `api.stripe.com` is refused it. |
src/apps/egress.ts +5 −2
| @@ -20,7 +20,7 @@ | ||
| 20 | 20 | import { lookup } from "node:dns/promises"; |
| 21 | 21 | import { isIP } from "node:net"; |
| 22 | 22 | import type { Socket } from "bun"; |
| 23 | import { neverGrantable } from "../core/egress_grants.ts"; | |
| 23 | import { neverGrantable, PAYMENT_HOSTS } from "../core/egress_grants.ts"; | |
| 24 | 24 | import { during } from "../perf/jobs.ts"; |
| 25 | 25 | |
| 26 | 26 | export interface EgressPolicy { |
| @@ -409,7 +409,10 @@ export async function decide( | ||
| 409 | 409 | // ports alone; any other port, and any private address (below), is |
| 410 | 410 | // refused in both modes. |
| 411 | 411 | if (!declared && literalPrivate) return { ok: false, why: askAdmin, policy: { private: true } }; |
| 412 | if (!declared && (policy.report === undefined || !WEB_PORTS.includes(port))) | |
| 412 | // A payment host is never let through by report mode: only an app that | |
| 413 | // names it reaches it, whatever the admin's setting (#11142). | |
| 414 | const payment = PAYMENT_HOSTS.includes(`${name}:${port}`); | |
| 415 | if (!declared && (policy.report === undefined || payment || !WEB_PORTS.includes(port))) | |
| 413 | 416 | return { |
| 414 | 417 | ok: false, |
| 415 | 418 | why: `${name}:${port} is not in this repository's egress (forge.json "egress")`, |
src/core/capacity.ts +14
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | import type { Database } from "bun:sqlite"; |
| 15 | 15 | import { readFileSync } from "node:fs"; |
| 16 | 16 | import { availableParallelism, totalmem } from "node:os"; |
| 17 | import { PAYMENT_HOSTS } from "./egress_grants.ts"; | |
| 17 | 18 | |
| 18 | 19 | export type CapacityKey = |
| 19 | 20 | | "crew.concurrency" |
| @@ -26,6 +27,7 @@ export type CapacityKey = | ||
| 26 | 27 | | "cells.egress" |
| 27 | 28 | | "children.modelEgress" |
| 28 | 29 | | "children.registryEgress" |
| 30 | | "children.paymentEgress" | |
| 29 | 31 | | "children.ownFilesystem" |
| 30 | 32 | | "guard_new_accounts" |
| 31 | 33 | | "signin.edge" |
| @@ -122,6 +124,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 122 | 124 | fallback: "off", |
| 123 | 125 | help: "off keeps a child forge's cell from the package registries, so its checks cannot fill their install cache; npm also lets it reach registry.npmjs.org on 443; build opens the hosts ordinary app builds fetch from, on 443 alone: npm and yarn, PyPI, GitHub (git dependencies and release tarballs), crates.io and the Go module proxy. Nothing else opens, and the cell enforces this even when cells.egress is report. Takes effect on the next child cell started.", |
| 124 | 126 | }, |
| 127 | "children.paymentEgress": { | |
| 128 | label: "Payment provider a child forge cell may reach", | |
| 129 | env: "FORGE_CHILDREN_PAYMENT_EGRESS", | |
| 130 | kind: { type: "choice", values: ["off", "stripe"] }, | |
| 131 | fallback: "stripe", | |
| 132 | help: "stripe lets a child forge's cell reach api.stripe.com on 443, so an app a customer builds there can take a payment; off closes it. Only an app whose forge.json egress names the host reaches it: an app that does not, and the child's crew sandbox, get none of it. Nothing else opens, and the cell enforces this even when cells.egress is report. Takes effect on the next child cell started.", | |
| 133 | }, | |
| 125 | 134 | "children.ownFilesystem": { |
| 126 | 135 | label: "Child forges need a filesystem of their own", |
| 127 | 136 | // No environment default: refusing children is an admin's decision. |
| @@ -454,6 +463,11 @@ export function childRegistryHosts(db: Database): readonly string[] { | ||
| 454 | 463 | return value === "build" ? BUILD_HOSTS : value === "npm" ? ["registry.npmjs.org:443"] : []; |
| 455 | 464 | } |
| 456 | 465 | |
| 466 | /** The payment provider hosts a child forge cell may reach (`children.paymentEgress`); the cell's ceiling, each app still declares its own. */ | |
| 467 | export function childPaymentHosts(db: Database): readonly string[] { | |
| 468 | return capacityValue(db, "children.paymentEgress") === "stripe" ? PAYMENT_HOSTS : []; | |
| 469 | } | |
| 470 | ||
| 457 | 471 | /** `build`: where an ordinary app's build fetches its packages from, each named exactly, on 443. */ |
| 458 | 472 | export const BUILD_HOSTS: readonly string[] = [ |
| 459 | 473 | "registry.npmjs.org", |
src/core/egress_grants.ts +8
| @@ -51,6 +51,14 @@ export function neverGrantable(address: string): boolean { | ||
| 51 | 51 | return inCidrs(mapped?.[1] ?? address, refusedRanges()); |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | /** | |
| 55 | * The curated payment hosts, each named exactly, on 443 (#11142): the | |
| 56 | * ceiling a child forge's cell may open (`children.paymentEgress`), and | |
| 57 | * never let through by report mode in any cell, only by an app's own | |
| 58 | * `forge.json` `egress`. | |
| 59 | */ | |
| 60 | export const PAYMENT_HOSTS: readonly string[] = ["api.stripe.com:443"]; | |
| 61 | ||
| 54 | 62 | const NAME_RE = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; |
| 55 | 63 | |
| 56 | 64 | export interface Destination { |
src/spawn/cell.ts +5 −2
| @@ -23,7 +23,7 @@ import { hostedPlacement, parentOrigin, childPublicUrl, type BodyStore, type Pla | ||
| 23 | 23 | import { SIZE_IO, SIZES, type Size } from "./sizes.ts"; |
| 24 | 24 | import { floorRefusal } from "./disk_floor.ts"; |
| 25 | 25 | import { CGROUP_DIR_ENV, SIZE_ENV } from "./usage.ts"; |
| 26 | import { childModelHosts, childRegistryHosts } from "../core/capacity.ts"; | |
| 26 | import { childModelHosts, childPaymentHosts, childRegistryHosts } from "../core/capacity.ts"; | |
| 27 | 27 | import { liveChildDisk, type ChildDisk } from "./disk_mount.ts"; |
| 28 | 28 | |
| 29 | 29 | export const CELL_PREFIX = "cell-"; |
| @@ -120,6 +120,8 @@ export function cellSpecFor(input: { | ||
| 120 | 120 | modelHosts?: readonly string[]; |
| 121 | 121 | /** The package registry an admin let children reach (`children.registryEgress`); none by default. */ |
| 122 | 122 | registryHosts?: readonly string[]; |
| 123 | /** The payment hosts children may reach (`children.paymentEgress`): a ceiling only an app declaring the host passes. */ | |
| 124 | paymentHosts?: readonly string[]; | |
| 123 | 125 | /** |
| 124 | 126 | * The child's writable space as found (`spawn/disk_mount.ts`). A filesystem |
| 125 | 127 | * of its own is named among the binds by its real path (bwrap binds |
| @@ -140,7 +142,7 @@ export function cellSpecFor(input: { | ||
| 140 | 142 | hide: [input.parentDataDir, input.parentHome ?? process.env["HOME"] ?? "", tmpdir()].filter((path) => path !== ""), |
| 141 | 143 | // The forge's own code may live under the parent's data (a release checkout). |
| 142 | 144 | reveal: [CODE], |
| 143 | egress: [...(input.modelHosts ?? []), ...(input.registryHosts ?? [])], | |
| 145 | egress: [...(input.modelHosts ?? []), ...(input.registryHosts ?? []), ...(input.paymentHosts ?? [])], | |
| 144 | 146 | // A child's cell is held to this list whatever cells.egress says: report |
| 145 | 147 | // mode is for apps still declaring hosts, not for a whole forge. |
| 146 | 148 | egressMode: "enforce", |
| @@ -262,6 +264,7 @@ export function launchSpecFor( | ||
| 262 | 264 | name: input.name, |
| 263 | 265 | modelHosts: childModelHosts(parent.db), |
| 264 | 266 | registryHosts: childRegistryHosts(parent.db), |
| 267 | paymentHosts: childPaymentHosts(parent.db), | |
Nothing fails if this line is removed: test it through launchSpecFor. |
||
| 265 | 268 | disk: liveChildDisk(parent.config.dataDir, input.bodyId, mountinfo), |
| 266 | 269 | }); |
| 267 | 270 | } |
test/egress-upstream.test.ts +28
| @@ -109,3 +109,31 @@ test("the upstream is the cell's own loopback proxy, or nothing", () => { | ||
| 109 | 109 | expect(upstreamProxy({ HTTPS_PROXY: "https://127.0.0.1:3128" })).toBeUndefined(); |
| 110 | 110 | expect(upstreamProxy({ HTTPS_PROXY: "http://u:[email protected]:3128" })).toBeUndefined(); |
| 111 | 111 | }); |
| 112 | ||
| 113 | test("a payment host passes a child's inner proxy only when the app declares it, in enforce and in report mode", async () => { | |
| 114 | const connect = "CONNECT api.stripe.com:443 HTTP/1.1\r\nHost: api.stripe.com:443\r\n\r\n"; | |
| 115 | for (const report of [false, true]) { | |
| 116 | const outerPort = freeish(); | |
| 117 | const innerPort = outerPort + 1; | |
| 118 | // The outer cell allows nothing, so a request that gets past the inner policy is named by the outer's refusal. | |
| 119 | const outer = startEgress({ tcp: outerPort }, { hosts: [], origins: [] }); | |
| 120 | const upstream = { host: "127.0.0.1", port: outerPort }; | |
| 121 | const seen: string[] = []; | |
| 122 | const policy = (hosts: string[]) => ({ hosts, origins: [], upstream, ...(report ? { report: (h: string) => void seen.push(h) } : {}) }); | |
| 123 | const declared = startEgress({ tcp: innerPort }, policy(["api.stripe.com"])); | |
| 124 | const undeclared = startEgress({ tcp: innerPort + 1 }, policy([])); | |
| 125 | try { | |
| 126 | expect(await through(innerPort, connect)).toContain("not reachable from this forge's cell"); | |
| 127 | const refused = await through(innerPort + 1, connect); | |
| 128 | expect(refused).toStartWith("HTTP/1.1 403"); | |
| 129 | expect(refused).toContain("not in this repository's egress"); | |
| 130 | expect(seen).toEqual([]); | |
| 131 | // Report mode still lets another undeclared public host reach the outer proxy. | |
| 132 | if (report) expect(await through(innerPort + 1, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")).toContain("not reachable from this forge's cell"); | |
| 133 | } finally { | |
| 134 | declared.close(); | |
| 135 | undeclared.close(); | |
| 136 | outer.close(); | |
| 137 | } | |
| 138 | } | |
| 139 | }); |
test/spawn-cell.test.ts +34 −2
| @@ -7,7 +7,7 @@ import { answerFromCell, closeHostedChildren, resolveHostedChild } from "../src/ | ||
| 7 | 7 | import { createApp } from "../src/app.ts"; |
| 8 | 8 | import { childEnv } from "../src/apps/process.ts"; |
| 9 | 9 | import { watchedLimits } from "../src/apps/contain.ts"; |
| 10 | import { childLog, plainLine, cellChildren, crewAtWork, stopCellChildren, stopGently, cellEnv, cellLimits, cellPlacement, cellSpecFor, forward, ownsBody } from "../src/spawn/cell.ts"; | |
| 10 | import { childLog, plainLine, cellChildren, crewAtWork, stopCellChildren, stopGently, cellEnv, cellLimits, cellPlacement, cellSpecFor, forward, launchSpecFor, ownsBody } from "../src/spawn/cell.ts"; | |
| 11 | 11 | import { fallbackPlacement, hostedPlacement, memoryBodies } from "../src/spawn/placement.ts"; |
| 12 | 12 | import type { SpawnPlan } from "../src/spawn/plan.ts"; |
| 13 | 13 | import { SIZES } from "../src/spawn/sizes.ts"; |
| @@ -17,7 +17,7 @@ import { portInsteadOfSocket } from "../src/git/hook-address.ts"; | ||
| 17 | 17 | import { missing } from "./helpers/tools.ts"; |
| 18 | 18 | import { resultLine } from "./helpers/result-line.ts"; |
| 19 | 19 | import { hostAllowed } from "../src/apps/egress.ts"; |
| 20 | import { childModelHosts, childRegistryHosts, setCapacity } from "../src/core/capacity.ts"; | |
| 20 | import { childModelHosts, childPaymentHosts, childRegistryHosts, setCapacity } from "../src/core/capacity.ts"; | |
| 21 | 21 | import { openBeatDoor, parentLocal } from "../src/spawn/beat-door.ts"; |
| 22 | 22 | import { lastHeard, sendBeat } from "../src/spawn/heartbeat.ts"; |
| 23 | 23 | import { failedWith } from "./helpers/machine-note.ts"; |
| @@ -538,3 +538,35 @@ describe("a child forge told to go", () => { | ||
| 538 | 538 | expect(proc.signalCode).toBe("SIGKILL"); |
| 539 | 539 | }); |
| 540 | 540 | }); |
| 541 | ||
| 542 | describe("a child forge's apps reaching a payment provider", () => { | |
| 543 | test("api.stripe.com on 443 alone by default, enforced even in report mode, closed by off", () => { | |
| 544 | const app = createApp({ dataDir: join(tmp, "payment-egress"), url: "https://f.test" }); | |
| 545 | try { | |
| 546 | expect(childPaymentHosts(app.db)).toEqual(["api.stripe.com:443"]); | |
| 547 | setCapacity(app.db, "cells.egress", "report", "system"); | |
| 548 | const spec = cellSpecFor({ dir: "/d/c", parentDataDir: "/d", parentUrl: "https://f.test", size: "small", port: 4100, label: "forge:kid", paymentHosts: childPaymentHosts(app.db) }); | |
| 549 | expect(spec.egressMode).toBe("enforce"); | |
| 550 | expect(hostAllowed(spec.egress, "api.stripe.com", 443)).toBe(true); | |
| 551 | expect(hostAllowed(spec.egress, "api.stripe.com", 80)).toBe(false); | |
| 552 | expect(hostAllowed(spec.egress, "evil.api.stripe.com", 443)).toBe(false); | |
| 553 | expect(hostAllowed(spec.egress, "example.com", 443)).toBe(false); | |
| 554 | setCapacity(app.db, "children.paymentEgress", "off", "system"); | |
| 555 | expect(childPaymentHosts(app.db)).toEqual([]); | |
| 556 | } finally { | |
| 557 | app.close(); | |
| 558 | } | |
| 559 | }); | |
| 560 | test("the launch path carries the setting: Stripe in the child's egress by default, gone once off", () => { | |
| 561 | const app = createApp({ dataDir: join(tmp, "payment-launch"), url: "https://f.test" }); | |
| 562 | try { | |
| 563 | const input = { bodyId: "cell-pay1", name: "kid", size: "small" } as const; | |
| 564 | const at = { port: 4100, parentPort: 4101 }; | |
| 565 | expect(launchSpecFor(app, input, at, "").egress).toContain("api.stripe.com:443"); | |
| 566 | setCapacity(app.db, "children.paymentEgress", "off", "system"); | |
| 567 | expect(launchSpecFor(app, input, at, "").egress).not.toContain("api.stripe.com:443"); | |
| 568 | } finally { | |
| 569 | app.close(); | |
| 570 | } | |
| 571 | }); | |
| 572 | }); |
egress.ts should not import the DB-aware capacity module for a constant; put PAYMENT_HOSTS in a dependency-free module.