From b7cb2bd10b89fc5eeaaf47a28dd6383d951fe442 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Mon, 7 Sep 2026 23:15:48 +0800 Subject: [PATCH] fix: update SSH channel security for host provisioning --- .github/workflows/security-release-gate.yml | 16 ++++---- .github/workflows/validate.yml | 20 ++++----- go.mod | 2 +- go.sum | 2 + scripts/test | 1 + scripts/validate | 1 + security/openvex.json | 6 +-- vendor/golang.org/x/crypto/ssh/certs.go | 38 ++++++++--------- vendor/golang.org/x/crypto/ssh/channel.go | 20 ++++++++- .../ssh/pasturestack_channel_security_test.go | 41 +++++++++++++++++++ vendor/golang.org/x/crypto/ssh/transport.go | 12 ++++-- vendor/modules.txt | 4 +- 12 files changed, 114 insertions(+), 49 deletions(-) create mode 100644 vendor/golang.org/x/crypto/ssh/pasturestack_channel_security_test.go diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index 0a521dbd..7d8d8ac7 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -23,7 +23,7 @@ jobs: env: DAPPER_IMAGE: pasturestack/host-provisioner-dapper:${{ github.sha }} TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 - VERSION_OVERRIDE: v0.39.5 + VERSION_OVERRIDE: v0.39.7 SOURCE_DATE_EPOCH: '0' steps: - name: Check out candidate @@ -47,21 +47,21 @@ jobs: run: | set -euo pipefail make DAPPER_IMAGE="$DAPPER_IMAGE" ci - test -s dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz - package_dir='host-provisioner-0.39.5-linux-amd64' - tar -tJf dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz | grep -Fx "${package_dir}/host-provisioner" - tar -tJf dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz | grep -Fx "${package_dir}/LICENSE" - tar -tJf dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz | grep -Fx "${package_dir}/ORIGIN.md" + test -s dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz + package_dir='host-provisioner-0.39.7-linux-amd64' + tar -tJf dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz | grep -Fx "${package_dir}/host-provisioner" + tar -tJf dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz | grep -Fx "${package_dir}/LICENSE" + tar -tJf dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz | grep -Fx "${package_dir}/ORIGIN.md" expected_legal=$(find vendor -type f \( \ -name 'LICENSE' -o -name 'LICENSE.*' -o \ -name 'NOTICE' -o -name 'NOTICE.*' -o \ -name 'COPYING' -o -name 'COPYING.*' -o \ -name 'AUTHORS' -o -name 'AUTHORS.*' \ \) | wc -l) - actual_legal=$(tar -tJf dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz | grep -Ec "^${package_dir}/licenses/.+/(LICENSE|LICENSE\..*|NOTICE|NOTICE\..*|COPYING|COPYING\..*|AUTHORS|AUTHORS\..*)$") + actual_legal=$(tar -tJf dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz | grep -Ec "^${package_dir}/licenses/.+/(LICENSE|LICENSE\..*|NOTICE|NOTICE\..*|COPYING|COPYING\..*|AUTHORS|AUTHORS\..*)$") test "$expected_legal" -gt 0 test "$actual_legal" -eq "$expected_legal" - sha256sum dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz bin/host-provisioner > evidence/product-artifacts.sha256 + sha256sum dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz bin/host-provisioner > evidence/product-artifacts.sha256 docker run --rm --entrypoint go -v "$PWD:/work:ro" "$DAPPER_IMAGE" \ version -m /work/bin/host-provisioner > evidence/product-go-version.txt grep -F $'build\tCGO_ENABLED=0' evidence/product-go-version.txt >/dev/null diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 16a5cc50..589ca4a4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -33,19 +33,19 @@ jobs: set -euo pipefail test -z "$(git status --porcelain)" git merge-base --is-ancestor 22d7baca609c18589f08225a97bf8c5e00109204 HEAD - grep -Fq 'VERSION_OVERRIDE: v0.39.5' .github/workflows/validate.yml + grep -Fq 'VERSION_OVERRIDE: v0.39.7' .github/workflows/validate.yml - name: Run complete tests, validation, and package env: - VERSION_OVERRIDE: v0.39.5 + VERSION_OVERRIDE: v0.39.7 SOURCE_DATE_EPOCH: '0' shell: bash run: | set -euo pipefail make ci - asset='dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz' + asset='dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz' test -s "$asset" - package_dir='host-provisioner-0.39.5-linux-amd64' + package_dir='host-provisioner-0.39.7-linux-amd64' tar -tJf "$asset" | grep -Fx "${package_dir}/host-provisioner" tar -tJf "$asset" | grep -Fx "${package_dir}/LICENSE" tar -tJf "$asset" | grep -Fx "${package_dir}/ORIGIN.md" @@ -61,28 +61,28 @@ jobs: mkdir -p evidence extracted tar -xJf "$asset" -C extracted test -x "extracted/${package_dir}/host-provisioner" - test "$("extracted/${package_dir}/host-provisioner" -v)" = $'host-provisioner\t gitcommit=v0.39.5' + test "$("extracted/${package_dir}/host-provisioner" -v)" = $'host-provisioner\t gitcommit=v0.39.7' sha256sum "$asset" "extracted/${package_dir}/host-provisioner" > evidence/product-artifacts.sha256 - name: Verify reproducible artifact env: - VERSION_OVERRIDE: v0.39.5 + VERSION_OVERRIDE: v0.39.7 SOURCE_DATE_EPOCH: '0' shell: bash run: | set -euo pipefail - cp dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz /tmp/first.tar.xz + cp dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz /tmp/first.tar.xz rm -rf bin dist make ci - cmp /tmp/first.tar.xz dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz + cmp /tmp/first.tar.xz dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz printf 'HOST_PROVISIONER_VALIDATION_OK source=%s version=%s\n' "$GITHUB_SHA" "$VERSION_OVERRIDE" - name: Retain reviewed release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: host-provisioner-0.39.5 + name: host-provisioner-0.39.7 path: | - dist/artifacts/host-provisioner-0.39.5-linux-amd64.tar.xz + dist/artifacts/host-provisioner-0.39.7-linux-amd64.tar.xz evidence/product-artifacts.sha256 if-no-files-found: error retention-days: 30 diff --git a/go.mod b/go.mod index ae27f892..c3f40c1a 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.55.0 // indirect + golang.org/x/crypto v0.56.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect ) diff --git a/go.sum b/go.sum index c30ac35e..18537a31 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/scripts/test b/scripts/test index e1996543..0d3c4e74 100755 --- a/scripts/test +++ b/scripts/test @@ -6,3 +6,4 @@ cd $(dirname $0)/.. echo Running tests CGO_ENABLED=1 go test -race -cover -tags=test ./... +CGO_ENABLED=1 go test -race -timeout 30s ./vendor/golang.org/x/crypto/ssh -run TestPastureStack diff --git a/scripts/validate b/scripts/validate index e4c23cf0..091a4dba 100755 --- a/scripts/validate +++ b/scripts/validate @@ -28,6 +28,7 @@ fi go list -mod=vendor -deps ./... >/dev/null crypto_version=$(go list -mod=vendor -m -f '{{.Version}}' golang.org/x/crypto) +test "$crypto_version" = v0.56.0 grep -F -- "\"@id\": \"pkg:golang/golang.org/x/crypto@${crypto_version}\"" security/openvex.json >/dev/null || { echo 'OpenVEX x/crypto identity does not match the resolved module.' >&2 exit 1 diff --git a/security/openvex.json b/security/openvex.json index a80254f7..f9138d95 100644 --- a/security/openvex.json +++ b/security/openvex.json @@ -1,8 +1,8 @@ { "@context": "https://openvex.dev/ns/v0.2.0", - "@id": "https://github.com/PastureStack/host-provisioner/security/openvex/2026-08-26", + "@id": "https://github.com/PastureStack/host-provisioner/security/openvex/2026-09-07", "author": "PastureStack contributors", - "timestamp": "2026-08-26T15:32:00+08:00", + "timestamp": "2026-09-07T15:32:00+08:00", "version": 1, "statements": [ { @@ -11,7 +11,7 @@ }, "products": [ { - "@id": "pkg:golang/golang.org/x/crypto@v0.55.0" + "@id": "pkg:golang/golang.org/x/crypto@v0.56.0" } ], "status": "not_affected", diff --git a/vendor/golang.org/x/crypto/ssh/certs.go b/vendor/golang.org/x/crypto/ssh/certs.go index fa848f51..a3b802e4 100644 --- a/vendor/golang.org/x/crypto/ssh/certs.go +++ b/vendor/golang.org/x/crypto/ssh/certs.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net" + "slices" "sort" "time" ) @@ -305,8 +306,11 @@ const sourceAddressCriticalOption = "source-address" // minimally, the IsAuthority callback should be set. type CertChecker struct { // SupportedCriticalOptions lists the CriticalOptions that the - // server application layer understands. These are only used - // for user certificates. + // application layer understands. A certificate carrying a critical + // option that is not listed here is rejected. + // CertChecker.Authenticate additionally accepts the source-address + // option, which the server enforces on the Permissions that + // Authenticate returns. SupportedCriticalOptions []string // IsUserAuthority should return true if the key is recognized as an @@ -369,8 +373,9 @@ func (c *CertChecker) CheckHostKey(addr string, remote net.Addr, key PublicKey) return c.CheckCert(hostname, cert) } -// Authenticate checks a user certificate. Authenticate can be used as -// a value for ServerConfig.PublicKeyCallback. +// Authenticate checks a user certificate. Authenticate can be used as a value +// for ServerConfig.PublicKeyCallback. The source-address critical option is +// allowed, as it will be enforced by the server. func (c *CertChecker) Authenticate(conn ConnMetadata, pubKey PublicKey) (*Permissions, error) { cert, ok := pubKey.(*Certificate) if !ok { @@ -389,8 +394,11 @@ func (c *CertChecker) Authenticate(conn ConnMetadata, pubKey PublicKey) (*Permis if !c.IsUserAuthority(cert.SignatureKey) { return nil, fmt.Errorf("ssh: certificate signed by unrecognized authority") } - - if err := c.CheckCert(conn.User(), cert); err != nil { + // The source-address critical option is enforced by serverAuthenticate, + // so it is supported regardless of SupportedCriticalOptions + cc := *c + cc.SupportedCriticalOptions = append(slices.Clip(cc.SupportedCriticalOptions), sourceAddressCriticalOption) + if err := cc.CheckCert(conn.User(), cert); err != nil { return nil, err } @@ -398,27 +406,15 @@ func (c *CertChecker) Authenticate(conn ConnMetadata, pubKey PublicKey) (*Permis } // CheckCert checks CriticalOptions, ValidPrincipals, revocation, timestamp and -// the signature of the certificate. +// the signature of the certificate. Critical options that are not listed in +// SupportedCriticalOptions are rejected. func (c *CertChecker) CheckCert(principal string, cert *Certificate) error { if c.IsRevoked != nil && c.IsRevoked(cert) { return fmt.Errorf("ssh: certificate serial %d revoked", cert.Serial) } for opt := range cert.CriticalOptions { - // sourceAddressCriticalOption will be enforced by - // serverAuthenticate - if opt == sourceAddressCriticalOption { - continue - } - - found := false - for _, supp := range c.SupportedCriticalOptions { - if supp == opt { - found = true - break - } - } - if !found { + if !slices.Contains(c.SupportedCriticalOptions, opt) { return fmt.Errorf("ssh: unsupported critical option %q in certificate", opt) } } diff --git a/vendor/golang.org/x/crypto/ssh/channel.go b/vendor/golang.org/x/crypto/ssh/channel.go index ba3279e9..d6010fd7 100644 --- a/vendor/golang.org/x/crypto/ssh/channel.go +++ b/vendor/golang.org/x/crypto/ssh/channel.go @@ -173,6 +173,12 @@ type channel struct { // (for outbound channels) or received (for inbound channels). decided bool + // established is set to true once the channel is open and may carry normal + // channel traffic: for an outbound channel when the peer's open + // confirmation is received, for an inbound channel when the local side + // accepts it. It is set and read from different goroutines. + established atomic.Bool + // direction contains either channelOutbound, for channels created // locally, or channelInbound, for channels created by the peer. direction channelDirection @@ -434,10 +440,20 @@ func (ch *channel) responseMessageReceived() error { return errors.New("ssh: duplicate response received for channel") } ch.decided = true + ch.established.Store(true) return nil } func (ch *channel) handlePacket(packet []byte) error { + // Only the open response is expected before the channel is established. + if !ch.established.Load() { + switch packet[0] { + case msgChannelOpenConfirm, msgChannelOpenFailure: + default: + return nil + } + } + switch packet[0] { case msgChannelData, msgChannelExtendedData: return ch.handleData(packet) @@ -503,7 +519,8 @@ func (ch *channel) handlePacket(packet []byte) error { default: } default: - ch.msg <- msg + // No other message type is expected on an established channel. + return fmt.Errorf("ssh: unexpected message type %d on channel %d", packet[0], ch.localId) } return nil } @@ -554,6 +571,7 @@ func (ch *channel) Accept() (Channel, <-chan *Request, error) { MaxPacketSize: ch.maxIncomingPayload, } ch.decided = true + ch.established.Store(true) if err := ch.sendMessage(confirm); err != nil { return nil, nil, err } diff --git a/vendor/golang.org/x/crypto/ssh/pasturestack_channel_security_test.go b/vendor/golang.org/x/crypto/ssh/pasturestack_channel_security_test.go new file mode 100644 index 00000000..a8c74f15 --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/pasturestack_channel_security_test.go @@ -0,0 +1,41 @@ +package ssh + +import "testing" + +// Exercise the exact channel dispatch boundary behind CVE-2026-56855 and +// CVE-2026-78662, without requiring a live host or cloud credentials. +func TestPastureStackUndecidedChannelDropsTraffic(t *testing.T) { + ch := &channel{incomingRequests: make(chan *Request, 1)} + packet := Marshal(channelRequestMsg{Request: "exec", WantReply: true}) + if err := ch.handlePacket(packet); err != nil { + t.Fatal(err) + } + if len(ch.incomingRequests) != 0 { + t.Fatal("unestablished channel accepted traffic and can deadlock the mux") + } + ch.established.Store(true) + if err := ch.handlePacket(packet); err != nil { + t.Fatal(err) + } + if len(ch.incomingRequests) != 1 || (<-ch.incomingRequests).Type != "exec" { + t.Fatal("normal requests must remain usable after channel establishment") + } +} + +func TestPastureStackEstablishedChannelRejectsUnexpectedMessages(t *testing.T) { + ch := &channel{msg: make(chan interface{}, 1)} + ch.established.Store(true) + if err := ch.handlePacket(Marshal(globalRequestMsg{Type: "unexpected"})); err == nil { + t.Fatal("unexpected messages must fail instead of filling the pending queue") + } + if len(ch.msg) != 0 { + t.Fatal("unexpected traffic entered the channel queue") + } + ch.sentRequestPending.Store(true) + if err := ch.handlePacket(Marshal(channelRequestSuccessMsg{})); err != nil { + t.Fatal(err) + } + if len(ch.msg) != 1 { + t.Fatal("the legitimate pending request response was lost") + } +} diff --git a/vendor/golang.org/x/crypto/ssh/transport.go b/vendor/golang.org/x/crypto/ssh/transport.go index fa3dd6a4..540865df 100644 --- a/vendor/golang.org/x/crypto/ssh/transport.go +++ b/vendor/golang.org/x/crypto/ssh/transport.go @@ -331,13 +331,19 @@ func exchangeVersions(rw io.ReadWriter, versionLine []byte) (them []byte, err er // chars const maxVersionStringBytes = 255 +// maxPreVersionLines is the maximum number of lines sent by the peer +// before the version string. Each of these lines is limited to a maximum +// of maxVersionStringBytes chars. Lines sent before the version string +// are silently ignored. +const maxPreVersionLines = 1024 + // Read version string as specified by RFC 4253, section 4.2. func readVersion(r io.Reader) ([]byte, error) { versionString := make([]byte, 0, 64) var ok bool var buf [1]byte - for length := 0; length < maxVersionStringBytes; length++ { + for lines := 0; len(versionString) < maxVersionStringBytes && lines < maxPreVersionLines; { _, err := io.ReadFull(r, buf[:]) if err != nil { return nil, err @@ -347,9 +353,9 @@ func readVersion(r io.Reader) ([]byte, error) { if buf[0] == '\n' { if !bytes.HasPrefix(versionString, []byte("SSH-")) { // RFC 4253 says we need to ignore all version string lines - // except the one containing the SSH version (provided that - // all the lines do not exceed 255 bytes in total). + // except the one containing the SSH version. versionString = versionString[:0] + lines++ continue } ok = true diff --git a/vendor/modules.txt b/vendor/modules.txt index b1abee13..bb8e2a8e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -103,8 +103,8 @@ github.com/stretchr/testify/require # go.yaml.in/yaml/v3 v3.0.5 ## explicit; go 1.16 go.yaml.in/yaml/v3 -# golang.org/x/crypto v0.55.0 -## explicit; go 1.25.0 +# golang.org/x/crypto v0.56.0 +## explicit; go 1.26.0 golang.org/x/crypto/blowfish golang.org/x/crypto/chacha20 golang.org/x/crypto/cryptobyte