{"record":{"id":"129730337855a1ad","repo":"juanfont/headscale","slug":"tags-in-ssh-source-cannot-access-user-owned-device","errorCode":null,"errorMessage":"tags in SSH source cannot access user-owned devices","messagePattern":"tags in SSH source cannot access user-owned devices","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/policy/v2/types.go","lineNumber":42,"sourceCode":"\t\"tailscale.com/util/slicesx\"\n)\n\n// Global JSON options for consistent parsing across all struct unmarshaling.\nvar policyJSONOpts = []json.Options{\n\tjson.DefaultOptionsV2(),\n\tjson.MatchCaseInsensitiveNames(true),\n\tjson.RejectUnknownMembers(true),\n}\n\nconst Wildcard = Asterix(0)\n\nvar ErrAutogroupSelfRequiresPerNodeResolution = errors.New(\"autogroup:self requires per-node resolution and cannot be resolved in this context\")\n\nvar ErrUndefinedTagReference = errors.New(\"references undefined tag\")\n\n// SSH validation errors.\nvar (\n\tErrSSHTagSourceToUserDest             = errors.New(\"tags in SSH source cannot access user-owned devices\")\n\tErrSSHUserDestRequiresSameUser        = errors.New(\"user destination requires source to contain only that same user\")\n\tErrSSHAutogroupSelfRequiresUserSource = errors.New(\"autogroup:self destination requires source to contain only users or groups, not tags or autogroup:tagged\")\n\tErrSSHTagSourceToAutogroupMember      = errors.New(\"tags in SSH source cannot access autogroup:member (user-owned devices)\")\n\tErrSSHWildcardDestination             = errors.New(\"wildcard (*) is not supported as SSH destination\")\n\tErrSSHCheckPeriodAboveMax             = errors.New(\"is above the max (168h)\")\n\tErrSSHCheckPeriodNegative             = errors.New(\"must be a positive duration\")\n\tErrSSHCheckPeriodOnNonCheck           = errors.New(\"checkPeriod is only valid with action \\\"check\\\"\")\n\tErrInvalidLocalpart                   = errors.New(\"invalid localpart format, must be localpart:*@<domain>\")\n\tErrSSHUsersMustBeSpecified            = errors.New(\"users must be specified\")\n\tErrSSHUserInvalid                     = errors.New(\"is not valid\")\n\tErrSSHAcceptEnvEmpty                  = errors.New(\"acceptEnv values cannot be empty\")\n\tErrSSHActionMustBeSpecified           = errors.New(\"action must be specified\")\n\tErrSSHActionInvalid                   = errors.New(\"is not a valid action\")\n\tErrSSHDestinationHostAlias            = errors.New(\"invalid dst\")\n\tErrTagNameMustStartWithLetter         = errors.New(\"tag names must start with a letter, after 'tag:'\")\n\tErrGroupMembersCannotBeRecursive      = errors.New(\"group members cannot be recursive\")\n)\n","sourceCodeStart":24,"sourceCodeEnd":60,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/policy/v2/types.go#L24-L60","documentation":"SSH validation error (hscontrol/policy/v2/types.go:2176): an SSH rule's src contains a tag (or autogroup:tagged) while its dst names user-owned devices. Tags denote shared/service identity, and Tailscale's SSH model forbids tag-owned sources from reaching user-owned SSH destinations; the rule must target tagged destinations instead.","triggerScenarios":"\"ssh\": [{\"src\": [\"tag:ci\"], \"dst\": [\"user:alice\"], \"users\": [\"root\"], \"action\": \"accept\"}] — any tag in src with a user (or autogroup:member) dst fails validation with this error naming the offending alias.","commonSituations":"Trying to let a CI/automation tagged node SSH into employees' machines; converting a wildcard SSH rule to explicit entries and mixing tags with user dsts.","solutions":["Point the rule at tagged destinations: dst [\"autogroup:tagged\"] or specific tags","If a human must be on the source side, use users/groups in src instead of the tag","Model tag-to-user access at the network layer (grants) rather than SSH rules"],"exampleFix":"// before\n{\"src\": [\"tag:ci\"], \"dst\": [\"user:alice\"], \"users\": [\"root\"], \"action\": \"accept\"}\n\n// after\n{\"src\": [\"tag:ci\"], \"dst\": [\"tag:server\"], \"users\": [\"deploy\"], \"action\": \"accept\"}","handlingStrategy":"validation","validationCode":"func sshSrcTagsAllowed(sources, dests []string) bool { dstUserOwned := slices.ContainsFunc(dests, func(d string) bool { return !strings.HasPrefix(d, \"tag:\") && d != \"autogroup:tagged\" }); if !dstUserOwned { return true }; return !slices.ContainsFunc(sources, func(s string) bool { return strings.HasPrefix(s, \"tag:\") || s == \"autogroup:tagged\" }) }","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Keep SSH rules tag-to-tag or user-to-user","Model tag-to-user access outside SSH rules","Lint policies for mixed tag-src/user-dst SSH rules"],"tags":["policy","ssh","validation","headscale"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}