stricter hostname validation and replace (#2383)

This commit is contained in:
Kristoffer Dalby
2025-10-22 13:50:39 +02:00
committed by GitHub
parent 2c9e98d3f5
commit 1cdea7ed9b
16 changed files with 888 additions and 193 deletions

View File

@@ -27,7 +27,7 @@ var (
invalidCharsInUserRegex = regexp.MustCompile("[^a-z0-9-.]+")
)
var ErrInvalidUserName = errors.New("invalid user name")
var ErrInvalidHostName = errors.New("invalid hostname")
// ValidateUsername checks if a username is valid.
// It must be at least 2 characters long, start with a letter, and contain
@@ -67,42 +67,86 @@ func ValidateUsername(username string) error {
return nil
}
func CheckForFQDNRules(name string) error {
// Ensure the username meets the minimum length requirement
// ValidateHostname checks if a hostname meets DNS requirements.
// This function does NOT modify the input - it only validates.
// The hostname must already be lowercase and contain only valid characters.
func ValidateHostname(name string) error {
if len(name) < 2 {
return errors.New("name must be at least 2 characters long")
return fmt.Errorf(
"hostname %q is too short, must be at least 2 characters",
name,
)
}
if len(name) > LabelHostnameLength {
return fmt.Errorf(
"DNS segment must not be over 63 chars. %v doesn't comply with this rule: %w",
"hostname %q is too long, must not exceed 63 characters",
name,
ErrInvalidUserName,
)
}
if strings.ToLower(name) != name {
return fmt.Errorf(
"DNS segment should be lowercase. %v doesn't comply with this rule: %w",
"hostname %q must be lowercase (try %q)",
name,
strings.ToLower(name),
)
}
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
return fmt.Errorf(
"hostname %q cannot start or end with a hyphen",
name,
)
}
if strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
return fmt.Errorf(
"hostname %q cannot start or end with a dot",
name,
ErrInvalidUserName,
)
}
if invalidDNSRegex.MatchString(name) {
return fmt.Errorf(
"DNS segment should only be composed of lowercase ASCII letters numbers, hyphen and dots. %v doesn't comply with these rules: %w",
"hostname %q contains invalid characters, only lowercase letters, numbers, hyphens and dots are allowed",
name,
ErrInvalidUserName,
)
}
return nil
}
func ConvertWithFQDNRules(name string) string {
// NormaliseHostname transforms a string into a valid DNS hostname.
// Returns error if the transformation results in an invalid hostname.
//
// Transformations applied:
// - Converts to lowercase
// - Removes invalid DNS characters
// - Truncates to 63 characters if needed
//
// After transformation, validates the result.
func NormaliseHostname(name string) (string, error) {
// Early return if already valid
if err := ValidateHostname(name); err == nil {
return name, nil
}
// Transform to lowercase
name = strings.ToLower(name)
// Strip invalid DNS characters
name = invalidDNSRegex.ReplaceAllString(name, "")
return name
// Truncate to DNS label limit
if len(name) > LabelHostnameLength {
name = name[:LabelHostnameLength]
}
// Validate result after transformation
if err := ValidateHostname(name); err != nil {
return "", fmt.Errorf(
"hostname invalid after normalisation: %w",
err,
)
}
return name, nil
}
// generateMagicDNSRootDomains generates a list of DNS entries to be included in `Routes` in `MapResponse`.

View File

@@ -2,6 +2,7 @@ package util
import (
"net/netip"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -9,94 +10,173 @@ import (
"tailscale.com/util/must"
)
func TestCheckForFQDNRules(t *testing.T) {
func TestNormaliseHostname(t *testing.T) {
type args struct {
name string
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "valid: user",
name: "valid: lowercase user",
args: args{name: "valid-user"},
want: "valid-user",
wantErr: false,
},
{
name: "invalid: capitalized user",
name: "normalise: capitalized user",
args: args{name: "Invalid-CapItaLIzed-user"},
wantErr: true,
want: "invalid-capitalized-user",
wantErr: false,
},
{
name: "invalid: email as user",
name: "normalise: email as user",
args: args{name: "foo.bar@example.com"},
wantErr: true,
want: "foo.barexample.com",
wantErr: false,
},
{
name: "invalid: chars in user name",
name: "normalise: chars in user name",
args: args{name: "super-user+name"},
wantErr: true,
want: "super-username",
wantErr: false,
},
{
name: "invalid: too long name for user",
name: "invalid: too long name truncated leaves trailing hyphen",
args: args{
name: "super-long-useruseruser-name-that-should-be-a-little-more-than-63-chars",
},
want: "",
wantErr: true,
},
{
name: "invalid: emoji stripped leaves trailing hyphen",
args: args{name: "hostname-with-💩"},
want: "",
wantErr: true,
},
{
name: "normalise: multiple emojis stripped",
args: args{name: "node-🎉-🚀-test"},
want: "node---test",
wantErr: false,
},
{
name: "invalid: only emoji becomes empty",
args: args{name: "💩"},
want: "",
wantErr: true,
},
{
name: "invalid: emoji at start leaves leading hyphen",
args: args{name: "🚀-rocket-node"},
want: "",
wantErr: true,
},
{
name: "invalid: emoji at end leaves trailing hyphen",
args: args{name: "node-test-🎉"},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := CheckForFQDNRules(tt.args.name); (err != nil) != tt.wantErr {
t.Errorf("CheckForFQDNRules() error = %v, wantErr %v", err, tt.wantErr)
got, err := NormaliseHostname(tt.args.name)
if (err != nil) != tt.wantErr {
t.Errorf("NormaliseHostname() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("NormaliseHostname() = %v, want %v", got, tt.want)
}
})
}
}
func TestConvertWithFQDNRules(t *testing.T) {
func TestValidateHostname(t *testing.T) {
tests := []struct {
name string
hostname string
dnsHostName string
name string
hostname string
wantErr bool
errorContains string
}{
{
name: "User1.test",
hostname: "User1.Test",
dnsHostName: "user1.test",
name: "valid lowercase",
hostname: "valid-hostname",
wantErr: false,
},
{
name: "User'1$2.test",
hostname: "User'1$2.Test",
dnsHostName: "user12.test",
name: "uppercase rejected",
hostname: "MyHostname",
wantErr: true,
errorContains: "must be lowercase",
},
{
name: "User-^_12.local.test",
hostname: "User-^_12.local.Test",
dnsHostName: "user-12.local.test",
name: "too short",
hostname: "a",
wantErr: true,
errorContains: "too short",
},
{
name: "User-MacBook-Pro",
hostname: "User-MacBook-Pro",
dnsHostName: "user-macbook-pro",
name: "too long",
hostname: "a" + strings.Repeat("b", 63),
wantErr: true,
errorContains: "too long",
},
{
name: "User-Linux-Ubuntu/Fedora",
hostname: "User-Linux-Ubuntu/Fedora",
dnsHostName: "user-linux-ubuntufedora",
name: "emoji rejected",
hostname: "hostname-💩",
wantErr: true,
errorContains: "invalid characters",
},
{
name: "User-[Space]123",
hostname: "User-[ ]123",
dnsHostName: "user-123",
name: "starts with hyphen",
hostname: "-hostname",
wantErr: true,
errorContains: "cannot start or end with a hyphen",
},
{
name: "ends with hyphen",
hostname: "hostname-",
wantErr: true,
errorContains: "cannot start or end with a hyphen",
},
{
name: "starts with dot",
hostname: ".hostname",
wantErr: true,
errorContains: "cannot start or end with a dot",
},
{
name: "ends with dot",
hostname: "hostname.",
wantErr: true,
errorContains: "cannot start or end with a dot",
},
{
name: "special characters",
hostname: "host!@#$name",
wantErr: true,
errorContains: "invalid characters",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fqdnHostName := ConvertWithFQDNRules(tt.hostname)
assert.Equal(t, tt.dnsHostName, fqdnHostName)
err := ValidateHostname(tt.hostname)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateHostname() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr && tt.errorContains != "" {
if err == nil || !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("ValidateHostname() error = %v, should contain %q", err, tt.errorContains)
}
}
})
}
}

View File

@@ -66,6 +66,11 @@ func MustGenerateRandomStringDNSSafe(size int) string {
return hash
}
func InvalidString() string {
hash, _ := GenerateRandomStringDNSSafe(8)
return "invalid-" + hash
}
func TailNodesToString(nodes []*tailcfg.Node) string {
temp := make([]string, len(nodes))

View File

@@ -1,6 +1,7 @@
package util
import (
"cmp"
"errors"
"fmt"
"net/netip"
@@ -264,54 +265,32 @@ func IsCI() bool {
// if Hostinfo is nil or Hostname is empty. This prevents nil pointer dereferences
// and ensures nodes always have a valid hostname.
// The hostname is truncated to 63 characters to comply with DNS label length limits (RFC 1123).
func SafeHostname(hostinfo *tailcfg.Hostinfo, machineKey, nodeKey string) string {
// EnsureHostname guarantees a valid hostname for node registration.
// This function never fails - it always returns a valid hostname.
//
// Strategy:
// 1. If hostinfo is nil/empty → generate default from keys
// 2. If hostname is provided → normalise it
// 3. If normalisation fails → generate invalid-<random> replacement
//
// Returns the guaranteed-valid hostname to use.
func EnsureHostname(hostinfo *tailcfg.Hostinfo, machineKey, nodeKey string) string {
if hostinfo == nil || hostinfo.Hostname == "" {
// Generate a default hostname using machine key prefix
if machineKey != "" {
keyPrefix := machineKey
if len(machineKey) > 8 {
keyPrefix = machineKey[:8]
}
return fmt.Sprintf("node-%s", keyPrefix)
key := cmp.Or(machineKey, nodeKey)
if key == "" {
return "unknown-node"
}
if nodeKey != "" {
keyPrefix := nodeKey
if len(nodeKey) > 8 {
keyPrefix = nodeKey[:8]
}
return fmt.Sprintf("node-%s", keyPrefix)
keyPrefix := key
if len(key) > 8 {
keyPrefix = key[:8]
}
return "unknown-node"
return fmt.Sprintf("node-%s", keyPrefix)
}
hostname := hostinfo.Hostname
// Validate hostname length - DNS label limit is 63 characters (RFC 1123)
// Truncate if necessary to ensure compatibility with given name generation
if len(hostname) > 63 {
hostname = hostname[:63]
lowercased := strings.ToLower(hostinfo.Hostname)
if err := ValidateHostname(lowercased); err == nil {
return lowercased
}
return hostname
}
// EnsureValidHostinfo ensures that Hostinfo is non-nil and has a valid hostname.
// If Hostinfo is nil, it creates a minimal valid Hostinfo with a generated hostname.
// Returns the validated/created Hostinfo and the extracted hostname.
func EnsureValidHostinfo(hostinfo *tailcfg.Hostinfo, machineKey, nodeKey string) (*tailcfg.Hostinfo, string) {
if hostinfo == nil {
hostname := SafeHostname(nil, machineKey, nodeKey)
return &tailcfg.Hostinfo{
Hostname: hostname,
}, hostname
}
hostname := SafeHostname(hostinfo, machineKey, nodeKey)
// Update the hostname in the hostinfo if it was empty or if it was truncated
if hostinfo.Hostname == "" || hostinfo.Hostname != hostname {
hostinfo.Hostname = hostname
}
return hostinfo, hostname
return InvalidString()
}

View File

@@ -3,6 +3,7 @@ package util
import (
"errors"
"net/netip"
"strings"
"testing"
"time"
@@ -795,7 +796,7 @@ over a maximum of 30 hops:
}
}
func TestSafeHostname(t *testing.T) {
func TestEnsureHostname(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -878,7 +879,7 @@ func TestSafeHostname(t *testing.T) {
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "123456789012345678901234567890123456789012345678901234567890123",
want: "invalid-",
},
{
name: "hostname_very_long_truncated",
@@ -887,7 +888,7 @@ func TestSafeHostname(t *testing.T) {
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "test-node-with-very-long-hostname-that-exceeds-dns-label-limits",
want: "invalid-",
},
{
name: "hostname_with_special_chars",
@@ -896,7 +897,7 @@ func TestSafeHostname(t *testing.T) {
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "node-with-special!@#$%",
want: "invalid-",
},
{
name: "hostname_with_unicode",
@@ -905,7 +906,7 @@ func TestSafeHostname(t *testing.T) {
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "node-ñoño-测试",
want: "invalid-",
},
{
name: "short_machine_key",
@@ -925,20 +926,160 @@ func TestSafeHostname(t *testing.T) {
nodeKey: "short",
want: "node-short",
},
{
name: "hostname_with_emoji_replaced",
hostinfo: &tailcfg.Hostinfo{
Hostname: "hostname-with-💩",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "hostname_only_emoji_replaced",
hostinfo: &tailcfg.Hostinfo{
Hostname: "🚀",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "hostname_with_multiple_emojis_replaced",
hostinfo: &tailcfg.Hostinfo{
Hostname: "node-🎉-🚀-test",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "uppercase_to_lowercase",
hostinfo: &tailcfg.Hostinfo{
Hostname: "User2-Host",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "user2-host",
},
{
name: "underscore_removed",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test_node",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "at_sign_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "Test@Host",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "chinese_chars_with_dash_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "server-北京-01",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "chinese_only_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "我的电脑",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "emoji_with_text_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "laptop-🚀",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "mixed_chinese_emoji_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "测试💻机器",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "only_emojis_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "🎉🎊",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "only_at_signs_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "@@@",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "starts_with_dash_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "-test",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "ends_with_dash_invalid",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test-",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
{
name: "very_long_hostname_truncated",
hostinfo: &tailcfg.Hostinfo{
Hostname: strings.Repeat("t", 70),
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
want: "invalid-",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := SafeHostname(tt.hostinfo, tt.machineKey, tt.nodeKey)
if got != tt.want {
t.Errorf("SafeHostname() = %v, want %v", got, tt.want)
got := EnsureHostname(tt.hostinfo, tt.machineKey, tt.nodeKey)
// For invalid hostnames, we just check the prefix since the random part varies
if strings.HasPrefix(tt.want, "invalid-") {
if !strings.HasPrefix(got, "invalid-") {
t.Errorf("EnsureHostname() = %v, want prefix %v", got, tt.want)
}
} else if got != tt.want {
t.Errorf("EnsureHostname() = %v, want %v", got, tt.want)
}
})
}
}
func TestEnsureValidHostinfo(t *testing.T) {
func TestEnsureHostnameWithHostinfo(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -976,14 +1117,6 @@ func TestEnsureValidHostinfo(t *testing.T) {
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
wantHostname: "node-mkey1234",
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
if hi == nil {
t.Error("hostinfo should not be nil")
}
if hi.Hostname != "node-mkey1234" {
t.Errorf("hostname = %v, want node-mkey1234", hi.Hostname)
}
},
},
{
name: "empty_hostname_updated",
@@ -994,37 +1127,15 @@ func TestEnsureValidHostinfo(t *testing.T) {
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
wantHostname: "node-mkey1234",
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
if hi == nil {
t.Error("hostinfo should not be nil")
}
if hi.Hostname != "node-mkey1234" {
t.Errorf("hostname = %v, want node-mkey1234", hi.Hostname)
}
if hi.OS != "darwin" {
t.Errorf("OS = %v, want darwin", hi.OS)
}
},
},
{
name: "long_hostname_truncated",
name: "long_hostname_rejected",
hostinfo: &tailcfg.Hostinfo{
Hostname: "test-node-with-very-long-hostname-that-exceeds-dns-label-limits-of-63-characters",
},
machineKey: "mkey12345678",
nodeKey: "nkey12345678",
wantHostname: "test-node-with-very-long-hostname-that-exceeds-dns-label-limits",
checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
if hi == nil {
t.Error("hostinfo should not be nil")
}
if hi.Hostname != "test-node-with-very-long-hostname-that-exceeds-dns-label-limits" {
t.Errorf("hostname = %v, want truncated", hi.Hostname)
}
if len(hi.Hostname) != 63 {
t.Errorf("hostname length = %v, want 63", len(hi.Hostname))
}
},
wantHostname: "invalid-",
},
{
name: "nil_hostinfo_node_key_only",
@@ -1128,23 +1239,20 @@ func TestEnsureValidHostinfo(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotHostinfo, gotHostname := EnsureValidHostinfo(tt.hostinfo, tt.machineKey, tt.nodeKey)
if gotHostname != tt.wantHostname {
t.Errorf("EnsureValidHostinfo() hostname = %v, want %v", gotHostname, tt.wantHostname)
}
if gotHostinfo == nil {
t.Error("returned hostinfo should never be nil")
}
if tt.checkHostinfo != nil {
tt.checkHostinfo(t, gotHostinfo)
gotHostname := EnsureHostname(tt.hostinfo, tt.machineKey, tt.nodeKey)
// For invalid hostnames, we just check the prefix since the random part varies
if strings.HasPrefix(tt.wantHostname, "invalid-") {
if !strings.HasPrefix(gotHostname, "invalid-") {
t.Errorf("EnsureHostname() = %v, want prefix %v", gotHostname, tt.wantHostname)
}
} else if gotHostname != tt.wantHostname {
t.Errorf("EnsureHostname() hostname = %v, want %v", gotHostname, tt.wantHostname)
}
})
}
}
func TestSafeHostname_DNSLabelLimit(t *testing.T) {
func TestEnsureHostname_DNSLabelLimit(t *testing.T) {
t.Parallel()
testCases := []string{
@@ -1157,7 +1265,7 @@ func TestSafeHostname_DNSLabelLimit(t *testing.T) {
for i, hostname := range testCases {
t.Run(cmp.Diff("", ""), func(t *testing.T) {
hostinfo := &tailcfg.Hostinfo{Hostname: hostname}
result := SafeHostname(hostinfo, "mkey", "nkey")
result := EnsureHostname(hostinfo, "mkey", "nkey")
if len(result) > 63 {
t.Errorf("test case %d: hostname length = %d, want <= 63", i, len(result))
}
@@ -1165,7 +1273,7 @@ func TestSafeHostname_DNSLabelLimit(t *testing.T) {
}
}
func TestEnsureValidHostinfo_Idempotent(t *testing.T) {
func TestEnsureHostname_Idempotent(t *testing.T) {
t.Parallel()
originalHostinfo := &tailcfg.Hostinfo{
@@ -1173,16 +1281,10 @@ func TestEnsureValidHostinfo_Idempotent(t *testing.T) {
OS: "linux",
}
hostinfo1, hostname1 := EnsureValidHostinfo(originalHostinfo, "mkey", "nkey")
hostinfo2, hostname2 := EnsureValidHostinfo(hostinfo1, "mkey", "nkey")
hostname1 := EnsureHostname(originalHostinfo, "mkey", "nkey")
hostname2 := EnsureHostname(originalHostinfo, "mkey", "nkey")
if hostname1 != hostname2 {
t.Errorf("hostnames not equal: %v != %v", hostname1, hostname2)
}
if hostinfo1.Hostname != hostinfo2.Hostname {
t.Errorf("hostinfo hostnames not equal: %v != %v", hostinfo1.Hostname, hostinfo2.Hostname)
}
if hostinfo1.OS != hostinfo2.OS {
t.Errorf("hostinfo OS not equal: %v != %v", hostinfo1.OS, hostinfo2.OS)
}
}