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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11142-an-app-inside-a-child-forge-reaches-a-pa → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/11142-an-app-inside-a-child-forge-reaches-a-pa was merged into main as b52996f.

Review

Sign in to review this change.
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):
588588 names, or `*.` for every name below one, at most 32, each reachable on 80
589589 and 443 — `db.example.com:5432` names one other port instead. Without it an app
590590 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,
592596 1 core and 1 GiB for an app and 2 GiB, 2 cores and 4 GiB for a step, and
593597 are capped by the machine (`FORGE_CELL_MEMORY_MAX_MIB`, default 4096; the
594598 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
2121 ## Cells inside a child
2222
2323 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 @@
2020 import { lookup } from "node:dns/promises";
2121 import { isIP } from "node:net";
2222 import type { Socket } from "bun";
23 import { neverGrantable } from "../core/egress_grants.ts";
23 import { neverGrantable, PAYMENT_HOSTS } from "../core/egress_grants.ts";
2424 import { during } from "../perf/jobs.ts";
outdated

egress.ts should not import the DB-aware capacity module for a constant; put PAYMENT_HOSTS in a dependency-free module.

2525
2626 export interface EgressPolicy {
@@ -409,7 +409,10 @@ export async function decide(
409409 // ports alone; any other port, and any private address (below), is
410410 // refused in both modes.
411411 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)))
413416 return {
414417 ok: false,
415418 why: `${name}:${port} is not in this repository's egress (forge.json "egress")`,
src/core/capacity.ts +14
@@ -14,6 +14,7 @@
1414 import type { Database } from "bun:sqlite";
1515 import { readFileSync } from "node:fs";
1616 import { availableParallelism, totalmem } from "node:os";
17 import { PAYMENT_HOSTS } from "./egress_grants.ts";
1718
1819 export type CapacityKey =
1920 | "crew.concurrency"
@@ -26,6 +27,7 @@ export type CapacityKey =
2627 | "cells.egress"
2728 | "children.modelEgress"
2829 | "children.registryEgress"
30 | "children.paymentEgress"
2931 | "children.ownFilesystem"
3032 | "guard_new_accounts"
3133 | "signin.edge"
@@ -122,6 +124,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
122124 fallback: "off",
123125 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.",
124126 },
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 },
125134 "children.ownFilesystem": {
126135 label: "Child forges need a filesystem of their own",
127136 // No environment default: refusing children is an admin's decision.
@@ -454,6 +463,11 @@ export function childRegistryHosts(db: Database): readonly string[] {
454463 return value === "build" ? BUILD_HOSTS : value === "npm" ? ["registry.npmjs.org:443"] : [];
455464 }
456465
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
457471 /** `build`: where an ordinary app's build fetches its packages from, each named exactly, on 443. */
458472 export const BUILD_HOSTS: readonly string[] = [
459473 "registry.npmjs.org",
src/core/egress_grants.ts +8
@@ -51,6 +51,14 @@ export function neverGrantable(address: string): boolean {
5151 return inCidrs(mapped?.[1] ?? address, refusedRanges());
5252 }
5353
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
5462 const NAME_RE = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
5563
5664 export interface Destination {
src/spawn/cell.ts +5 −2
@@ -23,7 +23,7 @@ import { hostedPlacement, parentOrigin, childPublicUrl, type BodyStore, type Pla
2323 import { SIZE_IO, SIZES, type Size } from "./sizes.ts";
2424 import { floorRefusal } from "./disk_floor.ts";
2525 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";
2727 import { liveChildDisk, type ChildDisk } from "./disk_mount.ts";
2828
2929 export const CELL_PREFIX = "cell-";
@@ -120,6 +120,8 @@ export function cellSpecFor(input: {
120120 modelHosts?: readonly string[];
121121 /** The package registry an admin let children reach (`children.registryEgress`); none by default. */
122122 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[];
123125 /**
124126 * The child's writable space as found (`spawn/disk_mount.ts`). A filesystem
125127 * of its own is named among the binds by its real path (bwrap binds
@@ -140,7 +142,7 @@ export function cellSpecFor(input: {
140142 hide: [input.parentDataDir, input.parentHome ?? process.env["HOME"] ?? "", tmpdir()].filter((path) => path !== ""),
141143 // The forge's own code may live under the parent's data (a release checkout).
142144 reveal: [CODE],
143 egress: [...(input.modelHosts ?? []), ...(input.registryHosts ?? [])],
145 egress: [...(input.modelHosts ?? []), ...(input.registryHosts ?? []), ...(input.paymentHosts ?? [])],
144146 // A child's cell is held to this list whatever cells.egress says: report
145147 // mode is for apps still declaring hosts, not for a whole forge.
146148 egressMode: "enforce",
@@ -262,6 +264,7 @@ export function launchSpecFor(
262264 name: input.name,
263265 modelHosts: childModelHosts(parent.db),
264266 registryHosts: childRegistryHosts(parent.db),
267 paymentHosts: childPaymentHosts(parent.db),

Nothing fails if this line is removed: test it through launchSpecFor.

265268 disk: liveChildDisk(parent.config.dataDir, input.bodyId, mountinfo),
266269 });
267270 }
test/egress-upstream.test.ts +28
@@ -109,3 +109,31 @@ test("the upstream is the cell's own loopback proxy, or nothing", () => {
109109 expect(upstreamProxy({ HTTPS_PROXY: "https://127.0.0.1:3128" })).toBeUndefined();
110110 expect(upstreamProxy({ HTTPS_PROXY: "http://u:[email protected]:3128" })).toBeUndefined();
111111 });
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/
77 import { createApp } from "../src/app.ts";
88 import { childEnv } from "../src/apps/process.ts";
99 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";
1111 import { fallbackPlacement, hostedPlacement, memoryBodies } from "../src/spawn/placement.ts";
1212 import type { SpawnPlan } from "../src/spawn/plan.ts";
1313 import { SIZES } from "../src/spawn/sizes.ts";
@@ -17,7 +17,7 @@ import { portInsteadOfSocket } from "../src/git/hook-address.ts";
1717 import { missing } from "./helpers/tools.ts";
1818 import { resultLine } from "./helpers/result-line.ts";
1919 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";
2121 import { openBeatDoor, parentLocal } from "../src/spawn/beat-door.ts";
2222 import { lastHeard, sendBeat } from "../src/spawn/heartbeat.ts";
2323 import { failedWith } from "./helpers/machine-note.ts";
@@ -538,3 +538,35 @@ describe("a child forge told to go", () => {
538538 expect(proc.signalCode).toBe("SIGKILL");
539539 });
540540 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs