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 // mustBeOld verifies that all paths have a modification time in the past, as
160 // 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,
201 fetchPasswdUnexpected304,
206 // Tests for plain and group
215 fetchStateCannotRead,
217 fetchStateCannotWrite,
219 fetchSecondFetchFails,
225 for _, f := range tests {
226 runMainTest(t, f, nil)
231 tests = append(tests, fetchInvalidCA)
233 cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath)
238 Certificates: []tls.Certificate{cert},
241 for _, f := range tests {
242 runMainTest(t, f, tls)
246 func runMainTest(t *testing.T, f func(args), tls *tls.Config) {
255 // NOTE: This is not guaranteed to work according to reflect's
256 // documentation but seems to work reliable for normal functions.
257 fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
259 name = name[strings.LastIndex(name, ".")+1:]
264 t.Run(name, func(t *testing.T) {
265 // Preparation & cleanup
266 for _, p := range cleanup {
268 if err != nil && !os.IsNotExist(err) {
271 // Remove the file at the end of this test run, if it
275 dir := filepath.Dir(p)
276 err = os.MkdirAll(dir, 0755)
280 defer os.Remove(dir) // remove empty directories
283 var handler func(http.ResponseWriter, *http.Request)
284 ts := httptest.NewUnstartedServer(http.HandlerFunc(
285 func(w http.ResponseWriter, r *http.Request) {
304 func fetchPasswdCacheFileDoesNotExist(a args) {
306 mustWritePasswdConfig(t, a.url)
308 err := mainFetch(configPath)
309 mustBeErrorWithSubstring(t, err,
310 "file.path \""+passwdPath+"\" must exist")
312 mustNotExist(t, statePath, passwdPath, plainPath, groupPath)
315 func fetchPasswd404(a args) {
317 mustWritePasswdConfig(t, a.url)
318 mustCreate(t, passwdPath)
320 *a.handler = func(w http.ResponseWriter, r *http.Request) {
322 w.WriteHeader(http.StatusNotFound)
325 err := mainFetch(configPath)
326 mustBeErrorWithSubstring(t, err,
329 mustNotExist(t, statePath, plainPath, groupPath)
330 mustBeOld(t, passwdPath)
333 func fetchPasswdUnexpected304(a args) {
335 mustWritePasswdConfig(t, a.url)
336 mustCreate(t, passwdPath)
338 *a.handler = func(w http.ResponseWriter, r *http.Request) {
340 w.WriteHeader(http.StatusNotModified)
343 err := mainFetch(configPath)
344 mustBeErrorWithSubstring(t, err,
345 "status code 304 but did not send If-Modified-Since")
347 mustNotExist(t, statePath, plainPath, groupPath)
348 mustBeOld(t, passwdPath)
351 func fetchPasswdEmpty(a args) {
353 mustWritePasswdConfig(t, a.url)
354 mustCreate(t, passwdPath)
356 *a.handler = func(w http.ResponseWriter, r *http.Request) {
360 err := mainFetch(configPath)
361 mustBeErrorWithSubstring(t, err,
362 "refusing to use empty passwd file")
364 mustNotExist(t, statePath, plainPath, groupPath)
365 mustBeOld(t, passwdPath)
368 func fetchPasswdInvalid(a args) {
370 mustWritePasswdConfig(t, a.url)
371 mustCreate(t, passwdPath)
373 *a.handler = func(w http.ResponseWriter, r *http.Request) {
374 if r.URL.Path != "/passwd" {
378 fmt.Fprintln(w, "root:x:invalid:0:root:/root:/bin/bash")
381 err := mainFetch(configPath)
382 mustBeErrorWithSubstring(t, err,
383 "invalid uid in line")
385 mustNotExist(t, statePath, plainPath, groupPath)
386 mustBeOld(t, passwdPath)
389 func fetchPasswdLimits(a args) {
391 mustWritePasswdConfig(t, a.url)
392 mustCreate(t, passwdPath)
394 *a.handler = func(w http.ResponseWriter, r *http.Request) {
395 if r.URL.Path != "/passwd" {
399 fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash")
400 for i := 0; i < 65536; i++ {
406 err := mainFetch(configPath)
407 mustBeErrorWithSubstring(t, err,
408 "passwd too large to serialize")
410 mustNotExist(t, statePath, plainPath, groupPath)
411 mustBeOld(t, passwdPath)
414 func fetchPasswd(a args) {
416 mustWritePasswdConfig(t, a.url)
417 mustCreate(t, passwdPath)
418 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
420 t.Log("First fetch, write files")
422 *a.handler = func(w http.ResponseWriter, r *http.Request) {
423 if r.URL.Path != "/passwd" {
427 // No "Last-Modified" header
428 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
429 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
432 err := mainFetch(configPath)
437 mustNotExist(t, plainPath, groupPath)
438 mustBeNew(t, passwdPath, statePath)
439 // The actual content of passwdPath is verified by the NSS tests
440 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
442 t.Log("Fetch again, no support for Last-Modified")
444 mustMakeOld(t, passwdPath, statePath)
446 err = mainFetch(configPath)
451 mustNotExist(t, plainPath, groupPath)
452 mustBeNew(t, passwdPath, statePath)
453 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
455 t.Log("Fetch again, support for Last-Modified, but not retrieved yet")
457 mustMakeOld(t, passwdPath, statePath)
459 lastChange := time.Now()
461 *a.handler = func(w http.ResponseWriter, r *http.Request) {
462 if r.URL.Path != "/passwd" {
466 modified := r.Header.Get("If-Modified-Since")
468 x, err := http.ParseTime(modified)
470 t.Fatalf("invalid If-Modified-Since %v",
473 if !x.Before(lastChange.Truncate(time.Second)) {
474 w.WriteHeader(http.StatusNotModified)
479 w.Header().Add("Last-Modified",
480 lastChange.UTC().Format(http.TimeFormat))
481 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
482 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
484 fmt.Fprintln(w, "bin:x:2:2:bin:/bin:/usr/sbin/nologin")
488 err = mainFetch(configPath)
493 mustNotExist(t, plainPath, groupPath)
494 mustBeNew(t, passwdPath, statePath)
495 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
497 t.Log("Fetch again, support for Last-Modified")
499 mustMakeOld(t, passwdPath, statePath)
501 err = mainFetch(configPath)
506 mustNotExist(t, plainPath, groupPath)
507 mustBeOld(t, passwdPath)
508 mustBeNew(t, statePath)
509 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
511 t.Log("Corrupt local passwd cache, fetched again")
513 os.Chmod(passwdPath, 0644) // make writable again
514 mustCreate(t, passwdPath)
515 mustMakeOld(t, passwdPath, statePath)
517 err = mainFetch(configPath)
522 mustNotExist(t, plainPath, groupPath)
523 mustBeNew(t, passwdPath, statePath)
524 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
526 t.Log("Fetch again with newer server response")
529 lastChange = time.Now().Add(time.Second)
531 mustMakeOld(t, passwdPath, statePath)
533 err = mainFetch(configPath)
538 mustNotExist(t, plainPath, groupPath)
539 mustBeNew(t, passwdPath, statePath)
540 mustHaveHash(t, passwdPath, "ca9c7477cb425667fc9ecbd79e8e1c2ad0e84423")
543 func fetchPlainEmpty(a args) {
545 mustWriteConfig(t, fmt.Sprintf(`
553 `, statePath, a.url, plainPath, tlsCAPath))
554 mustCreate(t, plainPath)
556 *a.handler = func(w http.ResponseWriter, r *http.Request) {
560 err := mainFetch(configPath)
561 mustBeErrorWithSubstring(t, err,
562 "refusing to use empty response")
564 mustNotExist(t, statePath, passwdPath, groupPath)
565 mustBeOld(t, plainPath)
568 func fetchPlain(a args) {
570 mustWriteConfig(t, fmt.Sprintf(`
578 `, statePath, a.url, plainPath, tlsCAPath))
579 mustCreate(t, plainPath)
580 mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
582 *a.handler = func(w http.ResponseWriter, r *http.Request) {
583 if r.URL.Path != "/plain" {
587 fmt.Fprintln(w, "some file")
590 err := mainFetch(configPath)
595 mustNotExist(t, passwdPath, groupPath)
596 mustBeNew(t, plainPath, statePath)
597 mustHaveHash(t, plainPath, "0e08b5e8c10abc3e455b75286ba4a1fbd56e18a5")
599 // Remaining functionality already tested in fetchPasswd()
602 func fetchGroupEmpty(a args) {
604 mustWriteGroupConfig(t, a.url)
605 mustCreate(t, groupPath)
607 *a.handler = func(w http.ResponseWriter, r *http.Request) {
611 err := mainFetch(configPath)
612 mustBeErrorWithSubstring(t, err,
613 "refusing to use empty group file")
615 mustNotExist(t, statePath, passwdPath, plainPath)
616 mustBeOld(t, groupPath)
619 func fetchGroupInvalid(a args) {
621 mustWriteGroupConfig(t, a.url)
622 mustCreate(t, groupPath)
624 *a.handler = func(w http.ResponseWriter, r *http.Request) {
625 if r.URL.Path != "/group" {
629 fmt.Fprintln(w, "root:x::")
632 err := mainFetch(configPath)
633 mustBeErrorWithSubstring(t, err,
634 "invalid gid in line")
636 mustNotExist(t, statePath, passwdPath, plainPath)
637 mustBeOld(t, groupPath)
640 func fetchGroupLimits(a args) {
642 mustWriteGroupConfig(t, a.url)
643 mustCreate(t, groupPath)
645 *a.handler = func(w http.ResponseWriter, r *http.Request) {
646 if r.URL.Path != "/group" {
650 fmt.Fprint(w, "root:x:0:")
651 for i := 0; i < 65536; i++ {
657 err := mainFetch(configPath)
658 mustBeErrorWithSubstring(t, err,
659 "group too large to serialize")
661 mustNotExist(t, statePath, passwdPath, plainPath)
662 mustBeOld(t, groupPath)
665 func fetchGroup(a args) {
667 mustWriteGroupConfig(t, a.url)
668 mustCreate(t, groupPath)
669 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
671 *a.handler = func(w http.ResponseWriter, r *http.Request) {
672 if r.URL.Path != "/group" {
676 fmt.Fprintln(w, "root:x:0:")
677 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
680 err := mainFetch(configPath)
685 mustNotExist(t, passwdPath, plainPath)
686 mustBeNew(t, groupPath, statePath)
687 // The actual content of groupPath is verified by the NSS tests
688 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
690 // Remaining functionality already tested in fetchPasswd()
693 func fetchNoConfig(a args) {
696 err := mainFetch(configPath)
697 mustBeErrorWithSubstring(t, err,
698 configPath+": no such file or directory")
700 mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
703 func fetchStateCannotRead(a args) {
705 mustWritePasswdConfig(t, a.url)
707 mustCreate(t, statePath)
708 err := os.Chmod(statePath, 0000)
713 err = mainFetch(configPath)
714 mustBeErrorWithSubstring(t, err,
715 statePath+": permission denied")
717 mustNotExist(t, passwdPath, plainPath, groupPath)
720 func fetchStateInvalid(a args) {
722 mustWriteGroupConfig(t, a.url)
723 mustCreate(t, statePath)
725 err := mainFetch(configPath)
726 mustBeErrorWithSubstring(t, err,
727 "unexpected end of JSON input")
729 mustNotExist(t, groupPath, passwdPath, plainPath)
730 mustBeOld(t, statePath)
733 func fetchStateCannotWrite(a args) {
735 mustWriteGroupConfig(t, a.url)
736 mustCreate(t, groupPath)
737 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
739 *a.handler = func(w http.ResponseWriter, r *http.Request) {
740 if r.URL.Path != "/group" {
744 fmt.Fprintln(w, "root:x:0:")
745 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
748 err := os.Chmod(filepath.Dir(statePath), 0500)
752 defer os.Chmod(filepath.Dir(statePath), 0755)
754 err = mainFetch(configPath)
755 mustBeErrorWithSubstring(t, err,
758 mustNotExist(t, statePath, passwdPath, plainPath)
759 mustBeNew(t, groupPath)
760 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
763 func fetchCannotDeploy(a args) {
765 mustWriteGroupConfig(t, a.url)
766 mustCreate(t, groupPath)
767 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
769 *a.handler = func(w http.ResponseWriter, r *http.Request) {
770 if r.URL.Path != "/group" {
774 fmt.Fprintln(w, "root:x:0:")
775 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
778 err := os.Chmod("testdata", 0500)
782 defer os.Chmod("testdata", 0755)
784 err = mainFetch(configPath)
785 mustBeErrorWithSubstring(t, err,
788 mustNotExist(t, statePath, passwdPath, plainPath)
789 mustBeOld(t, groupPath)
792 func fetchSecondFetchFails(a args) {
794 mustWriteConfig(t, fmt.Sprintf(`
808 `, statePath, a.url, passwdPath, groupPath, tlsCAPath))
809 mustCreate(t, passwdPath)
810 mustCreate(t, groupPath)
811 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
812 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
814 *a.handler = func(w http.ResponseWriter, r *http.Request) {
815 if r.URL.Path == "/passwd" {
816 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
818 if r.URL.Path == "/group" {
819 w.WriteHeader(http.StatusNotFound)
823 err := mainFetch(configPath)
824 mustBeErrorWithSubstring(t, err,
827 mustNotExist(t, statePath, plainPath)
828 // Even though passwd was successfully fetched, no files were modified
829 // because the second fetch failed
830 mustBeOld(t, passwdPath, groupPath)
833 func fetchBasicAuth(a args) {
835 mustWritePasswdConfig(t, a.url)
836 mustCreate(t, passwdPath)
837 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
839 validUser := "username"
840 validPass := "password"
842 *a.handler = func(w http.ResponseWriter, r *http.Request) {
843 if r.URL.Path != "/passwd" {
847 user, pass, ok := r.BasicAuth()
848 // NOTE: Do not use this in production because it permits
849 // attackers to determine the length of user/pass. Instead use
850 // hashes and subtle.ConstantTimeCompare().
851 if !ok || user != validUser || pass != validPass {
852 w.Header().Set("WWW-Authenticate", `Basic realm="Test"`)
853 w.WriteHeader(http.StatusUnauthorized)
857 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
858 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
861 t.Log("Missing authentication")
863 err := mainFetch(configPath)
864 mustBeErrorWithSubstring(t, err,
867 mustNotExist(t, statePath, groupPath, plainPath)
868 mustBeOld(t, passwdPath)
870 t.Log("Unsafe config permissions")
872 mustWriteConfig(t, fmt.Sprintf(`
882 `, statePath, a.url, passwdPath, tlsCAPath, validUser, validPass))
884 err = os.Chmod(configPath, 0644)
889 err = mainFetch(configPath)
890 mustBeErrorWithSubstring(t, err,
891 "file[0].username/passsword in use and unsafe permissions "+
892 "-rw-r--r-- on \"testdata/config.toml\"")
894 mustNotExist(t, statePath, groupPath, plainPath)
895 mustBeOld(t, passwdPath)
897 t.Log("Working authentication")
899 err = os.Chmod(configPath, 0600)
904 err = mainFetch(configPath)
909 mustNotExist(t, plainPath, groupPath)
910 mustBeNew(t, passwdPath, statePath)
911 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
914 func fetchInvalidCA(a args) {
919 mustWriteConfig(t, fmt.Sprintf(`
926 `, statePath, a.url, passwdPath))
927 mustCreate(t, passwdPath)
928 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
930 *a.handler = func(w http.ResponseWriter, r *http.Request) {
931 if r.URL.Path == "/passwd" {
932 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
936 err := mainFetch(configPath)
937 mustBeErrorWithSubstring(t, err,
938 "x509: certificate signed by unknown authority")
940 mustNotExist(t, statePath, plainPath, groupPath)
941 mustBeOld(t, passwdPath)
945 mustWriteConfig(t, fmt.Sprintf(`
953 `, statePath, a.url, passwdPath, tlsCA2Path))
954 mustCreate(t, passwdPath)
955 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
957 *a.handler = func(w http.ResponseWriter, r *http.Request) {
958 if r.URL.Path == "/passwd" {
959 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
963 err = mainFetch(configPath)
964 mustBeErrorWithSubstring(t, err,
965 "x509: certificate signed by unknown authority")
967 mustNotExist(t, statePath, plainPath, groupPath)
968 mustBeOld(t, passwdPath)