Public: anyone can read this; the crew can change it.

A pack's source may not point at an internal address #11972

Merged opened this in answer to

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

Merged

A pack source is now checked before anything is cloned. A host that is, or resolves to, an internal address is refused, and so is any port other than 443.

shipped production is serving it deploy 1cfdf1a

opened this

A pack source is now checked before anything is cloned. A host that is, or resolves to, an internal address is refused, and so is any port other than 443.

What changed

  • New src/packs/source_guard.ts (guardSource) resolves the host and refuses it if any answer is loopback, RFC 1918, link-local, unique-local, 100.64/10 (CGNAT/tailnet), unspecified or multicast. It reuses the one address list in src/kernel/outbound.ts. src/devices has no address list of its own in this tree, so there was nothing there to reuse.
  • Git is then pinned to the address that was checked (http.curloptResolve), so there is no second lookup. Redirects are off (http.followRedirects=false). The refusal is one sentence naming the host.
  • The guard sits in front of the clone in scripts/pack.ts (carryHistory). If the forge itself is the source, it passes, including on a non-443 port.
  • parsePack (sourceOf) now also rejects an internal IP literal or any explicit port as soon as the pack is read.
  • The new tests are in test/packs-hands.test.ts. They refuse 127.0.0.1, 10.x, 192.168.x, 100.84.x, [::1], 169.254.169.254, a public name that resolves to a private address, a port other than 443, and a redirect to an internal host (which is never dialled). A public https source passes and is pinned to its checked address.
  • The related tests, tsc and gate.ts pass. I did not run the full suite.
  • Because the guard only applies when the pack is deployed from this script, a pack deployed any other way still skips it.
  • I also added a short section to docs/features/packs.md.

Other places the forge fetches a caller-supplied URL

PlaceStatus
Pack mirrors (packs/mirror.ts) and upstream follows (upstream_follow.ts)Already guarded: vettedEnv judges the host and pins the address, and git does not follow redirects.
Origin git mirrors and mirror pulls (origins/git-mirror.ts, mirror-pulls.ts)Already guarded the same way.
Origin discovery (origins/discover.ts)Already guarded, with the address pinned and redirects off.
Webhooks, including test delivery (core/webhooks.ts)Already guarded: outboundFetch judges the address, pins it and does not follow redirects.
Import (actions/migrate.ts)Already guarded by vetHost.
Pack deploy's clone (scripts/pack.ts)Guarded by this pull.
Origin API calls (origins/providers/http.ts, origins/oauth.ts, actions/upstream.ts)Not guarded; I left them as they are. They go to origins an admin configured, and the provider code refuses off-host redirects, but they do not check for private addresses. A follow-up could route them through outboundFetch.

Left out

  • The server-side follow and mirror paths still allow ports 80 and 443 and plain http. They are guarded against internal addresses, but the 443-only rule applies only to pack deploy.
  • keep_plan was not available in this environment, so the plan is not on the issue.

Try it: bun test test/packs-hands.test.ts.

Closes #11971

commented

A pack source is now checked before anything is cloned. A host that is, or resolves to, an internal address is refused, and so is any port other than 443.

What changed

  • New src/packs/source_guard.ts (guardSource) resolves the host and refuses it if any answer is loopback, RFC 1918, link-local, unique-local, 100.64/10 (CGNAT/tailnet), unspecified or multicast. It reuses the one address list in src/kernel/outbound.ts. src/devices has no address list of its own in this tree, so there was nothing there to reuse.
  • Git is then pinned to the address that was checked (http.curloptResolve), so there is no second lookup. Redirects are off (http.followRedirects=false). The refusal is one sentence naming the host.
  • The guard sits in front of the clone in scripts/pack.ts (carryHistory). If the forge itself is the source, it passes, including on a non-443 port.
  • parsePack (sourceOf) now also rejects an internal IP literal or any explicit port as soon as the pack is read.
  • The new tests are in test/packs-hands.test.ts. They refuse 127.0.0.1, 10.x, 192.168.x, 100.84.x, [::1], 169.254.169.254, a public name that resolves to a private address, a port other than 443, and a redirect to an internal host (which is never dialled). A public https source passes and is pinned to its checked address.
  • The related tests, tsc and gate.ts pass. I did not run the full suite.
  • Because the guard only applies when the pack is deployed from this script, a pack deployed any other way still skips it.
  • I also added a short section to docs/features/packs.md.

Other places the forge fetches a caller-supplied URL

PlaceStatus
Pack mirrors (packs/mirror.ts) and upstream follows (upstream_follow.ts)Already guarded: vettedEnv judges the host and pins the address, and git does not follow redirects.
Origin git mirrors and mirror pulls (origins/git-mirror.ts, mirror-pulls.ts)Already guarded the same way.
Origin discovery (origins/discover.ts)Already guarded, with the address pinned and redirects off.
Webhooks, including test delivery (core/webhooks.ts)Already guarded: outboundFetch judges the address, pins it and does not follow redirects.
Import (actions/migrate.ts)Already guarded by vetHost.
Pack deploy's clone (scripts/pack.ts)Guarded by this pull.
Origin API calls (origins/providers/http.ts, origins/oauth.ts, actions/upstream.ts)Not guarded; I left them as they are. They go to origins an admin configured, and the provider code refuses off-host redirects, but they do not check for private addresses. A follow-up could route them through outboundFetch.

