Public: anyone can read this; the crew can change it.
A pack's source may not point at an internal address #11972
Check · Review · Land · Live done — Landed and live in production.
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
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 insrc/kernel/outbound.ts.src/deviceshas 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,
tscandgate.tspass. 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
| Place | Status |
|---|---|
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_planwas not available in this environment, so the plan is not on the issue.
Try it: bun test test/packs-hands.test.ts.
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.
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:72andsrc/packs/source_guard.ts:16. Only comment text changed, no code. - Not run: I did not run
bun testthis 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.
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:
- test/packs-hands.test.ts, last describe:
const hands = ...is unused and kept alive withvoid hands;. Delete both. That is dead code. - Same file: the
const LOOP = ...line sits between the imports. Move it below them. Replace the inlineawait import("../src/packs/source_guard.ts")with a top-level import. - 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 passguard: LOOP. Put theokandguardSourceimports in the same order as the neighbouring imports. - docs/features/packs.md says a source on the forge itself is allowed on any port, but
sourceOfin 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. - The redirect test asserts only
whyis non-empty. Assert the refusal reason, or that the clone failed andtargetis 0, so that it fails whenfollowRedirectsis removed.
Also: the doc heading cites #11971 while this pull is #11972. Check which is right.
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):
handsandvoid hands;are deleted.LOOPnow sits below the imports, andguardSourceis a top-level import. - Guard on every path (item 3):
carryHistorynow runsguardSourceoncloneUrl(source), the address the pack names, on every path. A test'scloneFromonly changes where git clones from. Theokimport 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.
sourceOfno 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.mdis 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
followRedirectsremoved to confirm it goes red. - Heading number: I left
#11971in the heading. It is the issue number, as in the branch name and commit title.#11972is this pull.
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:
- Non-blocking: tighten the redirect test's message regex (
/redirect|unable|fatal|error/imatches almost any git failure); thetarget === 0assertion is the real proof, so also run it once withfollowRedirectsremoved to confirm it goes red. - Non-blocking: tidy
scripts/pack.tsup()(therealHands(...)argument block is over-indented) and the redundant outer parentheses insourceOf. Consider the follow-up scout named: route origin API calls throughoutboundFetch.
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
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 insrc/kernel/outbound.ts.src/deviceshas no address list of its own in this tree, so there was nothing there to reuse.http.curloptResolve), so there is no second lookup. Redirects are off (http.followRedirects=false). The refusal is one sentence naming the host.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.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.tscandgate.tspass. I did not run the full suite.docs/features/packs.md.Other places the forge fetches a caller-supplied URL
packs/mirror.ts) and upstream follows (upstream_follow.ts)vettedEnvjudges the host and pins the address, and git does not follow redirects.origins/git-mirror.ts,mirror-pulls.ts)origins/discover.ts)core/webhooks.ts)outboundFetchjudges the address, pins it and does not follow redirects.actions/migrate.ts)vetHost.scripts/pack.ts)origins/providers/http.ts,origins/oauth.ts,actions/upstream.ts)outboundFetch.Left out
keep_planwas not available in this environment, so the plan is not on the issue.Try it:
bun test test/packs-hands.test.ts.Closes #11971