mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-22 16:24:53 +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
|
// knownSSHTesterDivergences tracks scenarios where headscale and SaaS
|
||||||
// disagree on whether a policy is accepted. Each entry should describe
|
// disagree on whether a policy is accepted. Each entry should describe
|
||||||
// the engine area a follow-up PR needs to touch.
|
// 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) {
|
func TestSSHTesterCompat(t *testing.T) {
|
||||||
t.Parallel()
|
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
|
// validateSSHTestDestination enforces that an sshTests dst entry names a
|
||||||
// single SSH-reachable host. Tailscale SaaS rejects three shapes at parse
|
// 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
|
// 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
|
// "unknown tag" error wording); a multi-host CIDR (raw `/N` narrower than
|
||||||
// `hosts:` entry whose RHS is a multi-host prefix); and autogroup:internet
|
// the address width, or a `hosts:` entry whose RHS is a multi-host prefix);
|
||||||
// (only valid in ACL destinations, not SSH ones). Tag entries must
|
// and autogroup:internet (only valid in ACL destinations, not SSH ones).
|
||||||
// reference a tag that exists in tagOwners; bare hosts must resolve to a
|
// A bare IP literal — which parses to a `/BitLen` prefix — names a single
|
||||||
// single-address prefix.
|
// 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 {
|
func validateSSHTestDestination(pol *Policy, alias Alias) error {
|
||||||
dst := alias.String()
|
dst := alias.String()
|
||||||
|
|
||||||
@@ -3336,12 +3337,16 @@ func validateSSHTestDestination(pol *Policy, alias Alias) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case *Prefix:
|
case *Prefix:
|
||||||
// IP / CIDR dsts are rejected: SSH dsts name hosts, tags, or
|
// SaaS accepts a bare IP (parsed to a `/BitLen` prefix) as a
|
||||||
// SSH-compatible autogroups, not raw addresses. Bare IPs and
|
// single SSH-reachable host but rejects a narrower CIDR like
|
||||||
// `/BitLen` literals parse to the same Prefix value so this
|
// `10.0.0.0/24`. Distinguish the two by mask width: a prefix
|
||||||
// branch covers both with one disallowed-element answer.
|
// whose Bits() matches the address BitLen() is a single host
|
||||||
_ = a
|
// and passes; anything narrower is a multi-host range and is
|
||||||
return fmt.Errorf("%w %q", ErrSSHTestDstDisallowedElement, dst)
|
// 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:
|
case *Tag:
|
||||||
// A tag must be declared in tagOwners. The `tag:server:22` shape
|
// A tag must be declared in tagOwners. The `tag:server:22` shape
|
||||||
|
|||||||
@@ -6089,6 +6089,52 @@ func TestUnmarshalPolicySSHTests(t *testing.T) {
|
|||||||
`,
|
`,
|
||||||
wantErr: ErrSSHTestDstDisallowedElement,
|
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",
|
name: "dst-autogroup-internet",
|
||||||
input: `
|
input: `
|
||||||
|
|||||||
Reference in New Issue
Block a user