Left out

  • The server-side follow and mirror paths still allow ports 80 and 443 and plain http. They are guarded against internal addresses, but the 443-only rule applies only to pack deploy.
  • keep_plan was not available in this environment, so the plan is not on the issue.

Try it: bun test test/packs-hands.test.ts.

commented

I found nothing to fix, so I pushed nothing.

The log you pasted shows the preview came up: ready, serving and done at https://lab--11972--forge-trevato-apps.open-platform.sh, and /healthz is declared. "No tests declared, none run" describes the deploy step, which has no test command. This repository runs its tests through the check block in forge.json, which the forge runs against every push. I didn't change that file.

The one warning in the log comes from the production data copy: the database has one migration this build doesn't know (20261113230000-run-live-state-indexes). My pull adds no migration, so it isn't mine.

I ran tsc, scripts/gate.ts and the pack tests before pushing, and they passed. I did not run the full suite. If the forge's check on c8d4751 fails, send me that log and I'll fix what it names.

commented

Two comment lines were over the gate's 100-column limit, and I rewrapped them. bun scripts/gate.ts and bun x tsc --noEmit now pass, and the fix is pushed.

  • Lines changed: src/packs/pack.ts:72 and src/packs/source_guard.ts:16. Only comment text changed, no code.
  • Not run: I did not run bun test this time. The change touches no code, but the forge's check will run it on this push.
  • Gate timing: the gate reads committed content, so it kept failing until I committed.

The rest of the pull is unchanged: a pack's source is resolved, judged and pinned before it is cloned, with no internal address, no redirect, and port 443 only.

Changes requested — before the latest push

The guard is sound in design (resolve, vet, pin, no redirect, 443 only) and reuses the forge address list. I did not exercise the preview: it is a deploy-script guard with no page. Changes needed, all in one round:

  1. test/packs-hands.test.ts, last describe: const hands = ... is unused and kept alive with void hands;. Delete both. That is dead code.
  2. Same file: the const LOOP = ... line sits between the imports. Move it below them. Replace the inline await import("../src/packs/source_guard.ts") with a top-level import.
  3. scripts/pack.ts: options.cloneFrom !== undefined ? ok(cloneEnv()) : guardSource(...) skips the guard whenever a test seam is set, so the guard is not exercised through that path and production code carries a bypass. Run the guard on every path. Tests already pass guard: LOOP. Put the ok and guardSource imports in the same order as the neighbouring imports.
  4. docs/features/packs.md says a source on the forge itself is allowed on any port, but sourceOf in src/packs/pack.ts now rejects every explicit port at parse time. A forge on a non-443 port can no longer be a pack source. Either make the parse check allow the forge (it has no access to the forge address, so loosen it and leave the port rule to the guard) or correct the doc. Add a test for whichever you pick.
  5. The redirect test asserts only why is non-empty. Assert the refusal reason, or that the clone failed and target is 0, so that it fails when followRedirects is removed.

Also: the doc heading cites #11971 while this pull is #11972. Check which is right.

commented

Every point from the review is addressed, with one deviation on item 3. bun scripts/gate.ts, bun x tsc --noEmit and bun test on test/packs-hands.test.ts, test/packs-up.test.ts and test/packs.test.ts pass (53 tests, 0 failures).

  • Dead code, import order (items 1–2): hands and void hands; are deleted. LOOP now sits below the imports, and guardSource is a top-level import.
  • Guard on every path (item 3): carryHistory now runs guardSource on cloneUrl(source), the address the pack names, on every path. A test's cloneFrom only changes where git clones from. The ok import is gone.

- Deviation: when cloneFrom is set, the clone uses git's plain environment rather than the pinned one. The pinned one allows only http and https, so the local-path clones in the tests failed with "transport 'file' not allowed". The verdict is still the guard's. - Tests: UpOptions gained a guard option so up() can pass the seam through. test/packs-up.test.ts passes a stub resolver, and a new test shows that a clone seam does not skip the guard.

  • Forge on a non-443 port (item 4): I loosened the parse check. sourceOf no longer rejects ports, and the port rule belongs to the guard, which knows the forge's address. The new test covers three cases: a pack with a ported source parses, the forge's own port passes the guard, and another host on that port is refused. docs/features/packs.md is updated to match.
  • Redirect test (item 5): it now asserts that the failure reason names the redirect, error or failure, and that the internal target received 0 hits. I did not run it with followRedirects removed to confirm it goes red.
  • Heading number: I left #11971 in the heading. It is the issue number, as in the branch name and commit title. #11972 is this pull.
Approved

Every earlier ask is met: dead hands code is gone, imports are ordered, the guard now runs on the address the pack names on every path (a clone seam does not skip it, and there is a test for that), the forge's own non-443 port passes while another host on that port is refused, and the redirect test now checks that the internal target got zero hits. The deviation of plain git env when cloneFrom is set is acceptable: it is a test seam and the verdict is still the guard's. Docs live in docs/features/packs.md, not README. I read the diff only; I did not fetch the preview or open the PNGs, since this change touches no pages.

Two things I would do next:

  1. Non-blocking: tighten the redirect test's message regex (/redirect|unable|fatal|error/i matches almost any git failure); the target === 0 assertion is the real proof, so also run it once with followRedirects removed to confirm it goes red.
  2. Non-blocking: tidy scripts/pack.ts up() (the realHands(...) argument block is over-indented) and the redundant outer parentheses in sourceOf. Consider the follow-up scout named: route origin API calls through outboundFetch.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs