mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
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:
@@ -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()
|
||||
|
||||
+18758
File diff suppressed because it is too large
Load Diff
+18737
File diff suppressed because it is too large
Load Diff
@@ -3318,11 +3318,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()
|
||||
|
||||
@@ -3336,12 +3337,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
|
||||
|
||||
@@ -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: `
|
||||
|
||||
Reference in New Issue
Block a user