policy/v2: refuse a port list in a grant ip entry

Tailscale SaaS takes one port range per ip entry; a list was accepted
and expanded. Write each range as an entry of its own.
This commit is contained in:
Kristoffer Dalby committed 2026-10-28 16:35:21 +00:00
1 parent e6e1528859
commit 6de71bdb73
4 files changed
+51 -31

No files matched your search

@@ -171,11 +171,8 @@ var grantSkipReasons = map[string]string{
"via-grant-v62": "FILTER_ADDRESS_FORM: same addresses, different wire form",
// Checks keyed <test_id>/<node>/filter: a listed check that matches
// fails, so a fix must delete its entry.
"grant-dxp-comma-ports/refused": grantPortList,
// Contiguous pinned addresses; TestViaGrantHACompat compares these
// filters by address.
// fails, so a fix must delete its entry. Contiguous pinned addresses;
// TestViaGrantHACompat compares these filters by address.
"via-grant-v64/client-a/filter": srcAddressForm,
"via-grant-v64/client-b/filter": srcAddressForm,
"via-grant-v64/plain/filter": srcAddressForm,
@@ -190,8 +187,6 @@ var grantSkipReasons = map[string]string{
"via-grant-v65/via-r/filter": srcAddressForm,
}
const grantPortList = "GRANT_PORT_LIST: SaaS refuses a comma port list in a grant ip entry (only one port range allowed); headscale accepts it"
// TestGrantsCompat is a data-driven test that loads all GRANT-*.json
// test files captured from Tailscale SaaS and compares headscale's grants
// engine output against the real Tailscale behavior.
+19 -22
View File
@@ -77,6 +77,7 @@ var (
// Grant validation errors.
var (
ErrGrantMissingIPOrApp = errors.New("ip and app can not both be empty")
ErrGrantIPPortList = errors.New("only one port range allowed")
ErrGrantViaNotATag = errors.New("via can only be a tag")
ErrProtocolPortInvalidFormat = errors.New("expected only one colon in Internet protocol and port type")
ErrCapNameInvalidForm = errors.New("capability name must have the form {domain}/{path}")
@@ -965,38 +966,34 @@ func (ve *ProtocolPort) UnmarshalJSON(b []byte) error {
return nil
}
// Only contains a port, no protocol
if !strings.Contains(vs, ":") {
ports, err := parsePortRange(vs)
if err != nil {
return fmt.Errorf("port range %q: %w", vs, err)
protocol, portsPart := ProtocolNameWildcard, vs
// "proto:ports", or ports alone for any protocol.
if strings.Contains(vs, ":") {
parts := strings.Split(vs, ":")
if len(parts) != 2 {
return fmt.Errorf("%w, got: %v(%d)", ErrProtocolPortInvalidFormat, parts, len(parts))
}
ve.Protocol = ProtocolNameWildcard
ve.Ports = ports
protocol, portsPart = Protocol(parts[0]), parts[1]
return nil
err := protocol.validate()
if err != nil {
return err
}
}
parts := strings.Split(vs, ":")
if len(parts) != 2 {
return fmt.Errorf("%w, got: %v(%d)", ErrProtocolPortInvalidFormat, parts, len(parts))
}
protocol := Protocol(parts[0])
err := protocol.validate()
if err != nil {
return err
}
portsPart := parts[1]
ports, err := parsePortRange(portsPart)
if err != nil {
return fmt.Errorf("port range %q: %w", portsPart, err)
}
// Tailscale SaaS takes one range per entry; a list goes in
// entries of its own.
if len(ports) > 1 {
return fmt.Errorf("%w: %q", ErrGrantIPPortList, vs)
}
ve.Protocol = protocol
ve.Ports = ports
+30
View File
@@ -5260,6 +5260,36 @@ func TestUnmarshalGrants(t *testing.T) {
`,
wantErr: "ip and app can not both be empty",
},
{
name: "invalid-grant-ip-port-list",
input: `
{
"grants": [
{
"src": ["*"],
"dst": ["*"],
"ip": ["tcp:80,443"]
}
]
}
`,
wantErr: `only one port range allowed: "tcp:80,443"`,
},
{
name: "invalid-grant-ip-bare-port-list",
input: `
{
"grants": [
{
"src": ["*"],
"dst": ["*"],
"ip": ["80-90,443"]
}
]
}
`,
wantErr: `only one port range allowed: "80-90,443"`,
},
{
name: "valid-grant-empty-sources",
input: `
-2
View File
@@ -321,8 +321,6 @@ var rejectedPolicySkipReasons = map[string]string{
"acl-err11/refused": "ROUTE_HOST_BITS: headscale accepts an autoApprovers route with host bits",
"ssh-check-tag-src/refused": "SSH_CHECK_TAG_SRC: headscale accepts a check rule with a tag source",
"grant-dxp-comma-ports/refused": "GRANT_PORT_LIST: headscale accepts a comma port list in a grant ip entry",
}
const noTailnetDomains = "NO_TAILNET_DOMAINS: headscale does not check user:*@domain or localpart:*@domain against the tailnet's domains"