policy/v2: accept bare-IP sshTests dst, reject only explicit CIDR

SaaS accepts dst: ["100.64.0.16"] and dst: ["fd7a:115c:a1e0::10"] as
host addresses but rejects dst: ["10.0.0.0/24"]. The earlier typed-Alias
switch rejected every *Prefix and so dropped the bare-IP shape that the
Prefix parser materialises as a /BitLen prefix.

validateSSHTestDestination now distinguishes by *Prefix mask width:
Bits() == Addr().BitLen() accepts (single host, equivalent to bare IP),
anything narrower rejects with the existing ErrSSHTestDstDisallowedElement
wording. The Host branch already used the same width check for
hosts:-table aliases.

Adds two captures for the new shapes (bare IPv4 / IPv6) and parse-time
rows for the same in types_test. The IPv6 capture lands a SaaS-side
engine asymmetry (parse-accept, engine-reject "test(s) failed" while
the IPv4 mirror engine-passes) so it is parked in
knownSSHTesterDivergences for a follow-up.
This commit is contained in:
Kristoffer Dalby
2026-05-13 10:56:24 +00:00
parent 2865926028
commit 87c6d9b68e
5 changed files with 37569 additions and 12 deletions
+12 -1
View File
@@ -31,7 +31,18 @@ import (
// knownSSHTesterDivergences tracks scenarios where headscale and SaaS
// disagree on whether a policy is accepted. Each entry should describe
// the engine area a follow-up PR needs to touch.
var knownSSHTesterDivergences = map[string]string{}
var knownSSHTesterDivergences = map[string]string{
// SaaS parse-accepts a bare IPv6 sshTests dst but engine-rejects
// the same input with "test(s) failed" while the matching IPv4
// scenario engine-passes. The two captures share the same topology
// and the same policy shape, so the asymmetry is in the SaaS
// sshTests evaluator's IPv6 handling, not in any rule the user
// wrote. Headscale's evaluator resolves both literals to the
// tagged node that carries them and the assertion passes — a
// follow-up needs to either reproduce the SaaS-side IPv6 quirk or
// confirm this is a SaaS bug we will not match.
"sshtest-malformed-dst-bare-ipv6": "engine: SaaS rejects bare IPv6 sshTests dst; headscale accepts (IPv4 mirror passes both sides)",
}
func TestSSHTesterCompat(t *testing.T) {
t.Parallel()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -11
View File
@@ -3469,11 +3469,12 @@ func validateSSHTests(pol *Policy, tests []SSHPolicyTest) error {
// validateSSHTestDestination enforces that an sshTests dst entry names a
// single SSH-reachable host. Tailscale SaaS rejects three shapes at parse
// time: a `:port` suffix (read by the parser as an unknown tag, hence the
// "unknown tag" error wording); a CIDR-shaped value (raw `/N` or a
// `hosts:` entry whose RHS is a multi-host prefix); and autogroup:internet
// (only valid in ACL destinations, not SSH ones). Tag entries must
// reference a tag that exists in tagOwners; bare hosts must resolve to a
// single-address prefix.
// "unknown tag" error wording); a multi-host CIDR (raw `/N` narrower than
// the address width, or a `hosts:` entry whose RHS is a multi-host prefix);
// and autogroup:internet (only valid in ACL destinations, not SSH ones).
// A bare IP literal — which parses to a `/BitLen` prefix — names a single
// host and is accepted. Tag entries must reference a tag that exists in
// tagOwners; bare hosts must resolve to a single-address prefix.
func validateSSHTestDestination(pol *Policy, alias Alias) error {
dst := alias.String()
@@ -3487,12 +3488,16 @@ func validateSSHTestDestination(pol *Policy, alias Alias) error {
}
case *Prefix:
// IP / CIDR dsts are rejected: SSH dsts name hosts, tags, or
// SSH-compatible autogroups, not raw addresses. Bare IPs and
// `/BitLen` literals parse to the same Prefix value so this
// branch covers both with one disallowed-element answer.
_ = a
return fmt.Errorf("%w %q", ErrSSHTestDstDisallowedElement, dst)
// SaaS accepts a bare IP (parsed to a `/BitLen` prefix) as a
// single SSH-reachable host but rejects a narrower CIDR like
// `10.0.0.0/24`. Distinguish the two by mask width: a prefix
// whose Bits() matches the address BitLen() is a single host
// and passes; anything narrower is a multi-host range and is
// rejected the same way as raw `/N`.
p := netip.Prefix(*a)
if p.Bits() < p.Addr().BitLen() {
return fmt.Errorf("%w %q", ErrSSHTestDstDisallowedElement, dst)
}
case *Tag:
// A tag must be declared in tagOwners. The `tag:server:22` shape
+46
View File
@@ -6089,6 +6089,52 @@ func TestUnmarshalPolicySSHTests(t *testing.T) {
`,
wantErr: ErrSSHTestDstDisallowedElement,
},
{
// SaaS accepts a bare IPv4 literal as a host address. The
// Prefix parser turns it into a /32 so validateSSHTestDestination
// must match Bits() against Addr().BitLen() rather than reject
// the whole *Prefix branch.
name: "dst-bare-ipv4-accepted",
input: `
{
"tagOwners": {"tag:server": ["admin@example.org"]},
"sshTests": [
{"src": "thor@example.org", "dst": ["100.64.0.16"], "accept": ["root"]}
]
}
`,
check: func(t *testing.T, pol *Policy) {
t.Helper()
require.Len(t, pol.SSHTests, 1)
got := pol.SSHTests[0]
require.Len(t, got.Dst, 1)
pref, ok := got.Dst[0].(*Prefix)
require.True(t, ok, "want *Prefix, got %T", got.Dst[0])
require.Equal(t, "100.64.0.16/32", pref.String())
},
},
{
// IPv6 mirror of the IPv4 case: bare `fd7a::10` parses to
// /128 and must pass the parse-time shape check.
name: "dst-bare-ipv6-accepted",
input: `
{
"tagOwners": {"tag:server": ["admin@example.org"]},
"sshTests": [
{"src": "thor@example.org", "dst": ["fd7a:115c:a1e0::10"], "accept": ["root"]}
]
}
`,
check: func(t *testing.T, pol *Policy) {
t.Helper()
require.Len(t, pol.SSHTests, 1)
got := pol.SSHTests[0]
require.Len(t, got.Dst, 1)
pref, ok := got.Dst[0].(*Prefix)
require.True(t, ok, "want *Prefix, got %T", got.Dst[0])
require.Equal(t, "fd7a:115c:a1e0::10/128", pref.String())
},
},
{
name: "dst-autogroup-internet",
input: `