mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 20:26:47 +02:00
net/dns: use os.Root to prevent path traversal in darwin resolver
The darwinConfigurator writes split DNS resolver files to /etc/resolver/$SUFFIX using os.WriteFile with string concatenation. A crafted MatchDomain value containing path traversal sequences (e.g. "../evil") could write files outside the resolver directory. Use os.OpenRoot to confine all file operations in SetDNS and removeResolverFiles to the resolver directory. os.Root rejects any path component that escapes the root, returning an error instead of following the traversal. Also parametrize the resolver directory path on the struct to enable testing with t.TempDir(), and add tests. As far as I can tell, this would require a malicious controlplane to exploit, but still worth fixing. Updates tailscale/corp#39751 Signed-off-by: Andrew Dunham <andrew@tailscale.com>
This commit is contained in:
parent
b9eac14ef9
commit
33714211c8
@ -5,7 +5,10 @@ package dns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go4.org/mem"
|
||||
"tailscale.com/control/controlknobs"
|
||||
@ -22,15 +25,22 @@ import (
|
||||
//
|
||||
// The health tracker, bus and the knobs may be nil and are ignored on this platform.
|
||||
func NewOSConfigurator(logf logger.Logf, _ *health.Tracker, _ *eventbus.Bus, _ policyclient.Client, _ *controlknobs.Knobs, ifName string) (OSConfigurator, error) {
|
||||
return &darwinConfigurator{logf: logf, ifName: ifName}, nil
|
||||
return &darwinConfigurator{
|
||||
logf: logf,
|
||||
ifName: ifName,
|
||||
resolverDir: "/etc/resolver",
|
||||
resolvConfPath: "/etc/resolv.conf",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// darwinConfigurator is the tailscaled-on-macOS DNS OS configurator that
|
||||
// maintains the Split DNS nameserver entries pointing MagicDNS DNS suffixes
|
||||
// to 100.100.100.100 using the macOS /etc/resolver/$SUFFIX files.
|
||||
type darwinConfigurator struct {
|
||||
logf logger.Logf
|
||||
ifName string
|
||||
logf logger.Logf
|
||||
ifName string
|
||||
resolverDir string // default "/etc/resolver"
|
||||
resolvConfPath string // default "/etc/resolv.conf"
|
||||
}
|
||||
|
||||
func (c *darwinConfigurator) Close() error {
|
||||
@ -51,10 +61,16 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll("/etc/resolver", 0755); err != nil {
|
||||
if err := os.MkdirAll(c.resolverDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(c.resolverDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
var keep map[string]bool
|
||||
|
||||
// Add a dummy file to /etc/resolver with a "search ..." directive if we have
|
||||
@ -70,7 +86,7 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
|
||||
sbuf.WriteString(string(d.WithoutTrailingDot()))
|
||||
}
|
||||
sbuf.WriteString("\n")
|
||||
if err := os.WriteFile("/etc/resolver/"+searchFile, sbuf.Bytes(), 0644); err != nil {
|
||||
if err := root.WriteFile(searchFile, sbuf.Bytes(), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -78,15 +94,34 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
|
||||
for _, d := range cfg.MatchDomains {
|
||||
fileBase := string(d.WithoutTrailingDot())
|
||||
mak.Set(&keep, fileBase, true)
|
||||
fullPath := "/etc/resolver/" + fileBase
|
||||
|
||||
if err := os.WriteFile(fullPath, buf.Bytes(), 0644); err != nil {
|
||||
if !isValidResolverFileName(fileBase) {
|
||||
c.logf("[unexpected] invalid resolver domain %q with slashes or colons", fileBase)
|
||||
return fmt.Errorf("invalid resolver domain %q: must not contain slashes or colons", fileBase)
|
||||
}
|
||||
|
||||
if err := root.WriteFile(fileBase, buf.Bytes(), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return c.removeResolverFiles(func(domain string) bool { return !keep[domain] })
|
||||
}
|
||||
|
||||
func isValidResolverFileName(name string) bool {
|
||||
// Verify that the filename doesn't contain any characters that
|
||||
// might cause issues when used as a filename; os.Root is a
|
||||
// defense against path traversal, but prefer a nice error here
|
||||
// if we can. These aren't valid for domain names anyway.
|
||||
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(name, ":") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetBaseConfig returns the current OS DNS configuration, extracting it from /etc/resolv.conf.
|
||||
// We should really be using the SystemConfiguration framework to get this information, as this
|
||||
// is not a stable public API, and is provided mostly as a compatibility effort with Unix
|
||||
@ -95,9 +130,9 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
|
||||
func (c *darwinConfigurator) GetBaseConfig() (OSConfig, error) {
|
||||
cfg := OSConfig{}
|
||||
|
||||
resolvConf, err := resolvconffile.ParseFile("/etc/resolv.conf")
|
||||
resolvConf, err := resolvconffile.ParseFile(c.resolvConfPath)
|
||||
if err != nil {
|
||||
c.logf("failed to parse /etc/resolv.conf: %v", err)
|
||||
c.logf("failed to parse %s: %v", c.resolvConfPath, err)
|
||||
return cfg, ErrGetBaseConfigNotSupported
|
||||
}
|
||||
|
||||
@ -113,7 +148,7 @@ func (c *darwinConfigurator) GetBaseConfig() (OSConfig, error) {
|
||||
|
||||
if len(cfg.Nameservers) == 0 {
|
||||
// Log a warning in case we couldn't find any nameservers in /etc/resolv.conf.
|
||||
c.logf("no nameservers found in /etc/resolv.conf, DNS resolution might fail")
|
||||
c.logf("no nameservers found in %s, DNS resolution might fail", c.resolvConfPath)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
@ -124,13 +159,19 @@ const macResolverFileHeader = "# Added by tailscaled\n"
|
||||
// removeResolverFiles deletes all files in /etc/resolver for which the shouldDelete
|
||||
// func returns true.
|
||||
func (c *darwinConfigurator) removeResolverFiles(shouldDelete func(domain string) bool) error {
|
||||
dents, err := os.ReadDir("/etc/resolver")
|
||||
root, err := os.OpenRoot(c.resolverDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
dents, err := fs.ReadDir(root.FS(), ".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, de := range dents {
|
||||
if !de.Type().IsRegular() {
|
||||
continue
|
||||
@ -139,8 +180,7 @@ func (c *darwinConfigurator) removeResolverFiles(shouldDelete func(domain string
|
||||
if !shouldDelete(name) {
|
||||
continue
|
||||
}
|
||||
fullPath := "/etc/resolver/" + name
|
||||
contents, err := os.ReadFile(fullPath)
|
||||
contents, err := root.ReadFile(name)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) { // race?
|
||||
continue
|
||||
@ -150,7 +190,7 @@ func (c *darwinConfigurator) removeResolverFiles(shouldDelete func(domain string
|
||||
if !mem.HasPrefix(mem.B(contents), mem.S(macResolverFileHeader)) {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
if err := root.Remove(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
182
net/dns/manager_darwin_test.go
Normal file
182
net/dns/manager_darwin_test.go
Normal file
@ -0,0 +1,182 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package dns
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
func newTestConfigurator(t *testing.T) *darwinConfigurator {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
resolvConf := filepath.Join(dir, "resolv.conf")
|
||||
if err := os.WriteFile(resolvConf, []byte("nameserver 8.8.8.8\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resolverDir := filepath.Join(dir, "resolvers")
|
||||
if err := os.Mkdir(resolverDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &darwinConfigurator{
|
||||
logf: logger.Discard,
|
||||
ifName: "utun99",
|
||||
resolverDir: resolverDir,
|
||||
resolvConfPath: resolvConf,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDNS(t *testing.T) {
|
||||
c := newTestConfigurator(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg OSConfig
|
||||
fileContents map[string]string // path -> expected file contents
|
||||
}{
|
||||
{
|
||||
name: "basic",
|
||||
cfg: OSConfig{
|
||||
Nameservers: []netip.Addr{netip.MustParseAddr("100.100.100.100")},
|
||||
MatchDomains: []dnsname.FQDN{"example.com.", "ts.net."},
|
||||
},
|
||||
fileContents: map[string]string{
|
||||
"example.com": macResolverFileHeader + "nameserver 100.100.100.100\n",
|
||||
"ts.net": macResolverFileHeader + "nameserver 100.100.100.100\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SearchDomains",
|
||||
cfg: OSConfig{
|
||||
Nameservers: []netip.Addr{netip.MustParseAddr("100.100.100.100")},
|
||||
SearchDomains: []dnsname.FQDN{"tail1234.ts.net."},
|
||||
MatchDomains: []dnsname.FQDN{"ts.net."},
|
||||
},
|
||||
fileContents: map[string]string{
|
||||
"ts.net": macResolverFileHeader + "nameserver 100.100.100.100\n",
|
||||
"search.tailscale": macResolverFileHeader + "search tail1234.ts.net\n",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := c.SetDNS(tt.cfg); err != nil {
|
||||
t.Fatalf("SetDNS failed: %v", err)
|
||||
}
|
||||
|
||||
// We want only the expected files in the resolverDir,
|
||||
// and nothing else.
|
||||
files, err := os.ReadDir(c.resolverDir)
|
||||
if err != nil {
|
||||
t.Fatalf("reading resolver directory: %v", err)
|
||||
}
|
||||
|
||||
var fileNames []string
|
||||
for _, f := range files {
|
||||
fileNames = append(fileNames, f.Name())
|
||||
}
|
||||
|
||||
if len(files) != len(tt.fileContents) {
|
||||
t.Fatalf("expected %d resolver files, got %d\ngot: %v\nwant: %v",
|
||||
len(tt.fileContents), len(files),
|
||||
fileNames, slices.Collect(maps.Keys(tt.fileContents)),
|
||||
)
|
||||
}
|
||||
|
||||
// Check each file's contents.
|
||||
for domain, expected := range tt.fileContents {
|
||||
path := filepath.Join(c.resolverDir, domain)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reading resolver file %q: %v", domain, err)
|
||||
}
|
||||
if string(data) != expected {
|
||||
t.Errorf("resolver file %q contents mismatch:\ngot: %q\nwant: %q", domain, string(data), expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDNS_PathTraversal(t *testing.T) {
|
||||
c := newTestConfigurator(t)
|
||||
|
||||
// Use a simple path traversal that tries to escape the resolver
|
||||
// directory. With the previously-vulnerable code (os.WriteFile with string
|
||||
// concatenation), this writes to the parent directory. With the
|
||||
// fix (os.Root), this is rejected.
|
||||
traversals := []dnsname.FQDN{
|
||||
"../evil.",
|
||||
"../../evil.",
|
||||
"sub/../../evil.",
|
||||
}
|
||||
|
||||
for _, traversal := range traversals {
|
||||
cfg := OSConfig{
|
||||
Nameservers: []netip.Addr{netip.MustParseAddr("100.100.100.100")},
|
||||
MatchDomains: []dnsname.FQDN{traversal},
|
||||
}
|
||||
|
||||
if err := c.SetDNS(cfg); err == nil {
|
||||
t.Errorf("SetDNS with MatchDomain %q should have failed, but succeeded", traversal)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no file named "evil" was written in the parent of resolverDir.
|
||||
parent := filepath.Dir(c.resolverDir)
|
||||
if fileExists(filepath.Join(parent, "evil")) {
|
||||
t.Fatal("file 'evil' was written to parent directory via path traversal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveResolverFiles(t *testing.T) {
|
||||
c := newTestConfigurator(t)
|
||||
|
||||
// Write a tailscale-managed file.
|
||||
managed := filepath.Join(c.resolverDir, "ts.net")
|
||||
if err := os.WriteFile(managed, []byte(macResolverFileHeader+"nameserver 100.100.100.100\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write a non-tailscale file that should be left alone.
|
||||
unmanaged := filepath.Join(c.resolverDir, "other.conf")
|
||||
if err := os.WriteFile(unmanaged, []byte("# not ours\nnameserver 8.8.8.8\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Remove all resolver files and verify that only the managed one is removed.
|
||||
if err := c.removeResolverFiles(func(domain string) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if fileExists(managed) {
|
||||
t.Error("managed file should have been removed")
|
||||
}
|
||||
if !fileExists(unmanaged) {
|
||||
t.Error("unmanaged file should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false
|
||||
} else if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
panic("unexpected error checking file existence: " + err.Error())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user