juanfont/headscale · error · HTTPError
ErrSSHBindingMismatch
ErrSSHBindingMismatch
Error message
src/dst pair does not match auth session
What it means
Returned when the (src, dst) node pair in the follow-up URL does not match the pair bound to the auth session in the cache (ErrSSHBindingMismatch, 401). The binding check stops an attacker who knows an auth_id from recording a verdict against a different node pair.
Source
Thrown at hscontrol/noise.go:634
// Verify the cached binding matches the (src, dst) pair the
// follow-up URL claims. Without this check an attacker who knew an
// auth_id could submit a follow-up for any other (src, dst) pair
// and have its verdict recorded against that pair instead.
if !auth.IsSSHCheck() {
return nil, NewHTTPError(
http.StatusBadRequest,
"auth session is not for SSH check",
fmt.Errorf("%w: %s", ErrSSHAuthSessionNotBound, authID),
)
}
binding := auth.SSHCheckBinding()
if binding.SrcNodeID != srcNodeID || binding.DstNodeID != dstNodeID {
return nil, NewHTTPError(
http.StatusUnauthorized,
"src/dst pair does not match auth session",
fmt.Errorf(
"%w: cached %d->%d, request %d->%d",
ErrSSHBindingMismatch,
binding.SrcNodeID, binding.DstNodeID,
srcNodeID, dstNodeID,
),
)
}
reqLog.Trace().Caller().Msg("SSH action follow-up")
var verdict types.AuthVerdict
select {
case <-ctx.Done():
// The client disconnected (or its request timed out) before the
// auth session resolved. Return an error so the parked goroutine
// is freed; without this select [noiseServer.sshActionFollowUp] would block
// until the cache eviction callback signalled [types.AuthRequest.FinishAuth], which
// could be up to register_cache_expiration (15 minutes).View on GitHub (pinned to 565fd254d0)
Solutions
- Use the unmodified URL issued for this specific SSH session
- Retry the SSH connection to get a new hold URL with the correct pair and auth_id
- Audit clients that cache tailcfg.SSHAction HoldURLs across sessions
Defensive patterns
Strategy: validation
Validate before calling
binding := auth.SSHCheckBinding()
if binding.SrcNodeID != srcNodeID || binding.DstNodeID != dstNodeID {
return errors.New("URL node pair does not match the session binding; use the issued URL verbatim")
} Prevention
- Treat SSH action URLs as atomic — never mix segments from different sessions
- Do not cache HoldURLs across SSH sessions in client code
When it happens
Trigger: Editing the $SRC_NODE_ID/$DST_NODE_ID segments of an SSH check URL while keeping the auth_id; a client reusing one auth_id across different SSH sessions.
Common situations: Manual URL tampering; client-side caching of action URLs; log-driven replay attempts.
Related errors
- ssh action: cached binding does not match request src/dst
- ErrSSHMachineKeyMismatch
- user mismatch
- ssh action: cached auth session is not an SSH-check binding
- authenticated principal is not in any allowed group
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/d41d997a60abd1db.
Report an issue: GitHub.