Files
headscale/hscontrol/policy/v2/utils_test.go
Kristoffer Dalby 49744cd467 policy/v2: accept RFC 3986 bracketed IPv6 in ACL destinations
Headscale rejects IPv6 addresses with square brackets in ACL policy
destinations (e.g. "[fd7a:115c:a1e0::87e1]:80,443"), while Tailscale
SaaS accepts them. The root cause is that splitDestinationAndPort uses
strings.LastIndex(":") which leaves brackets on the destination string,
and netip.ParseAddr does not accept brackets.

Add a bracket-handling branch at the top of splitDestinationAndPort that
uses net.SplitHostPort for RFC 3986 parsing when input starts with "[".
The extracted host is validated with netip.ParseAddr/ParsePrefix to
ensure brackets are only accepted around IP addresses and CIDR prefixes,
not hostnames or other alias types like tags and groups.

Fixes #2754
2026-02-20 21:49:21 +01:00

220 lines
7.0 KiB
Go

package v2
import (
"errors"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/tailcfg"
)
// TestParseDestinationAndPort tests the splitDestinationAndPort function using table-driven tests.
func TestParseDestinationAndPort(t *testing.T) {
testCases := []struct {
input string
wantDst string
wantPort string
wantErrIs error
wantNoError bool
}{
// --- Non-bracketed inputs (existing behavior, unchanged) ---
// Hostnames and tags
{"git-server:*", "git-server", "*", nil, true},
{"example-host-1:*", "example-host-1", "*", nil, true},
{"hostname:80-90", "hostname", "80-90", nil, true},
{"tag:montreal-webserver:80,443", "tag:montreal-webserver", "80,443", nil, true},
{"tag:api-server:443", "tag:api-server", "443", nil, true},
// IPv4 and IPv4 CIDR
{"192.168.1.0/24:22", "192.168.1.0/24", "22", nil, true},
{"10.0.0.1:443", "10.0.0.1", "443", nil, true},
// Bare IPv6 (no brackets) — last colon splits correctly
{"fd7a:115c:a1e0::2:22", "fd7a:115c:a1e0::2", "22", nil, true},
{"fd7a:115c:a1e0::2/128:22", "fd7a:115c:a1e0::2/128", "22", nil, true},
// --- Bracketed IPv6: [addr]:port ---
// Single port
{"[fd7a:115c:a1e0::87e1]:22", "fd7a:115c:a1e0::87e1", "22", nil, true},
{"[::1]:80", "::1", "80", nil, true},
{"[2001:db8::1]:443", "2001:db8::1", "443", nil, true},
{"[fe80::1]:22", "fe80::1", "22", nil, true},
// Multiple ports
{"[fd7a:115c:a1e0::87e1]:80,443", "fd7a:115c:a1e0::87e1", "80,443", nil, true},
{"[::1]:22,80,443", "::1", "22,80,443", nil, true},
// Port range
{"[fd7a:115c:a1e0::2]:80-90", "fd7a:115c:a1e0::2", "80-90", nil, true},
// Wildcard port
{"[fd7a:115c:a1e0::87e1]:*", "fd7a:115c:a1e0::87e1", "*", nil, true},
// Unspecified address [::]
{"[::]:80", "::", "80", nil, true},
{"[::]:*", "::", "*", nil, true},
// Full-length IPv6
{"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:443", "2001:0db8:85a3:0000:0000:8a2e:0370:7334", "443", nil, true},
// --- Bracketed IPv6 CIDR: [addr]/prefix:port ---
{"[fd7a:115c:a1e0::2905]/128:80,443", "fd7a:115c:a1e0::2905/128", "80,443", nil, true},
{"[fd7a:115c:a1e0::1]/128:22", "fd7a:115c:a1e0::1/128", "22", nil, true},
{"[2001:db8::1]/32:443", "2001:db8::1/32", "443", nil, true},
{"[::1]/128:*", "::1/128", "*", nil, true},
{"[fd7a:115c:a1e0::2]/64:80-90", "fd7a:115c:a1e0::2/64", "80-90", nil, true},
{"[::]/0:*", "::/0", "*", nil, true},
// --- Errors: brackets around non-IPv6 ---
// IPv4 in brackets
{"[192.168.1.1]:80", "", "", ErrBracketsNotIPv6, false},
{"[10.0.0.1]:443", "", "", ErrBracketsNotIPv6, false},
{"[192.168.1.1]/32:80", "", "", ErrBracketsNotIPv6, false},
// IPv4 CIDR inside brackets
{"[10.0.0.0/8]:80", "", "", ErrBracketsNotIPv6, false},
// Hostnames in brackets
{"[my-hostname]:80", "", "", ErrBracketsNotIPv6, false},
{"[git-server]:*", "", "", ErrBracketsNotIPv6, false},
// Tags in brackets
{"[tag:server]:80", "", "", ErrBracketsNotIPv6, false},
// --- Errors: CIDR inside brackets (must use [addr]/prefix:port) ---
{"[fd7a:115c:a1e0::2/128]:22", "", "", ErrBracketsNotIPv6, false},
{"[2001:db8::/32]:443", "", "", ErrBracketsNotIPv6, false},
{"[::1/128]:80", "", "", ErrBracketsNotIPv6, false},
// --- Errors: malformed bracket syntax ---
// No port after brackets
{"[::1]", "", "", ErrBracketsNotIPv6, false},
{"[2001:db8::1]", "", "", ErrBracketsNotIPv6, false},
// Empty brackets
{"[]:80", "", "", ErrBracketsNotIPv6, false},
// Missing close bracket
{"[::1", "", "", ErrBracketsNotIPv6, false},
{"[2001:db8::1:80", "", "", ErrBracketsNotIPv6, false},
// Empty port after colon
{"[fd7a:115c:a1e0::1]:", "", "", ErrInputEndsWithColon, false},
{"[::1]:", "", "", ErrInputEndsWithColon, false},
{"[fd7a::1]/128:", "", "", ErrInputEndsWithColon, false},
// Junk after close bracket (not : or /)
{"[::1]blah", "", "", ErrBracketsNotIPv6, false},
{"[::1] :80", "", "", ErrBracketsNotIPv6, false},
// --- Errors: non-bracketed malformed input (unchanged) ---
{"invalidinput", "", "", ErrInputMissingColon, false},
{":invalid", "", "", ErrInputStartsWithColon, false},
{"invalid:", "", "", ErrInputEndsWithColon, false},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
dst, port, err := splitDestinationAndPort(tc.input)
if tc.wantNoError {
if err != nil {
t.Fatalf("splitDestinationAndPort(%q) unexpected error: %v", tc.input, err)
}
if dst != tc.wantDst {
t.Errorf("splitDestinationAndPort(%q) dst = %q, want %q", tc.input, dst, tc.wantDst)
}
if port != tc.wantPort {
t.Errorf("splitDestinationAndPort(%q) port = %q, want %q", tc.input, port, tc.wantPort)
}
return
}
if err == nil {
t.Fatalf("splitDestinationAndPort(%q) = (%q, %q, nil), want error wrapping %v", tc.input, dst, port, tc.wantErrIs)
}
if !errors.Is(err, tc.wantErrIs) {
t.Errorf("splitDestinationAndPort(%q) error = %v, want error wrapping %v", tc.input, err, tc.wantErrIs)
}
})
}
}
func TestParsePort(t *testing.T) {
tests := []struct {
input string
expected uint16
err string
}{
{"80", 80, ""},
{"0", 0, ""},
{"65535", 65535, ""},
{"-1", 0, "port number out of range"},
{"65536", 0, "port number out of range"},
{"abc", 0, "invalid port number"},
{"", 0, "invalid port number"},
}
for _, test := range tests {
result, err := parsePort(test.input)
if err != nil && err.Error() != test.err {
t.Errorf("parsePort(%q) error = %v, expected error = %v", test.input, err, test.err)
}
if err == nil && test.err != "" {
t.Errorf("parsePort(%q) expected error = %v, got nil", test.input, test.err)
}
if result != test.expected {
t.Errorf("parsePort(%q) = %v, expected %v", test.input, result, test.expected)
}
}
}
func TestParsePortRange(t *testing.T) {
tests := []struct {
input string
expected []tailcfg.PortRange
err string
}{
{"80", []tailcfg.PortRange{{First: 80, Last: 80}}, ""},
{"80-90", []tailcfg.PortRange{{First: 80, Last: 90}}, ""},
{"80,90", []tailcfg.PortRange{{First: 80, Last: 80}, {First: 90, Last: 90}}, ""},
{"80-91,92,93-95", []tailcfg.PortRange{{First: 80, Last: 91}, {First: 92, Last: 92}, {First: 93, Last: 95}}, ""},
{"*", []tailcfg.PortRange{tailcfg.PortRangeAny}, ""},
{"80-", nil, "invalid port range format"},
{"-90", nil, "invalid port range format"},
{"80-90,", nil, "invalid port number"},
{"80,90-", nil, "invalid port range format"},
{"80-90,abc", nil, "invalid port number"},
{"80-90,65536", nil, "port number out of range"},
{"80-90,90-80", nil, "invalid port range: first port is greater than last port"},
}
for _, test := range tests {
result, err := parsePortRange(test.input)
if err != nil && err.Error() != test.err {
t.Errorf("parsePortRange(%q) error = %v, expected error = %v", test.input, err, test.err)
}
if err == nil && test.err != "" {
t.Errorf("parsePortRange(%q) expected error = %v, got nil", test.input, test.err)
}
if diff := cmp.Diff(result, test.expected); diff != "" {
t.Errorf("parsePortRange(%q) mismatch (-want +got):\n%s", test.input, diff)
}
}
}