From 44a325a9bea5f53c6489cecb3691709306a1814c Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sat, 3 Aug 2019 07:04:41 +0200 Subject: [PATCH] nsscash: add "username"/"passsword" options for files --- README | 4 +++ config.go | 16 +++++++++ fetch.go | 5 ++- file.go | 3 +- main_test.go | 93 +++++++++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 115 insertions(+), 6 deletions(-) diff --git a/README b/README index 4febddf..278f5a1 100644 --- a/README +++ b/README @@ -152,6 +152,10 @@ keys are available: only certificates signed by this CA. Defaults to the system's certificate store when omitted. +- `username`/`password`: Username and password sent via HTTP Basic-Auth to the + webserver. The configuration file must not be readable by other users when + this is used. + - `path`: Path to store the retrieved file diff --git a/config.go b/config.go index 8db49c1..18c6520 100644 --- a/config.go +++ b/config.go @@ -19,6 +19,7 @@ package main import ( "fmt" + "os" "github.com/BurntSushi/toml" ) @@ -33,6 +34,8 @@ type File struct { Url string Path string CA string + Username string + Password string body []byte // internally used by handleFiles() } @@ -72,6 +75,13 @@ func LoadConfig(path string) (*Config, error) { return nil, fmt.Errorf("invalid fields used: %q", undecoded) } + f, err := os.Stat(path) + if err != nil { + return nil, err + } + perms := f.Mode().Perm() + unsafe := (perms & 0077) != 0 // readable by others + if cfg.StatePath == "" { return nil, fmt.Errorf("statepath must not be empty") } @@ -85,6 +95,12 @@ func LoadConfig(path string) (*Config, error) { return nil, fmt.Errorf( "file[%d].path must not be empty", i) } + if (f.Username != "" || f.Password != "") && unsafe { + return nil, fmt.Errorf( + "file[%d].username/passsword in use and "+ + "unsafe permissions %v on %q", + i, perms, path) + } } return &cfg, nil diff --git a/fetch.go b/fetch.go index 491720a..16e8d90 100644 --- a/fetch.go +++ b/fetch.go @@ -36,11 +36,14 @@ func init() { clients[""] = &http.Client{} } -func fetchIfModified(url, ca string, lastModified *time.Time) (int, []byte, error) { +func fetchIfModified(url, user, pass, ca string, lastModified *time.Time) (int, []byte, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return 0, nil, err } + if user != "" || pass != "" { + req.SetBasicAuth(user, pass) + } if !lastModified.IsZero() { req.Header.Add("If-Modified-Since", lastModified.UTC().Format(http.TimeFormat)) diff --git a/file.go b/file.go index 07ac987..e06a9bc 100644 --- a/file.go +++ b/file.go @@ -89,7 +89,8 @@ func fetchFile(file *File, state *State) error { t = zero // force download } - status, body, err := fetchIfModified(file.Url, file.CA, &t) + status, body, err := fetchIfModified(file.Url, + file.Username, file.Password, file.CA, &t) if err != nil { return err } diff --git a/main_test.go b/main_test.go index ee79db9..181930f 100644 --- a/main_test.go +++ b/main_test.go @@ -66,6 +66,12 @@ func mustNotExist(t *testing.T, paths ...string) { } } +func hashAsHex(x []byte) string { + h := sha1.New() + h.Write(x) + return hex.EncodeToString(h.Sum(nil)) +} + // mustHaveHash checks if the given path content has the given SHA-1 string // (in hex). func mustHaveHash(t *testing.T, path string, hash string) { @@ -74,10 +80,7 @@ func mustHaveHash(t *testing.T, path string, hash string) { t.Fatal(err) } - h := sha1.New() - h.Write(x) - y := hex.EncodeToString(h.Sum(nil)) - + y := hashAsHex(x) if y != hash { t.Errorf("%q has unexpected hash %q", path, y) } @@ -212,6 +215,7 @@ func TestMainFetch(t *testing.T) { fetchStateCannotWrite, fetchCannotDeploy, fetchSecondFetchFails, + fetchBasicAuth, } // HTTP tests @@ -795,6 +799,87 @@ ca = "%[5]s" mustBeOld(t, passwdPath, groupPath) } +func fetchBasicAuth(a args) { + t := a.t + mustWritePasswdConfig(t, a.url) + mustCreate(t, passwdPath) + mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709") + + validUser := "username" + validPass := "password" + + *a.handler = func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/passwd" { + return + } + + user, pass, ok := r.BasicAuth() + // NOTE: Do not use this in production because it permits + // attackers to determine the length of user/pass. Instead use + // hashes and subtle.ConstantTimeCompare(). + if !ok || user != validUser || pass != validPass { + w.Header().Set("WWW-Authenticate", `Basic realm="Test"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash") + fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin") + } + + t.Log("Missing authentication") + + err := mainFetch(configPath) + mustBeErrorWithSubstring(t, err, + "status code 401") + + mustNotExist(t, statePath, groupPath, plainPath) + mustBeOld(t, passwdPath) + + t.Log("Unsafe config permissions") + + mustWriteConfig(t, fmt.Sprintf(` +statepath = "%[1]s" + +[[file]] +type = "passwd" +url = "%[2]s/passwd" +path = "%[3]s" +ca = "%[4]s" +username = "%[5]s" +password = "%[6]s" +`, statePath, a.url, passwdPath, tlsCAPath, validUser, validPass)) + + err = os.Chmod(configPath, 0644) + if err != nil { + t.Fatal(err) + } + + err = mainFetch(configPath) + mustBeErrorWithSubstring(t, err, + "file[0].username/passsword in use and unsafe permissions "+ + "-rw-r--r-- on \"testdata/config.toml\"") + + mustNotExist(t, statePath, groupPath, plainPath) + mustBeOld(t, passwdPath) + + t.Log("Working authentication") + + err = os.Chmod(configPath, 0600) + if err != nil { + t.Fatal(err) + } + + err = mainFetch(configPath) + if err != nil { + t.Error(err) + } + + mustNotExist(t, plainPath, groupPath) + mustBeNew(t, passwdPath, statePath) + mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23") +} + func fetchInvalidCA(a args) { t := a.t -- 2.45.2