Skip to content

Commit

Permalink
Add path exclusion support to BasicAuth authentication
Browse files Browse the repository at this point in the history
Signed-off-by: Kacper Rzetelski <[email protected]>
  • Loading branch information
rzetelskik committed Oct 25, 2024
1 parent ad41e17 commit 6ea5d3c
Show file tree
Hide file tree
Showing 18 changed files with 1,222 additions and 229 deletions.
4 changes: 4 additions & 0 deletions docs/web-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ http_server_config:
# required. Passwords are hashed with bcrypt.
basic_auth_users:
[ <string>: <secret> ... ]
# A list of HTTP paths to be excepted from authentication.
auth_excluded_paths:
[ - <string> ]
```

[A sample configuration file](web-config.yml) is provided.
Expand Down
50 changes: 1 addition & 49 deletions web/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,9 @@
package web

import (
"encoding/hex"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"

"golang.org/x/crypto/bcrypt"
)
Expand Down Expand Up @@ -79,10 +76,6 @@ type webHandler struct {
tlsConfigPath string
handler http.Handler
logger *slog.Logger
cache *cache
// bcryptMtx is there to ensure that bcrypt.CompareHashAndPassword is run
// only once in parallel as this is CPU intensive.
bcryptMtx sync.Mutex
}

func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Expand All @@ -98,46 +91,5 @@ func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set(k, v)
}

if len(c.Users) == 0 {
u.handler.ServeHTTP(w, r)
return
}

user, pass, auth := r.BasicAuth()
if auth {
hashedPassword, validUser := c.Users[user]

if !validUser {
// The user is not found. Use a fixed password hash to
// prevent user enumeration by timing requests.
// This is a bcrypt-hashed version of "fakepassword".
hashedPassword = "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSi"
}

cacheKey := strings.Join(
[]string{
hex.EncodeToString([]byte(user)),
hex.EncodeToString([]byte(hashedPassword)),
hex.EncodeToString([]byte(pass)),
}, ":")
authOk, ok := u.cache.get(cacheKey)

if !ok {
// This user, hashedPassword, password is not cached.
u.bcryptMtx.Lock()
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(pass))
u.bcryptMtx.Unlock()

authOk = validUser && err == nil
u.cache.set(cacheKey, authOk)
}

if authOk && validUser {
u.handler.ServeHTTP(w, r)
return
}
}

w.Header().Set("WWW-Authenticate", "Basic")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
u.handler.ServeHTTP(w, r)
}
172 changes: 0 additions & 172 deletions web/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,182 +17,10 @@ import (
"context"
"net"
"net/http"
"sync"
"testing"
"time"
)

// TestBasicAuthCache validates that the cache is working by calling a password
// protected endpoint multiple times.
func TestBasicAuthCache(t *testing.T) {
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
}),
}

done := make(chan struct{})
t.Cleanup(func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
<-done
})

go func() {
flags := FlagConfig{
WebListenAddresses: &([]string{port}),
WebSystemdSocket: OfBool(false),
WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"),
}
ListenAndServe(server, &flags, testlogger)
close(done)
}()

waitForPort(t, port)

login := func(username, password string, code int) {
client := &http.Client{}
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
if err != nil {
t.Fatal(err)
}
req.SetBasicAuth(username, password)
r, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
if r.StatusCode != code {
t.Fatalf("bad return code, expected %d, got %d", code, r.StatusCode)
}
}

// Initial logins, checking that it just works.
login("alice", "alice123", 200)
login("alice", "alice1234", 401)

var (
start = make(chan struct{})
wg sync.WaitGroup
)
wg.Add(300)
for i := 0; i < 150; i++ {
go func() {
<-start
login("alice", "alice123", 200)
wg.Done()
}()
go func() {
<-start
login("alice", "alice1234", 401)
wg.Done()
}()
}
close(start)
wg.Wait()
}

// TestBasicAuthWithFakePassword validates that we can't login the "fakepassword" used in
// to prevent user enumeration.
func TestBasicAuthWithFakepassword(t *testing.T) {
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
}),
}

done := make(chan struct{})
t.Cleanup(func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
<-done
})

go func() {
flags := FlagConfig{
WebListenAddresses: &([]string{port}),
WebSystemdSocket: OfBool(false),
WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"),
}
ListenAndServe(server, &flags, testlogger)
close(done)
}()

waitForPort(t, port)

login := func() {
client := &http.Client{}
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
if err != nil {
t.Fatal(err)
}
req.SetBasicAuth("fakeuser", "fakepassword")
r, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
if r.StatusCode != 401 {
t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode)
}
}

// Login with a cold cache.
login()
// Login with the response cached.
login()
}

// TestByPassBasicAuthVuln tests for CVE-2022-46146.
func TestByPassBasicAuthVuln(t *testing.T) {
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
}),
}

done := make(chan struct{})
t.Cleanup(func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
<-done
})

go func() {
flags := FlagConfig{
WebListenAddresses: &([]string{port}),
WebSystemdSocket: OfBool(false),
WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"),
}
ListenAndServe(server, &flags, testlogger)
close(done)
}()

waitForPort(t, port)

login := func(username, password string) {
client := &http.Client{}
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
if err != nil {
t.Fatal(err)
}
req.SetBasicAuth(username, password)
r, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
if r.StatusCode != 401 {
t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode)
}
}

// Poison the cache.
login("alice$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", "fakepassword")
// Login with a wrong password.
login("alice", "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSifakepassword")
}

// TestHTTPHeaders validates that HTTP headers are added correctly.
func TestHTTPHeaders(t *testing.T) {
server := &http.Server{
Expand Down
58 changes: 58 additions & 0 deletions web/internal/authentication/authenticator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright 2023 The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package authentication

import (
"log/slog"
"net/http"
)

// HTTPChallenge contains information which can used by an HTTP server to challenge a client request using a challenge-response authentication framework.
// https://datatracker.ietf.org/doc/html/rfc7235#section-2.1
type HTTPChallenge struct {
Scheme string
}

type Authenticator interface {
Authenticate(*http.Request) (bool, string, *HTTPChallenge, error)
}

type AuthenticatorFunc func(r *http.Request) (bool, string, *HTTPChallenge, error)

func (f AuthenticatorFunc) Authenticate(r *http.Request) (bool, string, *HTTPChallenge, error) {
return f(r)
}

func WithAuthentication(handler http.Handler, authenticator Authenticator, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ok, denyReason, httpChallenge, err := authenticator.Authenticate(r)
if err != nil {
logger.Error("Unable to authenticate", "URI", r.RequestURI, "err", err.Error())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}

if ok {
handler.ServeHTTP(w, r)
return
}

if httpChallenge != nil {
w.Header().Set("WWW-Authenticate", httpChallenge.Scheme)
}

logger.Warn("Unauthenticated request", "URI", r.RequestURI, "denyReason", denyReason)
http.Error(w, denyReason, http.StatusUnauthorized)
})
}
Loading

0 comments on commit 6ea5d3c

Please sign in to comment.