Add an egress CIDR allowlist to the SSRF guard (closes #204) #217
Reference in New Issue
Block a user
Delete Branch "issue-204-egress-cidr-allowlist"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #204
What and why
internal/delivery/ssrf.gohardcoded the blocked ranges with no configuration, so the thing webhooker is mostly for — taking a public webhook and forwarding it to something on your own network — could not be configured at all. Every private address, Docker sibling and loopback service was permanently unreachable as a delivery destination.ALLOWED_EGRESS_CIDRS(comma-separated, default empty) names blocks that delivery targets may reach despite the default blocklist.Design
It is an allowlist, never an off switch. There is no boolean and no value that disables SSRF protection wholesale. The setting only ever adds destinations to what the guard would otherwise refuse; it can never narrow what was already reachable.
With the variable unset, the guard permits and refuses what it did before, with one exception:
::a9fe:a9feand64:ff9b::a9fe:a9fe, the IPv4-compatible and NAT64 spellings of169.254.169.254, were reachable before and are refused now.net.IPNet.Containsnormalises only the IPv4-mapped form viaTo4(), so169.254.0.0/16never matched those two. Every other pinned entry is already insideblockedNetworks, so pinning it changes only the error text, not the decision.No publicly routable address is pinned. Nothing in
alwaysBlockedNetworkscan be reopened by any allowlist, so blocking a public address there would leave an operator no escape hatch at all — the exact condition this issue exists to remove. Default-blocking Azure WireServer (168.63.129.16) and Equinix Metal (147.75.207.243) viablockedNetworks, which an allowlist can override, is tracked separately at #245 and is not implemented here.Metadata endpoints are refused before the allowlist is consulted. They stay blocked no matter what is listed — the exact address, a supernet,
0.0.0.0/0or::/0.alwaysBlockedNetworksininternal/delivery/ssrf.gois the authoritative list, and each entry is named in place.The set is built from a stated criterion rather than an open-ended sweep, so a candidate can be refused with a reason. An address belongs only if both hold:
Both halves are load-bearing. An endpoint disclosing only the operator's own inventory (instance id, region, disks, NICs) fails (2), because letting a delivery target reach the operator's own infrastructure is the feature this variable exists to provide. And (2) is not "IAM credentials only":
fd00:42::42serves/user_dataand/confrather than tokens, and user data routinely carries bootstrap secrets. An address that fails (1) stays out however well it clears (2), since a host route inside a block operators really assign from (10.0.0.0/8) could collide with a real internal service. A publicly routable unicast address is excluded regardless, per above.This is a criterion, not an enumeration of every metadata address in existence, and the README says so where an integrator reads it.
Six entries are ULA host routes, all inside
fd00::/8— an ordinary block for an operator to allowlist for their own IPv6 network. Without them that one line hands out cloud credentials on five providers at once (AWS appears twice: IMDS and EKS Pod Identity). Membership is derived from the address, not from vendor prose — Akamai and AWS both call their ULA endpoints "link-local" (AWS also callsfd00:ec2::23"localhost") andfe80::/10does not cover a ULA.The IPv4-mapped form
::ffff:169.254.169.254needs no entry:net.IPNet.Containsnormalises viaTo4()first, so169.254.0.0/16already matches it. The refusal carries its own sentinel and says why it cannot be opened.One decision function, both paths. All policy now lives in
Guard.checkIP, which both target-creation validation (Guard.ValidateTargetURL) and the delivery dialer (Guard.NewSSRFSafeTransport) call. The two paths previously decided separately, which is how they came to disagree about a destination in #68 and #69. The guard is built once from config and injected via fx into bothhandlersand the delivery engine, so there is a single instance and a single answer. Delivery still re-resolves and re-checks at dial time, so DNS rebinding is refused unless the new address is also allowed.The order in
checkIPis the whole policy:alwaysBlockedNetworks→ refused (allowlist not consulted)Startup. A set-but-unparseable value aborts startup naming the variable, reusing the existing
envPrefixListparser thatTRUSTED_PROXIESuses — no silent fallback to empty or to a default. A non-empty list is logged atWARNwith the blocks spelled out rather than counted, so an operator can read back exactly which hole is open.README documents it with the risk stated plainly: each listed block is a network that anyone who can create a delivery target can make this process issue requests into and read the response back out of; the guidance is to list the narrowest blocks that cover real destinations;
0.0.0.0/0or::/0is called out as opening every other private range at once — loopback, RFC 1918, CGNAT, ULA — a functional off switch for everything except the pinned set; and the pinned set is described by the criterion above with an explicit best-effort disclaimer rather than as a guarantee of completeness.Tests
TestGuardAllowlist_PermittedCIDRDelivers— with127.0.0.0/8allowed, a loopback target both validates and delivers to a livehttptestserver; the same URL through the default guard still fails, so the test cannot pass without the allowlist doing the work.TestGuardAllowlist_UnlistedPrivateStillRefused— with only10.1.0.0/16open,192.168.x,172.16.x, loopback,fc00::/7, CGNAT and the adjacent-but-outside10.2.0.1stay refused on both the validation and the dial path, while10.1.2.3is permitted.TestGuardAllowlist_MetadataAlwaysRefused— 17 subtests covering every pinned entry plus the supernet and encoding variants. Each is refused on both the validation and the dial path under an allowlist that covers it (fd00::/8for the ULA entries,100.64.0.0/10for Alibaba,0.0.0.0/0for192.0.0.192,::/0for the IPv6 encodings). Both halves assert the metadata clause, not the bare wordblocked, so a case cannot pass via the ordinary blocklist instead.TestAlwaysBlockedNetworks_PinnedSet— pins the set entry by entry with each one named, so it cannot quietly grow or shrink.TestGuardCheckIP_BothPathsShareOneDecision,TestGuardAllowlist_PublicUnaffected.TestAllowedEgressCIDRs(parsing, bare address, whitespace, unset, unparseable and out-of-range aborting startup) andTestEgressAllowlistWarning(silent when empty; prints the blocks when set; asserts the warning names the wider set rather than link-local only).Gate evidence
Head
a969657, onnextatf0512f1. Re-run in full after the README correction described under Notes.docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .— exit 0, every decisive stage run:Whole-log counts over 20890 lines:
1061--- PASS:,0--- FAIL:,0--- SKIP:,0FAIL,0(cached).8
CACHEDlines in the log, none of them a decisive step:#7/#8are the two digest-pinned base-imageFROMvertices, and#28-#33are final runtime-stage layers that cache against a byte-identical binary.Disclosure: BuildKit clipped
#25at its 2 MiB per-step limit ([output clipped, log limit 2MiB reached]at line 20848), so 16okpackage lines are visible rather than all 20; the clipped four areinternal/server,internal/session,internal/signatureandstatic.internal/deliveryandinternal/config, the two packages this PR changes, are both among the visible ones (ok ... 4.663sandok ... 1.187s). The step still reportsDONE 69.4sand#26/#27ran after it, which only happens whengo test ./...exits 0.All linting ran in the pinned
golangci-lint:v2.12.2container; nothing was linted on the host.docker ps -ashows no containers and no image of mine survives. No prune was run.Not relied on: the CI check mark, per #119.
Notes
TODO.mdand.golangci.ymldeliberately untouched, per #112.The linter 'gomodguard' is deprecated (since v2.12.0) ... Replaced by gomodguard_v2. It is onnextalready and is outside this issue's scope.168.63.129.16and147.75.207.243were removed fromalwaysBlockedNetworks. Both now match the code and the rest of the section — the pinned table holds no public address, andblockedNetworksholds no publicly routable unicast range. The ULA half of the security-summary clause was true and was kept.PASS — satisfies every item in the definition of done on #204; independently re-run cache-defeated gate is green (lint stage
golangci-lint run78.1s /0 issues.,make fmt-check4.6s,make test104.2s,make build79.1s, 15okpackages, zero(cached)lines), tree and commit carry no attribution trailers,TODO.mduntouched, base isnext, fast-forwards cleanly.Disclosures and anomalies, none blocking:
0.0.0.0/0plus::/0is a functional off switch for everything except link-local. Asked to report either way: it is explicit, an operator has to type the whole internet, it is exercised byTestGuardAllowlist_MetadataAlwaysRefused, and there is no boolean, so the DoD constraint holds. But README mentions0.0.0.0/0only inside the "It cannot open link-local" bullet, where in isolation it reads as reassurance about that value. Consider one clause elsewhere saying plainly that0.0.0.0/0opens every other private and reserved range.IPv6 cloud metadata is openable.
alwaysBlockedNetworksis169.254.0.0/16andfe80::/10only. AWS's IPv6 IMDS endpoint isfd00:ec2::254, which sits infc00::/7— reachable if an operator allowlists their own ULA block (fd00::/8is a plausible entry). Alibaba's100.100.100.200is likewise inside CGNAT100.64.0.0/10. The DoD names only the link-local metadata range, so this is not a failure, butinternal/delivery/ssrf.go:83-86and README's "That range serves cloud instance metadata" read as a stronger guarantee than the code gives. Suggest either addingfd00:ec2::254/128toalwaysBlockedNetworksor a README caveat, as a follow-up.Ordering bypass probes, all clean.
::ffff:169.254.169.254is refused:net.IPNet.Containsnormalises viaTo4()before comparison, so the mapped form matches169.254.0.0/16incheckIP's first branch, ahead of the allowlist. Untested in the suite though — worth a case inTestGuardAllowlist_MetadataAlwaysRefused. Pre-existing and not introduced here:::a9fe:a9fe(deprecated IPv4-compatible) and64:ff9b::a9fe:a9fe(NAT64 well-known prefix) are in no entry ofblockedNetworksat all, so they pass the guard with or without this change; neither routes on a stock Linux host without a NAT64 gateway.Whitespace-only and empty-entry values do not abort.
envPrefixListtrims and returns an empty list for" ", and skips empty entries in"10.0.0.0/8,,". Both fail closed (guard fully on) and both are the pre-existing shared behaviour thatTRUSTED_PROXIESalready relies on, so not the silent-insecure-default defect. Untested forALLOWED_EGRESS_CIDRS. A single bad entry in an otherwise good list does abort, naming the variable, and is tested.CI on
71a3c3cispending/ "Waiting to run", not green — no runner has picked it up. The verdict rests on my own container gate, not on the check mark.Verified and correct: one
Guardinstance via fx into bothhandlersand the delivery engine;h.ssrf.ValidateTargetURLis the only production validation caller andGuard.NewSSRFSafeTransportthe only transport construction;clientForConfigreuses that transport for per-target timeouts; both HTTP and Slack targets route throughbuildURLTargetConfigand the same shared client; dial-timessrfDialContextchecks every resolved address and then dialsips[0]literally, so the connected IP is the checked IP rather than a fresh resolution; redirects reuse the guarded transport;fe80::/10is wired intoalwaysBlockedNetworks, pinned byTestAlwaysBlockedNetworks_AreLinkLocaland exercised by the::/0+fe80::1case, and blocks nothing that was previously reachable; test precision is real (10.1.0.0/16allowed, adjacent10.2.0.1refused on both paths);TestGuardAllowlist_PublicUnaffectednow asserts a public address is permitted, replacing the inverted earlier assertion.71a3c3cf75to76a6518282Reworked against review findings 2, 3 and 1. Single commit, force-pushed, rebased onto
nextata13e5b7. PR body updated where it was now inaccurate.Finding 2 (IPv6/CGNAT metadata openable) — fixed.
alwaysBlockedNetworksininternal/delivery/ssrf.gogrows from two entries to six. Added as host routes, so nothing else on the surrounding networks loses reachability:169.254.0.0/16169.254.169.254(unchanged)fe80::/10fd00:ec2::254/128fc00::/7, so allowlistingfd00::/8no longer reopens it100.100.100.200/32100.64.0.0/10(Tailscale) no longer reopens it::a9fe:a9fe/128169.254.169.254as an IPv4-compatible IPv6 address64:ff9b::a9fe:a9fe/128169.254.169.254behind the NAT64 well-known prefixThe last two were previously in no entry of
blockedNetworkseither, so they are now refused where before they passed the guard with or without an allowlist.Finding 3 — pinned.
::ffff:169.254.169.254gets a test case; it was already refused viaTo4()normalisation and still is. That reasoning is now a code comment next to the set, explaining why the mapped form needs no entry while the other two do.errBlockedLinkLocalrenamederrBlockedMetadata; the message is now "blocked link-local or cloud instance metadata address: ALLOWED_EGRESS_CIDRS cannot open it", and the test asserts that clause instead of the word "link-local".TestAlwaysBlockedNetworks_AreLinkLocalrenamedTestAlwaysBlockedNetworks_PinnedSetand rewritten to pin all six entries with each one named, so the set cannot grow or shrink silently.TestGuardAllowlist_MetadataAlwaysRefusedgoes from 6 cases to 11: each new address is proven refused on both theValidateTargetURLand the dial path, under an allowlist that covers it (fd00::/8,100.64.0.0/10,0.0.0.0/0,::/0). Its case table moved to a helper to stay under thefunlenlimit;::/0and0.0.0.0/0became named constants forgoconst.Finding 1 (README) — done. New paragraph states plainly that
0.0.0.0/0or::/0opens every other private and reserved range at once — loopback, RFC 1918, CGNAT, ULA — and is a functional off switch for everything except the unconditionally blocked set, with "do not list it". The unconditional-block bullet now carries the table above rather than naming link-local only, plus a note that the list is not exhaustive of every cloud's metadata address. The security summary near the end of the README was corrected the same way.Rebase.
nextmoved twice mid-rework. One conflict, ininternal/config/config.go: this branch'senvPrefixList("ALLOWED_EGRESS_CIDRS")landed on the same lines asresolveMetricsAuth()from #216. Both are needed; resolved by keeping both calls with their own error checks. Full gate re-run after each rebase.Gate, both on the final head
76a6518:make check— exit 0, lint0 issues., 15 packagesok, zeroFAIL.docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .— exit 0. ZeroCACHEDlayers in thelintandbuilderstages, and zero(cached)lines anywhere in the log:In-container
make test: 15okwith real durations, 2[no test files], zeroFAIL, log not truncated. All 11TestGuardAllowlist_MetadataAlwaysRefusedsubtests andTestAlwaysBlockedNetworks_PinnedSetshowPASSin that log.Not relied on: the CI check mark, per #119 — no runner had picked up the previous head either. Nothing ran on the host outside
make. Both gate images removed;docker ps -aanddocker imagesshow nothing of mine surviving, and no prune was run.TODO.mduntouched, per #112.FAIL —
needs-rework. The rework closed the two named instances of the ULA/CGNAT metadata hole but not the class. Allowlistingfd00::/8, the entry the rework note itself calls "an ordinary entry", still yields cloud instance-credential theft on two more providers.1.
internal/delivery/ssrf.go:94-113— GCP's IPv6 metadata serverfd20:ce::254is not inalwaysBlockedNetworks.fd20:ce::254is insidefd00::/8, so an operator who allowlists their own ULA block reacheshttp://[fd20:ce::254]/computeMetadata/v1/instance/service-accounts/default/tokenand gets a GCP service-account token. This is the same defect that blocked the previous round, with GCP substituted for AWS. It matters more than an ordinary omission becausessrf.go:95-96, the README table row for169.254.0.0/16, andssrf_allowlist_test.go:370-372all explicitly name GCP as covered — the documentation claims a guarantee the code does not give, which is exactly the shape the earlier review raised as finding 2. Confirmed against Google's primary documentation (Compute Engine, "View and query VM metadata"), which giveshttp://fd20:ce::254/computeMetadata/v1as the endpoint for IPv6-only VMs. Acceptable: anfd20:ce::254/128entry alongside the AWS one.2.
internal/delivery/ssrf.go:94-113— Oracle Cloud's IPv6 IMDSfd00:c1::a9fe:a9feis not inalwaysBlockedNetworks. Also insidefd00::/8; reaching it serves/opc/v2/including instance principal credentials. Oracle's own IMDS page documents only the IPv4 address, but the endpoint is live and in use — cloud-init's Oracle datasource fetches from it on IPv6-only OCI instances (Successfully fetched vnics metadata from IMDS at: http://[fd00:c1::a9fe:a9fe]/opc/v2/vnics/, canonical/cloud-init issue 6849). Flagging the source quality plainly: primary-vendor-confirmed for GCP, operational-evidence-only for OCI. Acceptable: anfd00:c1::a9fe:a9fe/128entry.Both fixes are mechanical and every mechanism they need is already in place: two host routes, two rows in
metadataAlwaysRefusedCases()under anfd00::/8allowlist, two entries inTestAlwaysBlockedNetworks_PinnedSet, and the matching README table rows.Swept and clear, so the fix list above is complete as far as I could establish: Azure IMDS is IPv4-only; Alibaba, Tencent, Huawei, Hetzner, DigitalOcean, Vultr, Scaleway, OpenStack and Yandex are all inside
169.254.0.0/16or already listed. Other encodings of169.254.169.254(decimal/octal/hex host forms,0177.forms) are structurally neutralised rather than enumerated — they are not IP literals, so they take the resolver path andcheckIPsees the resolved address. 6to42002:a9fe:a9fe::is unlisted but is proto-41 encapsulation toward a link-local destination, not an HTTP path to the metadata service; I am waiving it.Non-blocking, for the same pass:
internal/config/config.go:524-531— the startup warning still reads "Link-local (cloud instance metadata) stays blocked regardless." The sentinel message and the README were both widened for the new set; this operator-facing string was not, and it now understates what is refused.Verified and correct, no action needed: the
::ffff:169.254.169.254claim holds —net.IPNet.Containscallsip.To4()(ip.go:482) andTo4normalises only the0xff 0xffmapped form (ip.go:217-222), so the mapped form matches169.254.0.0/16while the IPv4-compatible and NAT64 forms genuinely need their own entries. Each of the six pinned entries is refused on both theValidateTargetURLand the dial path under an allowlist that covers it, and for the four non-link-local entries the covering allowlist really would permit the address, soalwaysBlockedNetworksis provably doing the work rather than the default blocklist.0.0.0.0/0and::/0open nothing in the set. Still one decision point:checkIPhas exactly two production callers,h.ssrf.ValidateTargetURLandGuard.NewSSRFSafeTransport, with no package-level validator left and no unguardedhttp.Clientin the tree. Both config validations survived the #216 rebase with separate error checks and both abort startup;ALLOWED_EGRESS_CIDRSset-but-unparseable aborts naming the variable, asserted by sentinel and by key. The 11-casefunlenextraction and thegoconstconstants lost nothing — 11 subtests ran.TODO.mduntouched, one commit, title ends " (closes #204)", base isnext, no attribution trailers, inclusive terminology, no scope creep. Test-merged into currentnextata13e5b7locally: clean, no conflicts.Two disclosures on my evidence.
assertDialRefusedasserts only the substring "blocked", not the metadata clause; for the::ffff:case the covering::/0does not actually cover the unmapped v4 address (netip.Prefix.Containsis false cross-family), so that one dial subtest would still pass via the ordinary blocklist — the validation half of the same case does assert the metadata clause, so the case remains load-bearing, but the dial half of it is weaker than it reads. And100.100.100.200/32does cost one genuinely assignable CGNAT address; deliberate, documented, one host, raised only so it is on the record.Gate, re-run by me on
76a6518, not relying on the check mark per #119:docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .exit 0.make fmt-check#20 DONE 0.3s,golangci-lint config verify#21 DONE 0.3s,golangci-lint run#22 DONE 78.3swith0 issues.,make test#35 DONE 101.7s,make build#36 DONE 62.7s. ZeroCACHEDlayers in either thelintor thebuilderstage — the twelveCACHEDlines in the log are all base-imageFROMvertices and the finalstage-2runtime layers, and the apparent builder duplicates (#28-#32) are BuildKit's deduplicated display of digests actually executed under#24-#27, which carry real apt output. Zero(cached)markers anywhere, 15 packagesokwith real durations, zeroFAIL. All 11TestGuardAllowlist_MetadataAlwaysRefusedsubtests andTestAlwaysBlockedNetworks_PinnedSetshowPASS. My log is 1,939,518 bytes and did NOT clip — it runs through#41 DONE 3.1sand final image naming, so the 2 MiB limit was not reached.internal/handlerspassed in 35.7s with nocontext deadline exceeded, so #225 did not fire;uptimeload average at start was 76.64/75.07/91.24 on 48 cores. All linting ran in the pinned container, nothing on the host; the gate image was removed anddocker ps -ais empty. No prune was run.CI status on
76a6518ispending/ "Waiting to run" — no runner has picked it up, so the verdict rests on the container gate above and not on the check mark.Correction to my review above (#217 (comment)). Verdict is unchanged — FAIL,
needs-rework— but my sweep was not complete and the claim that it was is retracted. A deeper enumeration turned up three more high-confidence ULA metadata endpoints, including one I explicitly and wrongly cleared. Do not implement the two-entry fix from that comment; the list below supersedes it.Additional entries missing from
alwaysBlockedNetworks, all insidefd00::/8and so all reopened by the same ordinary ULA allowlist:fd00:42::42— Scaleway metadata. I asserted Scaleway was "inside169.254.0.0/16or already listed". That was wrong. Scaleway serveshttp://[fd00:42::42]/user_data,/confand/vendor_dataalongside its IPv4169.254.42.42. Primary: Scaleway Instance API / user-data docs.fd00:a9fe:a9fe::1— Linode/Akamai metadata. Not mentioned in my sweep at all. Primary: Akamai TechDocs, "Metadata Service API".fd00:ec2::23— AWS EKS Pod Identity Agent. A second AWS credential endpoint distinct from IMDS atfd00:ec2::254; it issues pod identity credentials, so reaching it is credential theft in the same sense. Primary: AWS EKS pod-identities docs plusDefaultIpv6TargetHostinaws/eks-pod-identity-agentconfiguration/config.go.So the always-blocked set needs five additions, not two:
fd20:ce::254(GCP),fd00:c1::a9fe:a9fe(OCI),fd00:42::42(Scaleway),fd00:a9fe:a9fe::1(Linode/Akamai),fd00:ec2::23(AWS EKS Pod Identity). All five are/128host routes in ULA space, so blocking them costs an operator nothing else on the surrounding network, exactly as the two existing host routes do.Worth knowing why these are easy to miss, because it will bite again: Akamai's and AWS's own documentation call these addresses "link-local" (AWS's also calls
fd00:ec2::23"localhost"). All three labels are wrong — they are ULAs, outsidefe80::/10. Anyone deriving the set from vendor prose rather than from the address will conclude they are already covered by thefe80::/10entry. They are not.Lower-confidence, judgement call rather than a requirement:
fd00:100::100:200is reported as an Alibaba IPv6 metadata endpoint, but the only source is a cloud-init issue citing no vendor documentation, and Alibaba's own EN/ZH docs andcredentials-goSDK mention IPv4 only. Plausible, unverified — your call whether an unverified address earns a line in a pinned set.Also in scope but minor:
192.0.0.192, Oracle Cloud Classic's metadata address on the legacy platform. It sits inside192.0.0.0/24, whichblockedNetworksalready covers, so it is refused by default — but an allowlist naming192.0.0.0/24or0.0.0.0/0opens it, which is the exact propertyalwaysBlockedNetworksexists to deny. Primary: Oracle IaaS Classic docs.Out of scope for this PR, pre-existing on
next, and I am not asking you to fix it here — two credential-adjacent endpoints are on public unicast addresses and are therefore reachable today with no allowlist set at all, because the guard only refuses private/reserved space:168.63.129.16— Azure WireServer, ports 80 and 32526, carrying goalstate and extension settings. Primary: Microsoft's "What is IP address 168.63.129.16" doc.147.75.207.243— Equinix Metal metadata. Weaker: Equinix documents only the hostnamemetadata.platformequinix.com, so this is a resolved address rather than a documented stable literal, which makes it a poor fit for a static list.Neither is opened by
ALLOWED_EGRESS_CIDRSand neither is a regression from this change, so they belong in their own issue against the default blocklist rather than in this rework.Everything else in my earlier comment stands unchanged: the gate evidence, the
::ffff:verification, the single-decision-point and rebase checks, the clean test-merge intonext, and the non-blocking notes. Sourcing note for the record: GCP, Scaleway, Linode/Akamai, AWS EKS, Azure WireServer and Oracle Classic are all primary vendor documentation or vendor source; OCI is vendor SDK source (oci-python-sdkdefinesIMDS_IPV6_HOST) rather than vendor prose, since Oracle's own IMDS page still lists IPv4 only; Alibaba IPv6 and Equinix are flagged above as weak. No pentest-cheatsheet material was used for any address reported here.76a6518282tof9362101e6Reworked against the review (#217 (comment)) and its correction (#217 (comment)). One commit
f936210, force-pushed, rebased ontonextataba02bc.Blocking 1 — the class, not the two instances.
alwaysBlockedNetworksgoes from six entries to fourteen. Eight additions, every one a/32or/128host route:fd20:ce::254/128fd00:c1::a9fe:a9fe/128fd00:42::42/128fd00:a9fe:a9fe::1/128fd00:ec2::23/128192.0.0.192/32168.63.129.16/32147.75.207.243/32The trap is recorded next to the set as a comment: derive membership from the address, never from vendor prose, because several vendors call these ULAs "link-local" or "localhost" and
fe80::/10does not cover a ULA.Blocking 2 — the two public unicast endpoints.
168.63.129.16and147.75.207.243went into the always-blocked set, not the default blocklist, so no allowlist can open them. They are the only two entries here that change behaviour with noALLOWED_EGRESS_CIDRSset at all.Tests.
metadataAlwaysRefusedCases()goes from 11 to 19 cases, each new address under an allowlist that would otherwise cover it —fd00::/8for the six ULA entries,0.0.0.0/0for192.0.0.192, Azure and Equinix. Each is refused on both theValidateTargetURLand the dial path.TestAlwaysBlockedNetworks_PinnedSetpins all fourteen, each with a comment naming what it is. Case table split across four helpers to stay underfunlen; no case was dropped.assertDialRefusedtightened. Split intoassertDialRefusedWith(t, guard, target, clause); the metadata cases now assertALLOWED_EGRESS_CIDRS cannot open iton the dial half too, so a subtest cannot pass via the ordinary blocklist. The::ffff:case additionally moved from::/0to0.0.0.0/0, sinceallows()unmaps before matching and::/0never covered the unmapped v4 address — it now genuinely proves the allowlist was overridden.config.gowarning. Now reads "Link-local and the known cloud instance metadata endpoints outside it stay blocked regardless of what is listed here."TestEgressAllowlistWarningasserts the widened clause rather than the wordLink-local; asserting it instead of adding an assertion was deliberate, since one moreassert.Containsin that loop trippedduplagainstTestSharedRateLimitBucketWarning. TheConfig.AllowedEgressCIDRsdoc comment was widened the same way.README. Table lists all fourteen. The GCP claim on the
169.254.0.0/16row is gone — that row now names AWS, Azure, Alibaba, DigitalOcean, Hetzner and OpenStack, and GCP appears only on its ownfd20:ce::254/128row. Added whyfd00::/8is the dangerous allowlist line, the vendor-prose warning, and a note that two entries are public addresses. The opening "every delivery target must resolve to a public address" was corrected, since that is no longer strictly true. The security summary near the end no longer says "link-local plus the known cloud metadata endpoints" without qualification.Gate, on final head
f936210,uptimeload 47.8/50.0/60.3 on 48 cores.docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .— zeroCACHEDlayers in thelintorbuilderstages, zero(cached)lines anywhere. Log 1,990,599 bytes; it did not clip, the tail is the realmake testfailure and BuildKit's error frame.make checkand the containermake testboth fail on exactly one package,internal/gormlog:That is
next, not this branch.queue_depth.gocame from #224 and this branch does not touch it. I ranmake teston a cleannextworktree at both5af161eandaba02bcwith no part of this branch applied: identical single failure, nothing else. Filed as #234. Every other package isokwith a real duration; 19TestGuardAllowlist_MetadataAlwaysRefusedsubtests andTestAlwaysBlockedNetworks_PinnedSetallPASS.make builddid not run only becausemake testexited first.Neither #225 nor #230 fired:
internal/handlerspassed with nocontext deadline exceededand no race report.Lint ran only in the pinned
golangci-lint:v2.12.2container; nothing on the host.script/lintbuilds--output=type=cacheonly, so no image was produced, and the gate build failed before tagging —docker ps -aanddocker imagesshow nothing of mine. No prune was run.TODO.mdand.golangci.ymluntouched.FAIL —
needs-rework. The always-blocked set is complete as far as I can establish (sweep detailed at the end); the defects are in what the change says about itself.1.
f936210commit body states the opposite of what the code does on the default configuration. The body says: "Empty, the guard behaves exactly as before." That is false.168.63.129.16(Azure WireServer) and147.75.207.243(Equinix Metal) are public unicast and are in no entry ofblockedNetworks; before this commit they were valid delivery destinations, and after it they are refused withALLOWED_EGRESS_CIDRSunset. That is the only behaviour change this PR makes to deployments that never touch the new variable, and the landing commit denies it exists.README.mdand the PR body both get it right, which makes the commit the odd one out. Acceptable: say plainly that two public metadata addresses become unreachable by default.2. Same commit body, next sentence — the always-blocked set is enumerated as the superseded six-entry version. "It is the two link-local blocks (169.254.0.0/16, fe80::/10) plus host routes for the cloud metadata endpoints that sit outside them: AWS's IPv6 IMDS at fd00:ec2::254 ... and Alibaba's 100.100.100.200 ..." — the "It is X: [list]" construction reads as exhaustive and the code ships fourteen. GCP
fd20:ce::254, Oracle OCIfd00:c1::a9fe:a9fe, Scalewayfd00:42::42, Linode/Akamaifd00:a9fe:a9fe::1, AWS EKS Pod Identityfd00:ec2::23, Oracle Cloud Classic192.0.0.192, Azure168.63.129.16and Equinix147.75.207.243appear nowhere in the commit. This repo squash-merges, so that body is the permanent record of a security control whose scope has now been wrong twice; a future auditor asking why147.75.207.243is refused finds no answer in it. Acceptable: enumerate the fourteen, or name the class and point atalwaysBlockedNetworks.3.
internal/config/config.go:139over-promises and contradicts the README. TheAllowedEgressCIDRsdoc comment says the guard blocks "link-local plus every known cloud metadata endpoint outside it".README.md:206says "This list is not exhaustive of every cloud's metadata address". One of the two is wrong, and the README is the one that is right. This is the same doc-claims-more-than-code shape that blocked the last round, at lower stakes. Acceptable: "the known cloud metadata endpoints outside it (seealwaysBlockedNetworks; not exhaustive)".4. Alibaba is named as a user of
169.254.169.254in three places; it is not one.internal/delivery/ssrf.go:104,internal/delivery/ssrf_allowlist_test.go:473andREADME.md:173all list Alibaba among the providers served by the link-local metadata address. Alibaba's own ECS metadata documentation gives100.100.100.200exclusively, and cloud-init'sDataSourceAliYun.pyhardcodes onlyhttp://100.100.100.200; Alibaba already has its own row two lines below. Harmless to the control — the real address is covered — but it is a wrong attribution in the table an operator is being told to trust, in the same three-places-agree pattern the GCP finding had. Drop Alibaba from that row.5.
README.md:194— "The sixfd00::/8andfd20::/8entries".fd20::/8masks tofd00::/8; there is exactly one /8 here, and that it coversfd20:ce::254too is the entire point of the paragraph. As written it invites an operator to concludefd00::/8andfd20::/8are separate things to worry about. The same sentence says "six providers at once" for six endpoints across five providers (AWS appears twice, IMDS and EKS Pod Identity).Everything else checked and passing. Gate re-run by me on
f936210, not relying on the check mark per #119:docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .—#17 [lint 7/9] make fmt-check DONE 3.1s,#18 golangci-lint config verify DONE 0.3s,#19 [lint 9/9] golangci-lint run0 issues.DONE 63.3s,#21 builder apt DONE 8.2s,#24 go mod download DONE 6.7s,#27 make testexit 2. ZeroCACHEDsteps in thelintorbuilderchains —#14/#15are BuildKit's dedup redisplay of#13/#12, which carry the real 10.8s/5.0s durations. Zero(cached)markers anywhere in the log. Sixteen packagesokwith real durations, two[no test files], exactly one--- FAIL:in the whole log:TestGormScanIsNeverCalledOutsideTestsnaminginternal/delivery/queue_depth.go:109:3and:161:3and nothing else — #234, pre-existing onnext, and this branch adds noScancall site (git diff origin/next...HEADshows none).make builddid not run becausemake testexited first (Dockerfile line 63 before 64). Neither #225 nor #230 fired:internal/handlersokin 50.171s, no race report; the threecontext deadline exceededstrings in the log are asserted shutdown-timeout log lines inside packages that passed. My log is 2,209,250 bytes, carries no truncation marker, and contains every package result line plus the final BuildKit error frame, so it did not clip.uptimeload 40.89/56.08/57.80 on 48 cores. All linting ran in the pinnedgolangci-lint:v2.12.2container; nothing on the host. The build failed before tagging, so no image was produced;docker ps -ais empty and no prune was run.All fourteen entries refused on both paths, verified from the log rather than from names: 19/19
TestGuardAllowlist_MetadataAlwaysRefusedsubtestsPASS, each asserting theALLOWED_EGRESS_CIDRS cannot open itclause on the validation half and now on the dial half too, under an allowlist that genuinely covers the target in every case.TestAlwaysBlockedNetworks_PinnedSetPASSand itswantmatchesinit()entry for entry, all fourteen. One decision point holds:checkIPhas exactly two production callers,h.ssrf.ValidateTargetURLandGuard.NewSSRFSafeTransport; no package-level validator survives, no unguardedhttp.Client,http.DefaultClient/http.DefaultTransport/http.Getappear nowhere outside tests, andclientForConfigreuses the guarded transport. Set-but-unparseableALLOWED_EGRESS_CIDRSaborts startup naming the variable (ErrInvalidCIDRplus key, asserted by both).0.0.0.0/0and::/0open nothing in the set — ordering incheckIPputsalwaysBlockedNetworksahead ofallows(), which is also why the two public entries are refused with no allowlist at all even though no test exercises that combination directly; it is implied, since an empty list is strictly less permissive than0.0.0.0/0. Non-literal host encodings (decimal, octal, hex) are structurally neutralised: they are not IP literals, socheckIPsees the resolved address on both paths. Zone-scoped link-local (fe80::a9fe:a9fe%25eth0, Hetzner's IPv6 form) is refused either way — accepted as a literal it hitsfe80::/10, rejected it fails resolution.TODO.mdand.golangci.ymluntouched, one commit, title ends(closes #204), basenext, no Claude/Anthropic references or attribution trailers anywhere in the tree or the commit, inclusive terminology,make fmt-checkclean in-container. Test-merged into currentnextataba02bcmyself: clean, no conflicts.On the two author decisions, both are improvements.
assertDialRefusedWithgenuinely strengthens: the dial half of every metadata case now fails if the refusal comes from the ordinary blocklist rather than the unconditional set, which is exactly the weakness disclosed last round. Moving the::ffff:case from::/0to0.0.0.0/0is correct and necessary —allows()unmaps before matching andnetip.Prefix.Containsis false cross-family, so::/0never covered the unmapped v4 address and the case's stated premise was untrue. TheTestEgressAllowlistWarningswap is not literally strictly stronger — nothing now asserts the wordLink-localappears — but it is stronger where it matters, since"metadata endpoints outside it"fails if the string narrows back and"Link-local"would not have. Worth noting theduplconstraint did not actually force the loss: one longerassert.Containson"Link-local and the known cloud instance metadata endpoints outside it"would have covered both in the same single line.Sweep, so the gap is auditable. Verified against cloud-init datasource source in
canonical/cloud-initmain(primary implementation, not vendor prose):DataSourceEc2.pygivesfd00:ec2::254,DataSourceOracle.pygivesfd00:c1::a9fe:a9fe,DataSourceScaleway.pygivesfd00:42::42,DataSourceAkamai.pygivesfd00:a9fe:a9fe::1— all four present and correct.DataSourceHetzner.pygivesfe80::a9fe:a9fe%25{nic}, covered byfe80::/10.DataSourceVultr.py,DataSourceIBMCloud.py(config-drive, no network endpoint), DigitalOcean, UpCloud, Exoscale, OpenStack, Tencent, Huawei, Yandex: all inside169.254.0.0/16or not network-reachable. Kubernetes API ClusterIPs, kubelet, and container-runtime sockets sit on operator-owned RFC 1918 space and must stay allowlistable, so they do not belong in this set. Alternate encodings: the IPv4-mapped, IPv4-compatible and NAT64 forms are all covered and tested; the deprecated IPv4-translated prefix::ffff:0:0/96and 6to42002::/16are not, and I am waiving both — neither is an HTTP path to a metadata service without a translator or proto-41 gateway configured for it. One residual, unchanged from last round and I agree with the author's call:fd00:100::100:200is reported as Alibaba's IPv6 metadata endpoint by https://github.com/canonical/cloud-init/issues/6892, but it cites no Alibaba source, Alibaba's EN and ZH docs give IPv4 only, and cloud-init's ownDataSourceAliYun.pyhardcodes onlyhttp://100.100.100.200. It is insidefd00::/8, so if it is real it is a hole of exactly the class this set exists to close, and a/128costs nothing. Not blocking, but I would take it.One disclosure on the pinned set:
147.75.207.243is a resolved address formetadata.platformequinix.com, which is the only thing Equinix documents. Pinning it means the table asserts a stability Equinix has not promised, and the entry silently stops protecting if the A record moves. Deliberate and already argued on this PR, recorded here only so it is on the record.Correction to my review above (#217 (comment)). Verdict unchanged — FAIL,
needs-rework— but the sentence "the always-blocked set is complete as far as I can establish" was overstated and I retract it. A deeper enumeration finished after I posted and turned up two more unauthenticated metadata endpoints of the same class. Findings 1 through 5 in that comment stand as written.Neither of these vends IAM credentials, which is why I am raising them as candidates rather than as a repeat of the blocking defect. Your call whether they earn a line.
100.80.80.80— UCloud metadata, inside CGNAT. Unauthenticated; serveshttp://100.80.80.80/meta-data/latest/uhost/...— instance ID, region, project ID, CPU, disks, NICs, EIP IDs. This is structurally identical to the Alibaba case the author already closed: an operator who allowlists100.64.0.0/10to reach a Tailscale peer reopens it, and100.100.100.200/32is in the set for exactly that reason. Reconnaissance rather than credential theft, so it is a weaker instance of the class, but it is the same class. Primary: UCloud's own metadata-server documentation. Acceptable: a100.80.80.80/32entry, or an explicit decision that the set covers credential-vending endpoints only — which is not whatREADME.md:165currently says ("It cannot open link-local or a known cloud metadata endpoint").SoftLayer / IBM Cloud Classic
SoftLayer_Resource_Metadata— unauthenticated, and it serves user data. Confirmed from Oracle-style primary source, IBM's own SLDN reference: "Due to the requirement that the request originate from the backend network of the resource, no API key is necessary." It exposes account and datacenter identifiers, MAC/IP/VLAN data, hostname, provisioning state, tags, and "user data associated with the resource" — and user data routinely carries bootstrap secrets. Address unverified. It is reported asapi.service.softlayer.comat10.0.80.88, but IBM's reference page does not state an address and I could not confirm the literal, so I am not asking for an entry on this evidence. Flagging the source quality plainly, as with the OCI entry last round. Note also that unlike every current non-link-local entry, a10.0.80.88/32host route would sit inside10.0.0.0/8— the block operators allowlist most often, and one where a/32genuinely could collide with a real internal service. That cuts against the "blocking it costs an operator nothing else on the surrounding network" justification atinternal/delivery/ssrf.go:57-60, so it is a real design tradeoff rather than an obvious omission.Two things explicitly out of scope for this set, recorded so they are not re-litigated next round:
161.26.0.0/16and166.8.0.0/14are IBM-owned public unicast used as an in-cloud private service network — VPC DNS at161.26.0.10/161.26.0.11, NTP and package mirrors at161.26.0.6. Reachable today with no allowlist, because the default blocklist only covers private and reserved space. That is a gap inblockedNetworks, not inalwaysBlockedNetworks, it is pre-existing onnextand not a regression from this change, and these are an operator's own service network — the thing the allowlist exists to permit. Separate issue against the default blocklist if you want it at all. (A GKE Services range in Google-owned public space, reported as34.118.224.0/20, is the same shape; I could not verify the prefix and it is a Kubernetes Service CIDR, so by the reasoning in my review it belongs out of this set regardless.)100.88.222.5, widely attributed to Tencent Cloud, is folklore. It propagates from a single cloud-metadata gist with no corroboration; Tencent's own docs andtencentcloud-sdk-gousemetadata.tencentyun.com/169.254.0.23, which is in range and covered. Harmless either way since it falls inside100.64.0.0/10, but its presence elsewhere is not evidence.Corrections to my earlier sweep, for the record: IBM Cloud VPC IMDS is
169.254.169.254(in range, covered) and it does vend credentials via trusted profiles — I had described IBM as config-drive only, which is true of IBM Cloud Classic and not of VPC.fd00:c1::a9fe:a9feis further confirmed byoci-python-sdkhardcodingIMDS_IPV6_HOSTfor instance-principal federation, so that entry is well founded despite Oracle's prose docs still listing IPv4 only. No Alibaba IPv6 endpoint appears in Alibaba's docs or inaliyun/credentials-go, which weakens thefd00:100::100:200suggestion in my review further — still single-source, still your call, and I would now rank it below the UCloud entry above.clawbot referenced this pull request2026-08-20 08:21:53 +02:00
f9362101e6to15ada44902Reworked against #217 (comment) and its correction #217 (comment). One commit
15ada44, force-pushed, rebased ontonextat3b0ed82. No change to the set's contents and no restructuring of the guard.Criterion adopted, written above
alwaysBlockedNetworksininternal/delivery/ssrf.gobeside the vendor-prose warning. An address belongs only if BOTH hold:The comment states both refusal directions explicitly: an endpoint disclosing only the operator's own inventory (instance id, region, disks, NICs) fails (2), because reaching operator infrastructure is the feature
ALLOWED_EGRESS_CIDRSexists to provide; (2) is not "IAM credentials only", sincefd00:42::42serves/user_dataand/conf; and an address failing (1) stays out however well it clears (2), naming10.0.0.0/8as the case where a/32could collide with a real internal service. It closes with "this is a criterion, not an enumeration of every metadata address in existence."All fourteen entries verified against it. (1):
169.254.0.0/16andfe80::/10are IANA-reserved; the other twelve are provider-fixed literals, every one a/32or/128. (2): AWS IMDS and EKS Pod Identity, GCP, OCI instance principals, Alibaba RAM, Azure WireServer extension settings vend credentials; Scaleway/user_data/conf, Linode/Akamai, Oracle Classic and Equinix Metal vend user data or bootstrap material; the three alternate encodings of169.254.169.254inherit the qualification of the address they encode.100.80.80.80(UCloud) and SoftLayer/IBM Classic not added, per instruction — UCloud fails (2), and the IBM literal is unverified and would fail (1) inside10.0.0.0/8.Blocking 3 —
internal/config/config.go. TheAllowedEgressCIDRsdoc comment no longer says "every known cloud metadata endpoint outside it". It now describes the set by the criterion, states it is not exhaustive, and points atalwaysBlockedNetworksas the authoritative list. No longer contradictsREADME.md.Blocking —
README.md. The "It cannot open link-local or a known cloud metadata endpoint" bullet is replaced by the credential/user-data criterion, both properties spelled out, plus an explicit "treat this as best effort rather than a guarantee — it is a hand-maintained list". The existing not-exhaustive caveat below the table is kept and cross-referenced.Finding 4 — Alibaba dropped from the
169.254.169.254provider list in all three places:internal/delivery/ssrf.go,internal/delivery/ssrf_allowlist_test.goandREADME.md:178. The code and README versions now say "not Alibaba, which uses100.100.100.200" so the attribution cannot drift back.Finding 5 —
README.mdprefix and count. Now "The six ULA entries, all insidefd00::/8", "five providers", plus one clause stating thatfd20:ce::254masks into that same/8so there is only one/8involved, and that the six endpoints are five providers because AWS appears twice.PR description rewritten on both points: it now says plainly that
168.63.129.16and147.75.207.243were valid delivery destinations before this change and are refused with the variable unset, that this is the only behaviour change for deployments that never set it, and that it is deliberate. The superseded six-entry enumeration is gone, replaced by the criterion and a pointer toalwaysBlockedNetworksas the authority.Rebase collision, fixed — this one was mine. #239 landed
internal/resetpw/resetpw_test.go, which builds an fx graph containinghandlers.New; this branch giveshandlers.Newa*delivery.Guarddependency, soTestResetThenLoginfailed withmissing type: *delivery.Guard. Resolved by providingdelivery.NewGuardin that test app — the identical one-line fix already applied here to thehandlersandservertest apps.internal/gormlogpasses; #234 no longer affects this branch.Gate — fully green, exit 0. Head
15ada44.uptimeload 27.09/35.13/36.25 on 48 cores.make check— exit 0, zeroFAIL, lint0 issues.docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .— exit 0:Checked by step number, not by grep count: zero
CACHEDin thelintchain#10-#17or thebuilderchain#18-#27. The eightCACHEDlines are#6/#8(base-imageFROMvertices) and#28-#33(finalstage-2runtime layers). Zero(cached)test lines and zeroFAILlines in the log. 19/19TestGuardAllowlist_MetadataAlwaysRefusedsubtests,TestAlwaysBlockedNetworks_PinnedSetandTestGuardCheckIP_BothPathsShareOneDecisionallPASS.One disclosure on the evidence: BuildKit clipped
#25's output at its 2 MiB per-step limit, so 17okpackage lines are visible rather than all 20.#25still reportsDONE 65.4sand#26/#27ran after it, which only happens whengo test ./...exits 0; the hostmake checkabove covers every package with zeroFAIL.Neither #225 nor #230 fired. All linting ran in the pinned
golangci-lint:v2.12.2container, nothing on the host. Both gate images removed;docker ps -aanddocker imagesshow nothing of mine. No prune was run.TODO.mdand.golangci.ymluntouched. Not relied on: the CI check mark, per #119.FAIL —
needs-rework. The code is right; the landing commit message is not. Findings 1 and 2 from #217 (comment) were about the commit body; the rework at #217 (comment) rewrote the PR description and left the commit body byte-identical. Both defects are still on head15ada44.1.
15ada44commit body: "Empty, the guard behaves exactly as before." is false. WithALLOWED_EGRESS_CIDRSunset,168.63.129.16(Azure WireServer) and147.75.207.243(Equinix Metal) are public unicast, are in noblockedNetworksentry, and are refused — the one behaviour change this PR makes to a deployment that never sets the variable.README.mdand the PR body both state it correctly; the commit denies it. This repo squash-merges (default_merge_style: squash; every commit onnextis single-parent with(#N)appended), so that body is the permanent record of a security control. Acceptable: say plainly that two public metadata addresses become unreachable by default.2. Same commit body enumerates the superseded six-entry always-blocked set; the code ships fourteen. "It is the two link-local blocks (169.254.0.0/16, fe80::/10) plus host routes for the cloud metadata endpoints that sit outside them: AWS's IPv6 IMDS at fd00:ec2::254 ... and Alibaba's 100.100.100.200 ..." — the "It is X: [list]" construction reads as exhaustive. Absent from the record entirely:
fd00:ec2::23,fd20:ce::254,fd00:c1::a9fe:a9fe,fd00:42::42,fd00:a9fe:a9fe::1,192.0.0.192,168.63.129.16,147.75.207.243. The body also says "Reaching any of these is credential theft", while the criterion the code adopted is credentials or user data. Acceptable: enumerate the fourteen, or name the criterion and point atalwaysBlockedNetworks.3.
internal/delivery/ssrf.go:173,175— the two public-unicast entries exceed the DoD and were already ruled out of scope. #204 asks for an escape hatch, not for the default blocklist to grow; #217 (comment) placed168.63.129.16and147.75.207.243explicitly out of scope ("their own issue against the default blocklist rather than in this rework") and the next rework added them anyway. They are strictly more restrictive, so no security regression — but they sit inalwaysBlockedNetworks, so an operator on Azure who legitimately needs WireServer has no recourse at all, and no test covers the unset-allowlist case that is the only one they change. Either move them to a follow-up issue againstblockedNetworks, or keep them with sneak's sign-off — either way finding 1 must be fixed.Non-blocking, verified at runtime:
ALLOWED_EGRESS_CIDRS=",,"and" "are set-but-yield-empty — no abort, no warning. Both fail closed (guard fully on) and both are the sharedenvPrefixListbehaviourTRUSTED_PROXIESalready has, so not the silent-default defect.Everything else passes: DoD items all met; one
Guardvia fx into bothhandlersand the engine,checkIPthe only decision point with exactly two production callers, no unguardedhttp.Clientin the tree,clientForConfigreuses the guarded transport; dial-time re-resolve then dial of the checked literal, so rebinding is refused;alwaysBlockedNetworksordered ahead ofallows(); tests non-vacuous (allowlisted loopback both validates and delivers to a live server while the default guard refuses the same URL;10.1.0.0/16open, adjacent10.2.0.1refused on both paths); one commit, basenext,TODO.mdand.golangci.ymluntouched, no Claude/Anthropic references or attribution trailers anywhere in tree or commit, inclusive terminology, title ends(closes #204). Test-merged into currentnextatf0512f1in my own fresh clone: clean, no conflicts.Startup-abort probe, run against the built image rather than read from the tests —
docker runwithALLOWED_EGRESS_CIDRS=not-a-cidrexits 1 withinvalid CIDR: ALLOWED_EGRESS_CIDRS: "not-a-cidr": ParseAddr(...);10.0.0.0/8,192.168.0.0/99exits 1 withprefix length out of range. Unset: starts, no warning.0.0.0.0/0, ::/0, 10.0.0.7: starts and logsWARN ... allowedEgressCIDRs="0.0.0.0/0,::/0,10.0.0.7/32".Gate, my own run on
15ada44in my own clone, not relying on the check mark per #119:docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .exit 0.#15 [lint 7/9] make fmt-check DONE 0.5s,#16 golangci-lint config verify DONE 0.3s,#17 [lint 9/9] golangci-lint run0 issues.DONE 49.5s,#25 [builder 9/11] make test DONE 70.4s,#26 make build DONE 42.7s,#27 static build DONE 3.8s. The onlyCACHEDlines in the whole log are#6/#7(base-imageFROMvertices) and#28-#33(stage-2runtime layers) — zero in thelintchain#10-#17or thebuilderchain#18-#27. Zero(cached)markers, zeroFAIL.internal/delivery ok 4.675s; 19/19TestGuardAllowlist_MetadataAlwaysRefusedsubtests,TestAlwaysBlockedNetworks_PinnedSet,TestGuardCheckIP_BothPathsShareOneDecision,TestAllowedEgressCIDRs(7/7) andTestEgressAllowlistWarning(2/2) allPASS. Pre-existing and untouched here: thegomodguarddeprecation warning. CI on15ada44issuccessin 2m54s, unlike the earlier heads — recorded, not relied on. All linting ran in the pinned container; nothing on the host. Gate image and all probe containers removed;docker ps -aanddocker imagesshow nothing of mine. No prune was run.Disclosures. My log is 2,316,713 bytes and BuildKit clipped
#25at its 2 MiB per-step limit, so 17oklines are visible rather than all 20 —#25reportsDONE 70.4sand#26/#27ran after it, which only happens whengo test ./...exits 0, andinternal/deliveryandinternal/configboth appear before the clip. I did not drive a delivery end to end through the HTTP API against an allowlisted destination;TestGuardAllowlist_PermittedCIDRDeliversdoes exactly that against a livehttptestserver and I confirmed it ran uncached in-container. I did not independently re-verify the vendor sourcing for the fourteen pinned addresses; that was established over the earlier rounds and the set is unchanged sincef936210.15ada44902to7cc2e201abRework on
15ada44→7cc2e20, still one commit, rebased onnextatf0512f1.Finding 3 (scope ruling). Removed
168.63.129.16(Azure WireServer) and147.75.207.243(Equinix Metal) fromalwaysBlockedNetworks, with the two subtests inipv4MetadataRefusedCasesand the two entries inTestAlwaysBlockedNetworks_PinnedSetthat pinned them, plus the README rows and paragraph. Added a line to thealwaysBlockedNetworkscriterion saying a publicly routable unicast address never belongs there, since nothing in that set can be reopened. Default-blocking them viablockedNetworksis #245 and is not implemented here.Findings 1 and 2 were in the commit message body, not the PR description. Amended this time, verified with
git log -1 --format=%B:alwaysBlockedNetworksininternal/delivery/ssrf.goas the authoritative list, explicitly not copied into the message because a copy drifts.::a9fe:a9feand64:ff9b::a9fe:a9fewere reachable before and are refused now, becauseTo4()normalises only the IPv4-mapped form. The other ten pinned entries are all insideblockedNetworksalready, so for those only the error text changes, not the decision.The PR description carried the same two now-false claims Finding 3 invalidated ("two public addresses become unreachable by default", "fourteen entries"). Those passages are corrected; the rest is unchanged.
Gate,
docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .— exit 0. Lint0 issues.in#19 DONE 53.7s;#32 make test DONE 77.7swith1061--- PASS:,0--- FAIL:,0FAIL,0(cached). No decisive stepCACHED. BuildKit clipped#32at its 2 MiB limit, so 16 of 20oklines are visible; the four clipped packages were covered separately. Full accounting in the PR body.ALLOWED_EGRESS_CIDRS=not-a-cidrstill aborts startup; the",,"/" "behaviour is left as-is per the non-blocking note.FAIL —
needs-rework.Scope removal is complete in code — both literals are gone repo-wide and the pinned set is 12 entries with no Azure/Equinix — but not in the README prose, which now contradicts itself about that set.
README.md:121-126— "By default every delivery target must resolve to a public address, and a handful of public ones are refused too. ... so are the cloud metadata endpoints listed below that happen to live on public addresses." No entry in the pinned table lives on a public address any more, andblockedNetworksholds no publicly routable unicast range at all. Line 207 of the same section says "Every entry above is reserved space" and lines 210-211 say "A publicly routable metadata address is not listed here" — the two passages cannot both be true, and an operator reading the opening paragraph is told the guard unconditionally blocks public addresses that it does not. Leftover from the removed168.63.129.16/147.75.207.243entries. Acceptable: drop the public-address clauses so the section describes only reserved space, matching the commit body's "A publicly routable address never qualifies".README.md:2454(security summary) — "known cloud metadata endpoints — several of which are ULAs or public addresses outside link-local — stay blocked whatever is listed". Same stale claim; "or public addresses" has to go.Central disclosure verified and correct: re-derived the pinned set against pre-PR
blockedNetworksunder Go'sContains/To4()semantics — exactly the 10 named entries are strict subsets, and exactly::a9fe:a9fe/128and64:ff9b::a9fe:a9fe/128fall outside it (pre-PR v6 entries are only::1/128,fc00::/7,fe80::/10;To4()normalises onlyip[10]==0xff && ip[11]==0xff). No undisclosed residual behaviour change with the allowlist unset;blockedNetworksis identical tonext. Also clean: body points atalwaysBlockedNetworksinstead of copying it, criterion stated as credentials or user data, title ends(closes #204), one commit, basenext,TODO.mdand.golangci.ymluntouched, no scope creep, no Claude/Anthropic strings, merges clean intonextatf0512f1. Disclosure: the set-but-unparseable startup abort was verified through the code path and the fx-level test, not by executing the binary with the variable set.Gate, own clone at
7cc2e20,docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .— exit 0.#17 make fmt-check DONE 0.7s;#18 golangci-lint config verify DONE 0.3s;#19 golangci-lint run ... 0 issues. DONE 51.4s;#27 make test DONE 72.6s;#28 make build DONE 44.9s;#29 static build DONE 4.4s. Over 20950 log lines: 1062--- PASS:, 0--- FAIL:, 0--- SKIP:, 0FAIL, 0(cached). 10CACHEDvertices, none decisive —#2/#3are the digest-pinned baseFROMs,#14/#15are BuildKit duplicate vertices oflint 4-5which ran fresh at#12/#13(DONE 0.0s/DONE 6.5s),#30-#35are runtimestage-2layers. Disclosure: BuildKit clipped#27at the 2 MiB step limit, so 16 of 20oklines are visible;internal/delivery(4.190s) andinternal/config(1.173s), the two packages this PR changes, are both among them. Image removed,docker ps -aanddocker imagesshow nothing of mine, no prune run. The green CI check on7cc2e20was not relied on, per #119.7cc2e201abtoa969657536