diff --git a/httpbin/digest/digest.go b/httpbin/digest/digest.go index 13c09e1306333184a1049955cafe4c2ef82cb8d7..f679382277a7c7db1eec2ad918c87dca3ca0b6c1 100644 --- a/httpbin/digest/digest.go +++ b/httpbin/digest/digest.go @@ -11,7 +11,6 @@ package digest import ( - "crypto" "crypto/md5" "crypto/sha256" "crypto/subtle" @@ -22,6 +21,25 @@ import ( "time" ) +// digestAlgorithm is an algorithm used to hash digest payloads +type digestAlgorithm int + +// Digest algorithms supported by this package +const ( + MD5 digestAlgorithm = iota + SHA256 +) + +func (a digestAlgorithm) String() string { + switch a { + case MD5: + return "MD5" + case SHA256: + return "SHA-256" + } + return "UNKNOWN" +} + // Check returns a bool indicating whether the request is correctly // authenticated for the given username and password. func Check(req *http.Request, username, password string) bool { @@ -34,23 +52,32 @@ func Check(req *http.Request, username, password string) bool { } // Challenge returns a WWW-Authenticate header value for the given realm and -// algorithm. -func Challenge(realm, algorithm string) string { +// algorithm. If an invalid realm or an unsupported algorithm is given +func Challenge(realm string, algorithm digestAlgorithm) string { entropy := make([]byte, 32) rand.Read(entropy) opaqueVal := entropy[:16] nonceVal := fmt.Sprintf("%s:%x", time.Now(), entropy[16:31]) - opaque := hash(opaqueVal, crypto.MD5) - nonce := hash([]byte(nonceVal), crypto.MD5) + // we use MD5 to hash nonces regardless of hash used for authentication + opaque := hash(opaqueVal, MD5) + nonce := hash([]byte(nonceVal), MD5) + + return fmt.Sprintf("Digest qop=auth, realm=%#v, algorithm=%s, nonce=%s, opaque=%s", sanitizeRealm(realm), algorithm, nonce, opaque) +} - return fmt.Sprintf("Digest qop=auth, realm=%s, algorithm=%s, nonce=%s, opaque=%s", realm, algorithm, nonce, opaque) +// sanitizeRealm tries to ensure that a given realm does not include any +// characters that will trip up our extremely simplistic header parser. +func sanitizeRealm(realm string) string { + realm = strings.Replace(realm, `"`, "", -1) + realm = strings.Replace(realm, ",", "", -1) + return realm } // authorization is the result of parsing an Authorization header type authorization struct { - algorithm crypto.Hash + algorithm digestAlgorithm cnonce string nc string nonce string @@ -92,9 +119,9 @@ func parseAuthorizationHeader(value string) *authorization { authInfo := parts[1] auth := parseDictHeader(authInfo) - algo := crypto.MD5 + algo := MD5 if strings.ToLower(auth["algorithm"]) == "sha-256" { - algo = crypto.SHA256 + algo = SHA256 } return &authorization{ @@ -136,9 +163,9 @@ func parseDictHeader(value string) map[string]string { // hash generates the hex digest of the given data using the given hashing // algorithm, which must be one of MD5 or SHA256. -func hash(data []byte, algorithm crypto.Hash) string { +func hash(data []byte, algorithm digestAlgorithm) string { switch algorithm { - case crypto.SHA256: + case SHA256: return fmt.Sprintf("%x", sha256.Sum256(data)) default: return fmt.Sprintf("%x", md5.Sum(data)) @@ -150,7 +177,7 @@ func hash(data []byte, algorithm crypto.Hash) string { // HA1 = H(A1) = H(username:realm:password) // // and H is one of MD5 or SHA256. -func makeHA1(realm, username, password string, algorithm crypto.Hash) string { +func makeHA1(realm, username, password string, algorithm digestAlgorithm) string { A1 := fmt.Sprintf("%s:%s:%s", username, realm, password) return hash([]byte(A1), algorithm) } diff --git a/httpbin/digest/digest_test.go b/httpbin/digest/digest_test.go index a359f1ac0fd4e34455a8bd72470bf62853ea3e14..dc25086eee65468dc544bae1effe0328a41496cf 100644 --- a/httpbin/digest/digest_test.go +++ b/httpbin/digest/digest_test.go @@ -1,7 +1,6 @@ package digest import ( - "crypto" "fmt" "net/http" "reflect" @@ -75,6 +74,27 @@ func TestCheck(t *testing.T) { }) } +func TestChallenge(t *testing.T) { + var tests = []struct { + realm string + expectedRealm string + algorithm digestAlgorithm + expectedAlgorithm string + }{ + {"realm", "realm", MD5, "MD5"}, + {"realm", "realm", SHA256, "SHA-256"}, + {"realm with spaces", "realm with spaces", SHA256, "SHA-256"}, + {`realm "with" "quotes"`, "realm with quotes", MD5, "MD5"}, + {`spaces, "quotes," and commas`, "spaces quotes and commas", MD5, "MD5"}, + } + for _, test := range tests { + challenge := Challenge(test.realm, test.algorithm) + result := parseDictHeader(challenge) + assertStringEquals(t, test.expectedRealm, result["realm"]) + assertStringEquals(t, test.expectedAlgorithm, result["algorithm"]) + } +} + func TestResponse(t *testing.T) { auth := parseAuthorizationHeader(exampleAuthorization) expected := auth.response @@ -84,16 +104,15 @@ func TestResponse(t *testing.T) { func TestHash(t *testing.T) { var tests = []struct { - algorithm crypto.Hash + algorithm digestAlgorithm data []byte expected string }{ - {crypto.SHA256, []byte("hello, world!\n"), "4dca0fd5f424a31b03ab807cbae77eb32bf2d089eed1cee154b3afed458de0dc"}, - {crypto.MD5, []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"}, + {SHA256, []byte("hello, world!\n"), "4dca0fd5f424a31b03ab807cbae77eb32bf2d089eed1cee154b3afed458de0dc"}, + {MD5, []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"}, // Any unhandled hash results in MD5 being used - {crypto.MD4, []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"}, - {crypto.SHA512, []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"}, + {digestAlgorithm(10), []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"}, } for _, test := range tests { t.Run(fmt.Sprintf("hash/%v", test.algorithm), func(t *testing.T) { @@ -177,7 +196,7 @@ func TestParseAuthorizationHeader(t *testing.T) { // incomplete headers are fine {"Digest username=u, realm=r, nonce=n", &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, username: "u", realm: "r", nonce: "n", @@ -185,51 +204,51 @@ func TestParseAuthorizationHeader(t *testing.T) { // algorithm can be either MD5 or SHA-256, with MD5 as default {"Digest username=u", &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, username: "u", }}, {"Digest algorithm=MD5, username=u", &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, username: "u", }}, {"Digest algorithm=md5, username=u", &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, username: "u", }}, {"Digest algorithm=SHA-256, username=u", &authorization{ - algorithm: crypto.SHA256, + algorithm: SHA256, username: "u", }}, {"Digest algorithm=foo, username=u", &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, username: "u", }}, {"Digest algorithm=SHA-512, username=u", &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, username: "u", }}, // algorithm not case sensitive {"Digest algorithm=sha-256, username=u", &authorization{ - algorithm: crypto.SHA256, + algorithm: SHA256, username: "u", }}, // but dash is required in SHA-256 is not recognized {"Digest algorithm=SHA256, username=u", &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, username: "u", }}, // session variants not recognized {"Digest algorithm=SHA-256-sess, username=u", &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, username: "u", }}, {"Digest algorithm=MD5-sess, username=u", &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, username: "u", }}, {exampleAuthorization, &authorization{ - algorithm: crypto.MD5, + algorithm: MD5, cnonce: "0a4f113b", nc: "00000001", nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093", diff --git a/httpbin/handlers.go b/httpbin/handlers.go index 64bed3f8cf29e1e06099726c28c4fe4ddf197f8b..58f0579d43f19c3e4bf95db95df145b1193647a9 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -861,20 +861,25 @@ func (h *HTTPBin) DigestAuth(w http.ResponseWriter, r *http.Request) { user := parts[3] password := parts[4] - algorithm := "MD5" + algoName := "MD5" if count == 6 { - algorithm = strings.ToUpper(parts[5]) + algoName = strings.ToUpper(parts[5]) } if qop != "auth" { http.Error(w, "Invalid QOP directive", http.StatusBadRequest) return } - if algorithm != "MD5" && algorithm != "SHA-256" { + if algoName != "MD5" && algoName != "SHA-256" { http.Error(w, "Invalid algorithm", http.StatusBadRequest) return } + algorithm := digest.MD5 + if algoName == "SHA-256" { + algorithm = digest.SHA256 + } + if !digest.Check(r, user, password) { w.Header().Set("WWW-Authenticate", digest.Challenge("go-httpbin", algorithm)) w.WriteHeader(http.StatusUnauthorized) diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index eabffbd528c868a409f321405ec7ca677c299112..46cb62f664ccd127896c2ebef5d1b64456940a06 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -1123,9 +1123,16 @@ func TestDigestAuth(t *testing.T) { {"/digest-auth", http.StatusNotFound}, {"/digest-auth/user", http.StatusNotFound}, {"/digest-auth/user/pass", http.StatusNotFound}, + {"/digest-auth/auth/user/pass/MD5/foo", http.StatusNotFound}, + // valid but unauthenticated requests {"/digest-auth/auth/user/pass", http.StatusUnauthorized}, {"/digest-auth/auth/user/pass/MD5", http.StatusUnauthorized}, + {"/digest-auth/auth/user/pass/SHA-256", http.StatusUnauthorized}, + + // invalid requests + {"/digest-auth/bad-qop/user/pass/MD5", http.StatusBadRequest}, + {"/digest-auth/auth/user/pass/SHA-512", http.StatusBadRequest}, } for _, test := range tests { t.Run("ok"+test.url, func(t *testing.T) { @@ -1135,6 +1142,40 @@ func TestDigestAuth(t *testing.T) { assertStatusCode(t, w, test.status) }) } + + t.Run("ok", func(t *testing.T) { + // Example captured from a successful login in a browser + authorization := `Digest username="user", + realm="go-httpbin", + nonce="6fb213c6593975c877bb1247370527ad", + uri="/digest-auth/auth/user/pass/MD5", + algorithm=MD5, + response="9b7a05d78051b4f668356eedf32f55d6", + opaque="fd1c386a015a2bb7c41585f54329ce91", + qop=auth, + nc=00000001, + cnonce="aaab705226af5bd4"` + + url := "/digest-auth/auth/user/pass/MD5" + r, _ := http.NewRequest("GET", url, nil) + r.RequestURI = url + r.Header.Set("Authorization", authorization) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + assertStatusCode(t, w, http.StatusOK) + + resp := &authResponse{} + json.Unmarshal(w.Body.Bytes(), resp) + + expectedResp := &authResponse{ + Authorized: true, + User: "user", + } + if !reflect.DeepEqual(resp, expectedResp) { + t.Fatalf("expected response %#v, got %#v", expectedResp, resp) + } + }) } func TestGzip(t *testing.T) {