Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add path exclusion to mTLS and BasicAuth authentication #151

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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> ]
matthiasr marked this conversation as resolved.
Show resolved Hide resolved
```

[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
Loading