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

  1. Use the unmodified URL issued for this specific SSH session
  2. Retry the SSH connection to get a new hold URL with the correct pair and auth_id
  3. 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

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


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/d41d997a60abd1db. Report an issue: GitHub.