Partially reverts #25136 for non-darwin platforms.
In general we want to avoid pinning trust roots to embedded Certs, since that limits operational flexibility. If Azure changes CAs, operators should, at most, be able to update the OS trust store to keep Coder working correctly. Embedding roots means we need to upgrade the Coder binary.
Since Coder Server on macOS is not really supported for production use, embedding only in that case to ease development and testing is OK.
Azure IMDS attested data signatures can now chain through
Microsoft TLS G2 RSA CA OCSP intermediates, then through the
cross-signed Microsoft TLS RSA Root G2 certificate, before reaching
DigiCert Global Root G2.
coderd did not bundle the new G2 OCSP intermediates or the
cross-signed Microsoft TLS RSA Root G2 bridge certificate, so it could
fail to build a trusted chain for affected IMDS signatures.
Related to:
https://linear.app/codercom/issue/PLAT-205/bug-azure-instance-identity-verification-is-broken
Migrates Azure instance identity verification from
`go.mozilla.org/pkcs7` and `github.com/fullsailor/pkcs7` to
`github.com/smallstep/pkcs7`, using `VerifyWithChainAtTime` to validate
both the PKCS7 signature and the certificate chain in one call. The
previous code only verified the signer certificate against a set of
intermediates/roots but did not verify that the PKCS7 signature itself
covered the content, meaning tampered payloads could be accepted.
The `Options` struct is restructured to accept `Roots`, `Intermediates`,
and `CurrentTime` as explicit fields instead of embedding
`x509.VerifyOptions`. The test helper `NewAzureInstanceIdentity` now
builds a realistic 3-level certificate chain (Root CA -> Intermediate CA
-> Signing Cert) matching real Azure trust hierarchy. New tests
(`TestValidate_TamperedContent`,
`TestValidate_UntrustedCertWithValidSignature`) confirm tampered and
untrusted envelopes are rejected.
Addresses GHSA-6x44-w3xg-hqqf.
> [!NOTE]
> This PR was authored by Coder Agents.
<details>
<summary>Implementation Plan</summary>
### Files Changed
| File | Summary |
|------|---------|
| `coderd/azureidentity/azureidentity.go` | Replace `signer.Verify()`
with `VerifyWithChainAtTime`; restructure `Options` struct; add
`ParseCertificates()` helper |
| `coderd/azureidentity/azureidentity_test.go` | Add `testCertChain`
builder, tampered-content and untrusted-cert tests; update existing
tests for new `Options` API |
| `coderd/coderd.go` | Change `AzureCertificates` field from
`x509.VerifyOptions` to `azureidentity.Options` |
| `coderd/workspaceresourceauth.go` | Pass `api.AzureCertificates`
directly instead of wrapping |
| `coderd/coderdtest/coderdtest.go` | Migrate to `smallstep/pkcs7`;
build 3-level cert chain in test helper |
| `go.mod` / `go.sum` | Add `github.com/smallstep/pkcs7`; remove
`fullsailor/pkcs7` and `go.mozilla.org/pkcs7` |
</details>
Security improvements:
- Restrict cert fetches to a host+port allowlist (Microsoft and DigiCert
on 80/443).
- Route requests through a dedicated `http.Client` that resolves the
host once and dials the validated IP directly, preventing DNS rebinding.
- Reject loopback, private (RFC 1918 / IPv6 ULA), link-local, multicast,
unspecified, CGNAT, benchmarking, and IPv4-mapped IPv6 addresses.
- Cap the certificate response body at 1 MiB.
- Log the underlying error via slog and return a generic detail to the
caller to prevent information disclosure.
* fix(coderd): Harden Azure identity certificate fetch
- Restrict cert fetches to a host+port allowlist (Microsoft and
DigiCert on 80/443).
- Route requests through a dedicated `http.Client` that resolves
the host once and dials the validated IP directly.
- Reject loopback, private (RFC 1918 / IPv6 ULA), link-local,
multicast, unspecified, CGNAT, benchmarking, and IPv4-mapped
IPv6 addresses.
- Cap the certificate response body at 1 MiB.
- Log the underlying error via slog and return a generic detail
to the caller.
- Add unit tests for the URL allowlist, IP classification, and
dialer.
* fix(coderd/azureidentity): add IPv6 special-use ranges to SSRF blocklist
The extraBlockedNetworks list only contained IPv4 CIDRs. Add IPv6
equivalents that Go's stdlib classification methods do not cover:
- 64:ff9b:1::/48 RFC 8215 NAT64 translation
- 100::/64 RFC 6666 discard-only
- 2001:2::/48 RFC 5180 benchmarking
- 2001:db8::/32 RFC 3849 documentation
IPv6 ranges already handled by stdlib (unchanged):
- ::1/128 (IsLoopback)
- fc00::/7 (IsPrivate, ULA)
- fe80::/10 (IsLinkLocalUnicast)
- ff00::/8 (IsMulticast)
- ::/128 (IsUnspecified)
Fixes
[CODAGT-372](https://linear.app/codercom/issue/CODAGT-372/coderdazureidentity-testvalidateregular-fails-on-macos).
Closes coder/internal#101.
## Problem
`coderd/azureidentity TestValidate/regular` fails on macOS with:
```
verify signature:
github.com/coder/coder/v2/coderd/azureidentity.Validate
/Users/runner/work/coder/coder/coderd/azureidentity/azureidentity.go:75
- x509: “metadata.azure.com” certificate is not standards compliant
```
When `crypto/x509.VerifyOptions.Roots` is `nil`, Go's verifier on
macOS/iOS falls back to the system verifier (`systemVerify` in
`crypto/x509/root_darwin.go`), which delegates to Apple's
`SecTrustEvaluateWithError`. Apple's framework enforces stricter
standards-compliance checks than Go's pure-Go verifier and rejects some
otherwise valid Azure instance-identity leaf certificates with
`errSecCertificateIsNotStandardsCompliant`, surfaced as the `not
standards compliant` error.
The test had been skipped on darwin since #12979 (April 2024) as a
workaround.
## Fix
- Embed the three root CAs that Azure instance-identity certificates
ultimately chain to:
- DigiCert Global Root G2
- DigiCert Global Root G3
- Baltimore CyberTrust Root (kept for historical chains via `Microsoft
RSA TLS CA 01/02`)
- In `Validate`, populate `options.Roots` from those embedded roots when
the caller does not supply its own pool. Because `Roots != nil`, Go no
longer takes the `systemVerify` path on darwin and uses the pure-Go
verifier on all platforms.
- Remove the `runtime.GOOS == "darwin"` skip from `TestValidate`.
- Add `TestEmbeddedRoots` to guard against future regressions in the
embedded root list (parses each PEM, asserts self-signed, requires all
three named roots).
The caller's existing `Intermediates` handling is unchanged. Tests that
pass their own `Roots` (e.g. `coderdtest.NewAzureInstanceIdentity`) are
unaffected.
## Verification
On Linux:
```
$ go test ./coderd/azureidentity/ -race -count=1 -v
=== RUN TestValidate
=== RUN TestValidate/regular
=== RUN TestValidate/govcloud
=== RUN TestValidate/rsa
--- PASS: TestValidate (0.00s)
--- PASS: TestValidate/regular (0.00s)
--- PASS: TestValidate/rsa (0.00s)
--- PASS: TestValidate/govcloud (0.00s)
=== RUN TestEmbeddedRoots
--- PASS: TestEmbeddedRoots (0.00s)
=== RUN TestExpiresSoon
--- SKIP: TestExpiresSoon (0.00s)
PASS
ok github.com/coder/coder/v2/coderd/azureidentity 1.020s
```
The `test-go-pg` job on `macos-latest` in CI is the authoritative
confirmation of the fix on macOS; previously it would have failed
`TestValidate/regular` had the skip been removed.
<details>
<summary>Why this is the correct fix</summary>
From `/usr/local/go/src/crypto/x509/verify.go`:
```go
// Use platform verifiers, where available, if Roots is from SystemCertPool.
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" || runtime.GOOS == "ios" {
systemPool := systemRootsPool()
if opts.Roots == nil && (systemPool == nil || systemPool.systemPool) {
return c.systemVerify(&opts)
}
...
}
```
Setting `opts.Roots` to any non-nil, non-system pool deterministically
routes verification through Go's pure-Go verifier, bypassing Apple's
stricter compliance checks. The embedded roots are sufficient to
validate every chain we currently care about, since every intermediate
in `Certificates` ultimately issues to one of the three embedded roots.
</details>
> Generated by Coder Agents. Reviewed manually.
* chore: add /v2 to import module path
go mod requires semantic versioning with versions greater than 1.x
This was a mechanical update by running:
```
go install github.com/marwan-at-work/mod/cmd/mod@latest
mod upgrade
```
Migrate generated files to import /v2
* Fix gen