From: Simon Ruderich <simon@ruderich.org>
Date: Sat, 3 Aug 2019 05:04:41 +0000 (+0200)
Subject: nsscash: add "username"/"passsword" options for files
X-Git-Tag: 0.1~23
X-Git-Url: https://ruderich.org/simon/gitweb/?a=commitdiff_plain;h=44a325a9bea5f53c6489cecb3691709306a1814c;p=nsscash%2Fnsscash.git

nsscash: add "username"/"passsword" options for files
---

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