1 // Copyright (C) 2019 Simon Ruderich
3 // This program is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU Affero General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU Affero General Public License for more details.
13 // You should have received a copy of the GNU Affero General Public License
14 // along with this program. If not, see <https://www.gnu.org/licenses/>.
37 configPath = "testdata/config.toml"
38 statePath = "testdata/var/state.json"
39 passwdPath = "testdata/passwd.nsscash"
40 plainPath = "testdata/plain"
41 groupPath = "testdata/group.nsscash"
42 tlsCAPath = "testdata/ca.crt"
43 tlsCertPath = "testdata/server.crt"
44 tlsKeyPath = "testdata/server.key"
45 tlsCA2Path = "testdata/ca2.crt"
51 handler *func(http.ResponseWriter, *http.Request)
54 // mustNotExist verifies that all given paths don't exist in the file system.
55 func mustNotExist(t *testing.T, paths ...string) {
56 for _, p := range paths {
59 if !os.IsNotExist(err) {
60 t.Errorf("path %q: unexpected error: %v",
64 t.Errorf("path %q exists", p)
70 func hashAsHex(x []byte) string {
73 return hex.EncodeToString(h.Sum(nil))
76 // mustHaveHash checks if the given path content has the given SHA-1 string
78 func mustHaveHash(t *testing.T, path string, hash string) {
79 x, err := ioutil.ReadFile(path)
86 t.Errorf("%q has unexpected hash %q", path, y)
90 // mustBeErrorWithSubstring checks if the given error, represented as string,
91 // contains the given substring. This is somewhat ugly but the simplest way to
92 // check for proper errors.
93 func mustBeErrorWithSubstring(t *testing.T, err error, substring string) {
95 t.Errorf("err is nil")
96 } else if !strings.Contains(err.Error(), substring) {
97 t.Errorf("err %q does not contain string %q", err, substring)
101 func mustWriteConfig(t *testing.T, config string) {
102 err := ioutil.WriteFile(configPath, []byte(config), 0644)
108 func mustWritePasswdConfig(t *testing.T, url string) {
109 mustWriteConfig(t, fmt.Sprintf(`
117 `, statePath, url, passwdPath, tlsCAPath))
120 func mustWriteGroupConfig(t *testing.T, url string) {
121 mustWriteConfig(t, fmt.Sprintf(`
129 `, statePath, url, groupPath, tlsCAPath))
132 // mustCreate creates a file, truncating it if it exists. It then changes the
133 // modification to be in the past.
134 func mustCreate(t *testing.T, path string) {
135 f, err := os.Create(path)
144 // Change modification time to the past to detect updates to the file
148 // mustMakeOld change the modification time of all paths to be in the past.
149 func mustMakeOld(t *testing.T, paths ...string) {
150 old := time.Now().Add(-2 * time.Hour)
151 for _, p := range paths {
152 err := os.Chtimes(p, old, old)
159 // mustMakeOld verifies that all paths have a modification time in the past,
160 // as set by mustMakeOld().
161 func mustBeOld(t *testing.T, paths ...string) {
162 for _, p := range paths {
170 if now.Sub(mtime) < time.Hour {
171 t.Errorf("%q was recently modified", p)
176 // mustBeNew verifies that all paths have a modification time in the present.
177 func mustBeNew(t *testing.T, paths ...string) {
178 for _, p := range paths {
186 if now.Sub(mtime) > time.Hour {
187 t.Errorf("%q was not recently modified", p)
192 func TestMainFetch(t *testing.T) {
193 // Suppress log messages
194 log.SetOutput(ioutil.Discard)
195 defer log.SetOutput(os.Stderr)
197 tests := []func(args){
198 // Perform most tests with passwd for simplicity
199 fetchPasswdCacheFileDoesNotExist,
205 // Tests for plain and group
214 fetchStateCannotRead,
216 fetchStateCannotWrite,
218 fetchSecondFetchFails,
224 for _, f := range tests {
225 runMainTest(t, f, nil)
230 tests = append(tests, fetchInvalidCA)
232 cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath)
237 Certificates: []tls.Certificate{cert},
240 for _, f := range tests {
241 runMainTest(t, f, tls)
245 func runMainTest(t *testing.T, f func(args), tls *tls.Config) {
254 // NOTE: This is not guaranteed to work according to reflect's
255 // documentation but seems to work reliable for normal functions.
256 fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
258 name = name[strings.LastIndex(name, ".")+1:]
263 t.Run(name, func(t *testing.T) {
264 // Preparation & cleanup
265 for _, p := range cleanup {
267 if err != nil && !os.IsNotExist(err) {
270 // Remove the file at the end of this test run, if it
274 dir := filepath.Dir(p)
275 err = os.MkdirAll(dir, 0755)
279 defer os.Remove(dir) // remove empty directories
282 var handler func(http.ResponseWriter, *http.Request)
283 ts := httptest.NewUnstartedServer(http.HandlerFunc(
284 func(w http.ResponseWriter, r *http.Request) {
303 func fetchPasswdCacheFileDoesNotExist(a args) {
305 mustWritePasswdConfig(t, a.url)
307 err := mainFetch(configPath)
308 mustBeErrorWithSubstring(t, err,
309 "file.path \""+passwdPath+"\" must exist")
311 mustNotExist(t, statePath, passwdPath, plainPath, groupPath)
314 func fetchPasswd404(a args) {
316 mustWritePasswdConfig(t, a.url)
317 mustCreate(t, passwdPath)
319 *a.handler = func(w http.ResponseWriter, r *http.Request) {
321 w.WriteHeader(http.StatusNotFound)
324 err := mainFetch(configPath)
325 mustBeErrorWithSubstring(t, err,
328 mustNotExist(t, statePath, plainPath, groupPath)
329 mustBeOld(a.t, passwdPath)
332 func fetchPasswdEmpty(a args) {
334 mustWritePasswdConfig(t, a.url)
335 mustCreate(t, passwdPath)
337 *a.handler = func(w http.ResponseWriter, r *http.Request) {
341 err := mainFetch(configPath)
342 mustBeErrorWithSubstring(t, err,
343 "refusing to use empty passwd file")
345 mustNotExist(t, statePath, plainPath, groupPath)
346 mustBeOld(t, passwdPath)
349 func fetchPasswdInvalid(a args) {
351 mustWritePasswdConfig(t, a.url)
352 mustCreate(t, passwdPath)
354 *a.handler = func(w http.ResponseWriter, r *http.Request) {
355 if r.URL.Path != "/passwd" {
359 fmt.Fprintln(w, "root:x:invalid:0:root:/root:/bin/bash")
362 err := mainFetch(configPath)
363 mustBeErrorWithSubstring(t, err,
364 "invalid uid in line")
366 mustNotExist(t, statePath, plainPath, groupPath)
367 mustBeOld(t, passwdPath)
370 func fetchPasswdLimits(a args) {
372 mustWritePasswdConfig(t, a.url)
373 mustCreate(t, passwdPath)
375 *a.handler = func(w http.ResponseWriter, r *http.Request) {
376 if r.URL.Path != "/passwd" {
380 fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash")
381 for i := 0; i < 65536; i++ {
387 err := mainFetch(configPath)
388 mustBeErrorWithSubstring(t, err,
389 "passwd too large to serialize")
391 mustNotExist(t, statePath, plainPath, groupPath)
392 mustBeOld(t, passwdPath)
395 func fetchPasswd(a args) {
397 mustWritePasswdConfig(t, a.url)
398 mustCreate(t, passwdPath)
399 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
401 t.Log("First fetch, write files")
403 *a.handler = func(w http.ResponseWriter, r *http.Request) {
404 if r.URL.Path != "/passwd" {
408 // No "Last-Modified" header
409 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
410 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
413 err := mainFetch(configPath)
418 mustNotExist(t, plainPath, groupPath)
419 mustBeNew(t, passwdPath, statePath)
420 // The actual content of passwdPath is verified by the NSS tests
421 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
423 t.Log("Fetch again, no support for Last-Modified")
425 mustMakeOld(t, passwdPath, statePath)
427 err = mainFetch(configPath)
432 mustNotExist(t, plainPath, groupPath)
433 mustBeNew(t, passwdPath, statePath)
434 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
436 t.Log("Fetch again, support for Last-Modified, but not retrieved yet")
438 mustMakeOld(t, passwdPath, statePath)
440 lastChange := time.Now()
442 *a.handler = func(w http.ResponseWriter, r *http.Request) {
443 if r.URL.Path != "/passwd" {
447 modified := r.Header.Get("If-Modified-Since")
449 x, err := http.ParseTime(modified)
451 t.Fatalf("invalid If-Modified-Since %v",
454 if !x.Before(lastChange.Truncate(time.Second)) {
455 w.WriteHeader(http.StatusNotModified)
460 w.Header().Add("Last-Modified",
461 lastChange.UTC().Format(http.TimeFormat))
462 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
463 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
465 fmt.Fprintln(w, "bin:x:2:2:bin:/bin:/usr/sbin/nologin")
469 err = mainFetch(configPath)
474 mustNotExist(t, plainPath, groupPath)
475 mustBeNew(t, passwdPath, statePath)
476 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
478 t.Log("Fetch again, support for Last-Modified")
480 mustMakeOld(t, passwdPath, statePath)
482 err = mainFetch(configPath)
487 mustNotExist(t, plainPath, groupPath)
488 mustBeOld(t, passwdPath)
489 mustBeNew(t, statePath)
490 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
492 t.Log("Corrupt local passwd cache, fetched again")
494 os.Chmod(passwdPath, 0644) // make writable again
495 mustCreate(t, passwdPath)
496 mustMakeOld(t, passwdPath, statePath)
498 err = mainFetch(configPath)
503 mustNotExist(t, plainPath, groupPath)
504 mustBeNew(t, passwdPath, statePath)
505 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
507 t.Log("Fetch again with newer server response")
510 lastChange = time.Now().Add(time.Second)
512 mustMakeOld(t, passwdPath, statePath)
514 err = mainFetch(configPath)
519 mustNotExist(t, plainPath, groupPath)
520 mustBeNew(t, passwdPath, statePath)
521 mustHaveHash(t, passwdPath, "ca9c7477cb425667fc9ecbd79e8e1c2ad0e84423")
524 func fetchPlainEmpty(a args) {
526 mustWriteConfig(t, fmt.Sprintf(`
534 `, statePath, a.url, plainPath, tlsCAPath))
535 mustCreate(t, plainPath)
537 *a.handler = func(w http.ResponseWriter, r *http.Request) {
541 err := mainFetch(configPath)
542 mustBeErrorWithSubstring(t, err,
543 "refusing to use empty response")
545 mustNotExist(t, statePath, passwdPath, groupPath)
546 mustBeOld(t, plainPath)
549 func fetchPlain(a args) {
551 mustWriteConfig(t, fmt.Sprintf(`
559 `, statePath, a.url, plainPath, tlsCAPath))
560 mustCreate(t, plainPath)
561 mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
563 *a.handler = func(w http.ResponseWriter, r *http.Request) {
564 if r.URL.Path != "/plain" {
568 fmt.Fprintln(w, "some file")
571 err := mainFetch(configPath)
576 mustNotExist(t, passwdPath, groupPath)
577 mustBeNew(t, plainPath, statePath)
578 mustHaveHash(t, plainPath, "0e08b5e8c10abc3e455b75286ba4a1fbd56e18a5")
580 // Remaining functionality already tested in fetchPasswd()
583 func fetchGroupEmpty(a args) {
585 mustWriteGroupConfig(t, a.url)
586 mustCreate(t, groupPath)
588 *a.handler = func(w http.ResponseWriter, r *http.Request) {
592 err := mainFetch(configPath)
593 mustBeErrorWithSubstring(t, err,
594 "refusing to use empty group file")
596 mustNotExist(t, statePath, passwdPath, plainPath)
597 mustBeOld(t, groupPath)
600 func fetchGroupInvalid(a args) {
602 mustWriteGroupConfig(t, a.url)
603 mustCreate(t, groupPath)
605 *a.handler = func(w http.ResponseWriter, r *http.Request) {
606 if r.URL.Path != "/group" {
610 fmt.Fprintln(w, "root:x::")
613 err := mainFetch(configPath)
614 mustBeErrorWithSubstring(t, err,
615 "invalid gid in line")
617 mustNotExist(t, statePath, passwdPath, plainPath)
618 mustBeOld(t, groupPath)
621 func fetchGroupLimits(a args) {
623 mustWriteGroupConfig(t, a.url)
624 mustCreate(t, groupPath)
626 *a.handler = func(w http.ResponseWriter, r *http.Request) {
627 if r.URL.Path != "/group" {
631 fmt.Fprint(w, "root:x:0:")
632 for i := 0; i < 65536; i++ {
638 err := mainFetch(configPath)
639 mustBeErrorWithSubstring(t, err,
640 "group too large to serialize")
642 mustNotExist(t, statePath, passwdPath, plainPath)
643 mustBeOld(t, groupPath)
646 func fetchGroup(a args) {
648 mustWriteGroupConfig(t, a.url)
649 mustCreate(t, groupPath)
650 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
652 *a.handler = func(w http.ResponseWriter, r *http.Request) {
653 if r.URL.Path != "/group" {
657 fmt.Fprintln(w, "root:x:0:")
658 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
661 err := mainFetch(configPath)
666 mustNotExist(t, passwdPath, plainPath)
667 mustBeNew(t, groupPath, statePath)
668 // The actual content of groupPath is verified by the NSS tests
669 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
671 // Remaining functionality already tested in fetchPasswd()
674 func fetchNoConfig(a args) {
677 err := mainFetch(configPath)
678 mustBeErrorWithSubstring(t, err,
679 configPath+": no such file or directory")
681 mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
684 func fetchStateCannotRead(a args) {
686 mustWritePasswdConfig(t, a.url)
688 mustCreate(t, statePath)
689 err := os.Chmod(statePath, 0000)
694 err = mainFetch(configPath)
695 mustBeErrorWithSubstring(t, err,
696 statePath+": permission denied")
698 mustNotExist(t, passwdPath, plainPath, groupPath)
701 func fetchStateInvalid(a args) {
703 mustWriteGroupConfig(t, a.url)
704 mustCreate(t, statePath)
706 err := mainFetch(configPath)
707 mustBeErrorWithSubstring(t, err,
708 "unexpected end of JSON input")
710 mustNotExist(t, groupPath, passwdPath, plainPath)
711 mustBeOld(t, statePath)
714 func fetchStateCannotWrite(a args) {
716 mustWriteGroupConfig(t, a.url)
717 mustCreate(t, groupPath)
718 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
720 *a.handler = func(w http.ResponseWriter, r *http.Request) {
721 if r.URL.Path != "/group" {
725 fmt.Fprintln(w, "root:x:0:")
726 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
729 err := os.Chmod(filepath.Dir(statePath), 0500)
733 defer os.Chmod(filepath.Dir(statePath), 0755)
735 err = mainFetch(configPath)
736 mustBeErrorWithSubstring(t, err,
739 mustNotExist(t, statePath, passwdPath, plainPath)
740 mustBeNew(t, groupPath)
741 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
744 func fetchCannotDeploy(a args) {
746 mustWriteGroupConfig(t, a.url)
747 mustCreate(t, groupPath)
748 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
750 *a.handler = func(w http.ResponseWriter, r *http.Request) {
751 if r.URL.Path != "/group" {
755 fmt.Fprintln(w, "root:x:0:")
756 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
759 err := os.Chmod("testdata", 0500)
763 defer os.Chmod("testdata", 0755)
765 err = mainFetch(configPath)
766 mustBeErrorWithSubstring(t, err,
769 mustNotExist(t, statePath, passwdPath, plainPath)
770 mustBeOld(t, groupPath)
773 func fetchSecondFetchFails(a args) {
775 mustWriteConfig(t, fmt.Sprintf(`
789 `, statePath, a.url, passwdPath, groupPath, tlsCAPath))
790 mustCreate(t, passwdPath)
791 mustCreate(t, groupPath)
792 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
793 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
795 *a.handler = func(w http.ResponseWriter, r *http.Request) {
796 if r.URL.Path == "/passwd" {
797 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
799 if r.URL.Path == "/group" {
800 w.WriteHeader(http.StatusNotFound)
804 err := mainFetch(configPath)
805 mustBeErrorWithSubstring(t, err,
808 mustNotExist(t, statePath, plainPath)
809 // Even though passwd was successfully fetched, no files were modified
810 // because the second fetch failed
811 mustBeOld(t, passwdPath, groupPath)
814 func fetchBasicAuth(a args) {
816 mustWritePasswdConfig(t, a.url)
817 mustCreate(t, passwdPath)
818 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
820 validUser := "username"
821 validPass := "password"
823 *a.handler = func(w http.ResponseWriter, r *http.Request) {
824 if r.URL.Path != "/passwd" {
828 user, pass, ok := r.BasicAuth()
829 // NOTE: Do not use this in production because it permits
830 // attackers to determine the length of user/pass. Instead use
831 // hashes and subtle.ConstantTimeCompare().
832 if !ok || user != validUser || pass != validPass {
833 w.Header().Set("WWW-Authenticate", `Basic realm="Test"`)
834 w.WriteHeader(http.StatusUnauthorized)
838 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
839 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
842 t.Log("Missing authentication")
844 err := mainFetch(configPath)
845 mustBeErrorWithSubstring(t, err,
848 mustNotExist(t, statePath, groupPath, plainPath)
849 mustBeOld(t, passwdPath)
851 t.Log("Unsafe config permissions")
853 mustWriteConfig(t, fmt.Sprintf(`
863 `, statePath, a.url, passwdPath, tlsCAPath, validUser, validPass))
865 err = os.Chmod(configPath, 0644)
870 err = mainFetch(configPath)
871 mustBeErrorWithSubstring(t, err,
872 "file[0].username/passsword in use and unsafe permissions "+
873 "-rw-r--r-- on \"testdata/config.toml\"")
875 mustNotExist(t, statePath, groupPath, plainPath)
876 mustBeOld(t, passwdPath)
878 t.Log("Working authentication")
880 err = os.Chmod(configPath, 0600)
885 err = mainFetch(configPath)
890 mustNotExist(t, plainPath, groupPath)
891 mustBeNew(t, passwdPath, statePath)
892 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
895 func fetchInvalidCA(a args) {
900 mustWriteConfig(t, fmt.Sprintf(`
907 `, statePath, a.url, passwdPath))
908 mustCreate(t, passwdPath)
909 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
911 *a.handler = func(w http.ResponseWriter, r *http.Request) {
912 if r.URL.Path == "/passwd" {
913 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
917 err := mainFetch(configPath)
918 mustBeErrorWithSubstring(t, err,
919 "x509: certificate signed by unknown authority")
921 mustNotExist(t, statePath, plainPath, groupPath)
922 mustBeOld(t, passwdPath)
926 mustWriteConfig(t, fmt.Sprintf(`
934 `, statePath, a.url, passwdPath, tlsCA2Path))
935 mustCreate(t, passwdPath)
936 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
938 *a.handler = func(w http.ResponseWriter, r *http.Request) {
939 if r.URL.Path == "/passwd" {
940 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
944 err = mainFetch(configPath)
945 mustBeErrorWithSubstring(t, err,
946 "x509: certificate signed by unknown authority")
948 mustNotExist(t, statePath, plainPath, groupPath)
949 mustBeOld(t, passwdPath)