{"record":{"id":"d41d997a60abd1db","repo":"juanfont/headscale","slug":"errsshbindingmismatch","errorCode":"ErrSSHBindingMismatch","errorMessage":"src/dst pair does not match auth session","messagePattern":"src/dst pair does not match auth session","errorType":"http","errorClass":"HTTPError","httpStatus":401,"severity":"error","filePath":"hscontrol/noise.go","lineNumber":634,"sourceCode":"\n\t// Verify the cached binding matches the (src, dst) pair the\n\t// follow-up URL claims. Without this check an attacker who knew an\n\t// auth_id could submit a follow-up for any other (src, dst) pair\n\t// and have its verdict recorded against that pair instead.\n\tif !auth.IsSSHCheck() {\n\t\treturn nil, NewHTTPError(\n\t\t\thttp.StatusBadRequest,\n\t\t\t\"auth session is not for SSH check\",\n\t\t\tfmt.Errorf(\"%w: %s\", ErrSSHAuthSessionNotBound, authID),\n\t\t)\n\t}\n\n\tbinding := auth.SSHCheckBinding()\n\tif binding.SrcNodeID != srcNodeID || binding.DstNodeID != dstNodeID {\n\t\treturn nil, NewHTTPError(\n\t\t\thttp.StatusUnauthorized,\n\t\t\t\"src/dst pair does not match auth session\",\n\t\t\tfmt.Errorf(\n\t\t\t\t\"%w: cached %d->%d, request %d->%d\",\n\t\t\t\tErrSSHBindingMismatch,\n\t\t\t\tbinding.SrcNodeID, binding.DstNodeID,\n\t\t\t\tsrcNodeID, dstNodeID,\n\t\t\t),\n\t\t)\n\t}\n\n\treqLog.Trace().Caller().Msg(\"SSH action follow-up\")\n\n\tvar verdict types.AuthVerdict\n\tselect {\n\tcase <-ctx.Done():\n\t\t// The client disconnected (or its request timed out) before the\n\t\t// auth session resolved. Return an error so the parked goroutine\n\t\t// is freed; without this select [noiseServer.sshActionFollowUp] would block\n\t\t// until the cache eviction callback signalled [types.AuthRequest.FinishAuth], which\n\t\t// could be up to register_cache_expiration (15 minutes).","sourceCodeStart":616,"sourceCodeEnd":652,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/noise.go#L616-L652","documentation":"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.","triggerScenarios":"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.","commonSituations":"Manual URL tampering; client-side caching of action URLs; log-driven replay attempts.","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"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"binding := auth.SSHCheckBinding()\nif binding.SrcNodeID != srcNodeID || binding.DstNodeID != dstNodeID {\n    return errors.New(\"URL node pair does not match the session binding; use the issued URL verbatim\")\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Treat SSH action URLs as atomic — never mix segments from different sessions","Do not cache HoldURLs across SSH sessions in client code"],"tags":["ssh","security","binding-mismatch","authorization"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}