Files
netbird/client/internal/certproof/helper_run.go
T
riccardom 8224e0f7bf [client] Bound the wait on a certificate proof helper that outlives its launcher
The collector that answers certificate posture challenges allows one collection
at a time, and the latch guarding that is released by the goroutine running the
collection, not by the caller that gave up waiting for it. The collection is
therefore assumed to end.

On macOS and Windows it reads the signed-in user's certificates through a helper
process, and there the assumption does not hold. Wait blocks on the output pipe
rather than on the process, and killing the process we launched does not close
the pipe its own children inherited: on macOS we launch launchctl, which launches
sudo, which launches the helper, so the deadline reaps launchctl and leaves the
other two holding the pipe open. A helper stuck on a keychain prompt is enough.

Wait then never returns, the latch stays set, and the peer sends no proof again
for the life of the daemon. It fails every certificate check and loses the
policies that carry one, logging a single line per sync and nothing else.

Set a wait delay so Wait closes the pipes itself once the process is gone. The
run fails rather than reporting proofs, which is what we want: the output was cut
short, so there is nothing trustworthy to report.
2026-10-02 16:21:04 +02:00

115 lines
3.2 KiB
Go

package certproof
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
)
const (
maxHelperStdout = 1 << 20
maxHelperStderr = 4 << 10
// helperWaitDelay bounds how long Wait keeps reading the helper's output after the
// process we launched is gone. Killing that process does not close the pipe its own
// children inherited, and on macOS they are the ones doing the work: we launch
// launchctl, which launches sudo, which launches the helper. Without this, a helper
// stuck on a keychain prompt leaves Wait blocked with no deadline at all.
helperWaitDelay = time.Second
)
var errHelperOutputTooLarge = errors.New("helper output exceeds the size limit")
// runHelperCmd feeds req to the helper process cmd and returns the proofs it answered.
// The helper runs as an unprivileged user who can control its output, so both streams
// are capped and only proofs for a nonce req asked about are kept, one per challenge.
func runHelperCmd(cmd *exec.Cmd, req HelperRequest) ([]certposture.Proof, error) {
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("encode helper request: %w", err)
}
stdout := &cappedBuffer{limit: maxHelperStdout}
stderr := &cappedBuffer{limit: maxHelperStderr}
cmd.Stdin = bytes.NewReader(payload)
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.WaitDelay = helperWaitDelay
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
if stdout.truncated {
return nil, errHelperOutputTooLarge
}
var resp HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("decode helper response: %w", err)
}
return requestedProofs(req, resp.Proofs), nil
}
// requestedProofs keeps the proofs whose nonce belongs to one of req's challenges, at
// most as many as req has challenges.
func requestedProofs(req HelperRequest, proofs []certposture.Proof) []certposture.Proof {
var kept []certposture.Proof
for _, proof := range proofs {
if len(kept) == len(req.Challenges) {
break
}
if !req.asked(proof.Nonce) {
log.Debugf("certificate posture: dropping helper proof for a nonce that was not requested")
continue
}
kept = append(kept, proof)
}
return kept
}
func (r HelperRequest) asked(nonce []byte) bool {
for _, challenge := range r.Challenges {
if len(challenge.Nonce) > 0 && bytes.Equal(challenge.Nonce, nonce) {
return true
}
}
return false
}
// cappedBuffer keeps the first limit bytes written to it and discards the rest, so a
// misbehaving child cannot grow the parent's memory without bound. The buffer is a
// named field rather than embedded: an embedded bytes.Buffer would promote ReadFrom,
// which io.Copy prefers over Write, bypassing the cap.
type cappedBuffer struct {
buf bytes.Buffer
limit int
truncated bool
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
if room := b.limit - b.buf.Len(); room < len(p) {
b.truncated = true
if room > 0 {
b.buf.Write(p[:room])
}
return len(p), nil
}
return b.buf.Write(p)
}
func (b *cappedBuffer) Bytes() []byte {
return b.buf.Bytes()
}
func (b *cappedBuffer) String() string {
return b.buf.String()
}