api, policy/v2: refuse route prefixes with host bits

Routes are matched as written, so such a prefix never equals the one a
client announces. The approval APIs answer 400 and autoApprovers refuses
it at load and write, with Tailscale's text.
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 17:46:52 +00:00
1 parent fc8a12c950
commit 8c3b615bda
7 files changed
+30 -24

No files matched your search

+5
View File
@@ -429,6 +429,11 @@ func registerNodeAdminOps(api huma.API, b Backend) {
return nil, huma.Error400BadRequest("parsing route", parseErr)
}
maskErr := util.CheckRouteMasked(prefix)
if maskErr != nil {
return nil, huma.Error400BadRequest(prefix.String() + " " + maskErr.Error())
}
// One exit route implies both families, else the client won't
// annotate the node as an exit node.
if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() {
+5
View File
@@ -393,6 +393,11 @@ func parseRoutes(routes []string) ([]netip.Prefix, error) {
return nil, huma.Error400BadRequest("parsing route", err)
}
err = util.CheckRouteMasked(prefix)
if err != nil {
return nil, huma.Error400BadRequest(prefix.String() + " " + err.Error())
}
if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() {
approved = append(approved, tsaddr.AllIPv4(), tsaddr.AllIPv6())
} else {
+1 -16
View File
@@ -354,22 +354,7 @@ func containsDeviceID(devs []apiv2.Device, id string) bool {
// from SaaS, keyed <test_id>/<step>/<i>/<check>, where <i> is the call's index
// in its step. A listed check that matches fails, so a fix must delete its
// entry.
var routesAPICompatSkipReasons = map[string]string{
// The GET after each refused probe reads back what headscale stored.
"routes-t4-admin/probe/0/status": routeHostBitsAccepted,
"routes-t4-admin/probe/1/enabled": routeHostBitsAccepted,
"routes-t4-admin/probe/2/status": routeHostBitsAccepted,
"routes-t4-admin/probe/3/enabled": routeHostBitsAccepted,
"routes-t4-admin/probe/0/v1-status": routeHostBitsAccepted,
"routes-t4-admin/probe/1/v1-enabled": routeHostBitsAccepted,
"routes-t4-admin/probe/2/v1-status": routeHostBitsAccepted,
"routes-t4-admin/probe/3/v1-enabled": routeHostBitsAccepted,
"routes-t5-oauth-routes-scope/probe/0/status": routeHostBitsAccepted,
"routes-t5-oauth-routes-scope/probe/1/enabled": routeHostBitsAccepted,
}
const routeHostBitsAccepted = "ROUTE_HOST_BITS_ACCEPTED: SaaS refuses a route with host bits set with 400, headscale approves it as sent"
var routesAPICompatSkipReasons = map[string]string{}
// routeCall is one routes API answer, as the compat replays compare it.
type routeCall struct {
@@ -204,9 +204,6 @@ func loadACLTestFile(t *testing.T, path string) *testcapture.Capture {
// aclSkipReasons lists checks where headscale differs from SaaS, keyed
// <test_id>[/<node>]/<check>.
var aclSkipReasons = map[string]string{
"acl-err10/refused": routeHostBits,
"acl-err11/refused": routeHostBits,
"acl-n01/beedrill/filter": allowAllForm,
"acl-n01/bulbasaur/filter": allowAllForm,
"acl-n01/charmander/filter": allowAllForm,
@@ -222,7 +219,6 @@ var aclSkipReasons = map[string]string{
}
const (
routeHostBits = "ROUTE_HOST_BITS: SaaS refuses an autoApprovers route with host bits; headscale accepts it"
allowAllForm = "ALLOW_ALL_FORM: SaaS writes the allow-all default's sources as the tailnet ranges, headscale as \"*\""
srcAddressForm = "FILTER_ADDRESS_FORM: SaaS lists contiguous source addresses one by one, headscale as a range"
)
+6 -1
View File
@@ -2888,7 +2888,12 @@ func (p *Policy) validate() error {
}
}
for _, approvers := range p.AutoApprovers.Routes {
for prefix, approvers := range p.AutoApprovers.Routes {
err := util.CheckRouteMasked(prefix)
if err != nil {
errs = append(errs, fmt.Errorf("autoApprovers.routes[%q]: %w", prefix.String(), err))
}
for _, approver := range approvers {
switch approver := approver.(type) {
case *Group:
-3
View File
@@ -316,9 +316,6 @@ var rejectedPolicySkipReasons = map[string]string{
"ssh-e1/refused": noTailnetDomains,
"ssh-e2/refused": noTailnetDomains,
"ssh-malformed-user-localpart-multi-glob/refused": noTailnetDomains,
"acl-err10/refused": "ROUTE_HOST_BITS: headscale accepts an autoApprovers route with host bits",
"acl-err11/refused": "ROUTE_HOST_BITS: headscale accepts an autoApprovers route with host bits",
}
const noTailnetDomains = "NO_TAILNET_DOMAINS: headscale does not check user:*@domain or localpart:*@domain against the tailnet's domains"
+13
View File
@@ -146,6 +146,19 @@ func StringToIPPrefix(prefixes []string) ([]netip.Prefix, error) {
return result, nil
}
var ErrRouteHostBits = errors.New("has non-address bits set")
// CheckRouteMasked refuses a route with bits set past its length: routes are
// matched as written, so it would never equal the masked prefix a client
// announces.
func CheckRouteMasked(p netip.Prefix) error {
if masked := p.Masked(); p != masked {
return fmt.Errorf("%w; expected %s", ErrRouteHostBits, masked)
}
return nil
}
// IPSetAddrIter returns a function that iterates over all the IPs in the [netipx.IPSet].
func IPSetAddrIter(ipSet *netipx.IPSet) iter.Seq[netip.Addr] {
return func(yield func(netip.Addr) bool) {