diff --git a/caddyauth_imported/adapter.go b/caddyauth_imported/adapter.go new file mode 100644 index 00000000..20fbde1c --- /dev/null +++ b/caddyauth_imported/adapter.go @@ -0,0 +1,137 @@ +// Copyright 2015 Matthew Holt and The Caddy 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 caddyauthimported + +import ( + "encoding/base64" + "errors" + "net/http" + "strings" + + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/caddyauth" +) + +// [POC] Code is the same as forwardproxy's basicauth former implementation +func getCredsFromHeader(r *http.Request) (string, string, error) { + pa := strings.Split(r.Header.Get("Proxy-Authorization"), " ") + if len(pa) != 2 { + return "", "", errors.New("Proxy-Authorization is required! Expected format: ") + } + if strings.ToLower(pa[0]) != "basic" { + return "", "", errors.New("auth type is not supported") + } + buf := make([]byte, base64.StdEncoding.DecodedLen(len(pa[1]))) + _, _ = base64.StdEncoding.Decode(buf, []byte(pa[1])) // should not err ever since we are decoding a known good input // TODO true? + credarr := strings.Split(string(buf), ":") + + return credarr[0], credarr[1], nil +} + +// Authenticate validates the user credentials in req and returns the user, if valid. +// [POC] Same code as caddy's basicAuth, but it doesn't write anything on the ResponseWriter +// Needs upstreaming, in case, to keep the code inside caddy. +func (hba HTTPBasicAuth) AuthenticateNoCredsPrompt(req *http.Request) (caddyauth.User, bool, error) { + username, plaintextPasswordStr, err := getCredsFromHeader(req) + if err != nil { + return caddyauth.User{}, false, err + } + + account, accountExists := hba.Accounts[username] + if !accountExists { + // don't return early if account does not exist; we want + // to try to avoid side-channels that leak existence, so + // we use a fake password to simulate realistic CPU cycles + account.password = hba.fakePassword + } + + same, err := hba.correctPassword(account, []byte(plaintextPasswordStr)) + if err != nil || !same || !accountExists { + return caddyauth.User{ID: username}, false, err + } + + return caddyauth.User{ID: username}, true, nil +} + +// [POC] Lifted/adapted from modules/caddyhttp/caddyauth/caddyfile.go#parseCaddyfile() +// IDK how to reuse that method directly, honestly. Also, I don't have a httpcaddyfile.Helper as +// in the original code, but a caddyfile.Dispenser seems to work. +func ParseCaddyfileForHTTPBasicAuth(h *caddyfile.Dispenser) (*HTTPBasicAuth, error) { + // [POC] removed code + // h.Next() // consume directive name + + // // "basicauth" is deprecated, replaced by "basic_auth" + // if h.Val() == "basicauth" { + // caddy.Log().Named("config.adapter.caddyfile").Warn("the 'basicauth' directive is deprecated, please use 'basic_auth' instead!") + // } + + var ba HTTPBasicAuth + ba.HashCache = new(Cache) + + var cmp caddyauth.Comparer + args := h.RemainingArgs() + + var hashName string + switch len(args) { + case 0: + hashName = "bcrypt" + case 1: + hashName = args[0] + case 2: + hashName = args[0] + ba.Realm = args[1] + default: + return nil, h.ArgErr() + } + + switch hashName { + case "bcrypt": + cmp = caddyauth.BcryptHash{} + default: + return nil, h.Errf("unrecognized hash algorithm: %s", hashName) + } + + ba.HashRaw = caddyconfig.JSONModuleObject(cmp, "algorithm", hashName, nil) + + for h.NextBlock(0) { + username := h.Val() + + var b64Pwd string + h.Args(&b64Pwd) + if h.NextArg() { + return nil, h.ArgErr() + } + + if username == "" || b64Pwd == "" { + return nil, h.Err("username and password cannot be empty or missing") + } + + ba.AccountList = append(ba.AccountList, Account{ + Username: username, + Password: b64Pwd, + }) + } + + // [POC] Removed code + // return Authentication{ + // ProvidersRaw: caddy.ModuleMap{ + // "http_basic": caddyconfig.JSON(ba, nil), + // }, + // }, nil + + // [POC] Added code + return &ba, nil +} diff --git a/caddyauth_imported/basicauth.go b/caddyauth_imported/basicauth.go new file mode 100644 index 00000000..2ecb66f2 --- /dev/null +++ b/caddyauth_imported/basicauth.go @@ -0,0 +1,312 @@ +// Copyright 2015 Matthew Holt and The Caddy 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 caddyauthimported + +// [POC] I had to duplicate this because it doesn't export 'password' (same as 'Password?), +// fakePassword, correctPassword(). Upstream patch would be needed I am afraid. +// Code is the same but for package references (caddyauth.User, caddyauth.Authenticator) +// and I had to comment out init() because it would register the module again. +// A source of confusion: xcaddy compiles against a later version than go test, and +// between the two versions the Comparer interface changed. I cannot find how to align the +// two methods of running this. + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + weakrand "math/rand" + "net/http" + "strings" + "sync" + + "golang.org/x/sync/singleflight" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/caddyauth" +) + +// [POC] Removed code +// func init() { +// caddy.RegisterModule(HTTPBasicAuth{}) +// } + +// HTTPBasicAuth facilitates HTTP basic authentication. +type HTTPBasicAuth struct { + // The algorithm with which the passwords are hashed. Default: bcrypt + HashRaw json.RawMessage `json:"hash,omitempty" caddy:"namespace=http.authentication.hashes inline_key=algorithm"` + + // The list of accounts to authenticate. + AccountList []Account `json:"accounts,omitempty"` + + // The name of the realm. Default: restricted + Realm string `json:"realm,omitempty"` + + // If non-nil, a mapping of plaintext passwords to their + // hashes will be cached in memory (with random eviction). + // This can greatly improve the performance of traffic-heavy + // servers that use secure password hashing algorithms, with + // the downside that plaintext passwords will be stored in + // memory for a longer time (this should not be a problem + // as long as your machine is not compromised, at which point + // all bets are off, since basicauth necessitates plaintext + // passwords being received over the wire anyway). Note that + // a cache hit does not mean it is a valid password. + HashCache *Cache `json:"hash_cache,omitempty"` + + Accounts map[string]Account `json:"-"` + Hash Comparer `json:"-"` + + // fakePassword is used when a given user is not found, + // so that timing side-channels can be mitigated: it gives + // us something to hash and compare even if the user does + // not exist, which should have similar timing as a user + // account that does exist. + fakePassword []byte +} + +// CaddyModule returns the Caddy module information. +func (HTTPBasicAuth) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.authentication.providers.http_basic", + New: func() caddy.Module { return new(HTTPBasicAuth) }, + } +} + +// Provision provisions the HTTP basic auth provider. +func (hba *HTTPBasicAuth) Provision(ctx caddy.Context) error { + if hba.HashRaw == nil { + hba.HashRaw = json.RawMessage(`{"algorithm": "bcrypt"}`) + } + + // load password hasher + hasherIface, err := ctx.LoadModule(hba, "HashRaw") + if err != nil { + return fmt.Errorf("loading password hasher module: %v", err) + } + hba.Hash = hasherIface.(Comparer) + + if hba.Hash == nil { + return fmt.Errorf("hash is required") + } + + // if supported, generate a fake password we can compare against if needed + if hasher, ok := hba.Hash.(Hasher); ok { + hba.fakePassword = hasher.FakeHash() + } + + repl := caddy.NewReplacer() + + // load account list + hba.Accounts = make(map[string]Account) + for i, acct := range hba.AccountList { + if _, ok := hba.Accounts[acct.Username]; ok { + return fmt.Errorf("account %d: username is not unique: %s", i, acct.Username) + } + + acct.Username = repl.ReplaceAll(acct.Username, "") + acct.Password = repl.ReplaceAll(acct.Password, "") + + if acct.Username == "" || acct.Password == "" { + return fmt.Errorf("account %d: username and password are required", i) + } + + // TODO: Remove support for redundantly-encoded b64-encoded hashes + // Passwords starting with '$' are likely in Modular Crypt Format, + // so we don't need to base64 decode them. But historically, we + // required redundant base64, so we try to decode it otherwise. + if strings.HasPrefix(acct.Password, "$") { + acct.password = []byte(acct.Password) + } else { + acct.password, err = base64.StdEncoding.DecodeString(acct.Password) + if err != nil { + return fmt.Errorf("base64-decoding password: %v", err) + } + } + + hba.Accounts[acct.Username] = acct + } + hba.AccountList = nil // allow GC to deallocate + + if hba.HashCache != nil { + hba.HashCache.cache = make(map[string]bool) + hba.HashCache.mu = new(sync.RWMutex) + hba.HashCache.g = new(singleflight.Group) + } + + return nil +} + +// Authenticate validates the user credentials in req and returns the user, if valid. +func (hba HTTPBasicAuth) Authenticate(w http.ResponseWriter, req *http.Request) (caddyauth.User, bool, error) { + username, plaintextPasswordStr, ok := req.BasicAuth() + if !ok { + return hba.promptForCredentials(w, nil) + } + + account, accountExists := hba.Accounts[username] + if !accountExists { + // don't return early if account does not exist; we want + // to try to avoid side-channels that leak existence, so + // we use a fake password to simulate realistic CPU cycles + account.password = hba.fakePassword + } + + same, err := hba.correctPassword(account, []byte(plaintextPasswordStr)) + if err != nil || !same || !accountExists { + return hba.promptForCredentials(w, err) + } + + return caddyauth.User{ID: username}, true, nil +} + +func (hba HTTPBasicAuth) correctPassword(account Account, plaintextPassword []byte) (bool, error) { + compare := func() (bool, error) { + return hba.Hash.Compare(account.password, plaintextPassword) + } + + // if no caching is enabled, simply return the result of hashing + comparing + if hba.HashCache == nil { + return compare() + } + + // compute a cache key that is unique for these input parameters + cacheKey := hex.EncodeToString(append(account.password, plaintextPassword...)) + + // fast track: if the result of the input is already cached, use it + hba.HashCache.mu.RLock() + same, ok := hba.HashCache.cache[cacheKey] + hba.HashCache.mu.RUnlock() + if ok { + return same, nil + } + // slow track: do the expensive op, then add it to the cache + // but perform it in a singleflight group so that multiple + // parallel requests using the same password don't cause a + // thundering herd problem by all performing the same hashing + // operation before the first one finishes and caches it. + v, err, _ := hba.HashCache.g.Do(cacheKey, func() (any, error) { + return compare() + }) + if err != nil { + return false, err + } + same = v.(bool) + hba.HashCache.mu.Lock() + if len(hba.HashCache.cache) >= 1000 { + hba.HashCache.makeRoom() // keep cache size under control + } + hba.HashCache.cache[cacheKey] = same + hba.HashCache.mu.Unlock() + + return same, nil +} + +func (hba HTTPBasicAuth) promptForCredentials(w http.ResponseWriter, err error) (caddyauth.User, bool, error) { + // browsers show a message that says something like: + // "The website says: " + // which is kinda dumb, but whatever. + realm := hba.Realm + if realm == "" { + realm = "restricted" + } + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm)) + return caddyauth.User{}, false, err +} + +// Cache enables caching of basic auth results. This is especially +// helpful for secure password hashes which can be expensive to +// compute on every HTTP request. +type Cache struct { + mu *sync.RWMutex + g *singleflight.Group + + // map of concatenated hashed password + plaintext password, to result + cache map[string]bool +} + +// makeRoom deletes about 1/10 of the items in the cache +// in order to keep its size under control. It must not be +// called without a lock on c.mu. +func (c *Cache) makeRoom() { + // we delete more than just 1 entry so that we don't have + // to do this on every request; assuming the capacity of + // the cache is on a long tail, we can save a lot of CPU + // time by doing a whole bunch of deletions now and then + // we won't have to do them again for a while + numToDelete := len(c.cache) / 10 + if numToDelete < 1 { + numToDelete = 1 + } + for deleted := 0; deleted <= numToDelete; deleted++ { + // Go maps are "nondeterministic" not actually random, + // so although we could just chop off the "front" of the + // map with less code, this is a heavily skewed eviction + // strategy; generating random numbers is cheap and + // ensures a much better distribution. + //nolint:gosec + rnd := weakrand.Intn(len(c.cache)) + i := 0 + for key := range c.cache { + if i == rnd { + delete(c.cache, key) + break + } + i++ + } + } +} + +// Comparer is a type that can securely compare +// a plaintext password with a hashed password +// in constant-time. Comparers should hash the +// plaintext password and then use constant-time +// comparison. +type Comparer interface { + // Compare returns true if the result of hashing + // plaintextPassword is hashedPassword, false + // otherwise. An error is returned only if + // there is a technical/configuration error. + Compare(hashedPassword, plaintextPassword []byte) (bool, error) +} + +// Hasher is a type that can generate a secure hash +// given a plaintext. Hashing modules which implement +// this interface can be used with the hash-password +// subcommand as well as benefitting from anti-timing +// features. A hasher also returns a fake hash which +// can be used for timing side-channel mitigation. +type Hasher interface { + Hash(plaintext []byte) ([]byte, error) + FakeHash() []byte +} + +// Account contains a username and password. +type Account struct { + // A user's username. + Username string `json:"username"` + + // The user's hashed password, in Modular Crypt Format (with `$` prefix) + // or base64-encoded. + Password string `json:"password"` + + password []byte +} + +// Interface guards +var ( + _ caddy.Provisioner = (*HTTPBasicAuth)(nil) + _ caddyauth.Authenticator = (*HTTPBasicAuth)(nil) +) diff --git a/caddyfile.go b/caddyfile.go index 239b5c70..961676cb 100644 --- a/caddyfile.go +++ b/caddyfile.go @@ -10,6 +10,7 @@ import ( "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" + caddyauthimported "github.com/proofrock/forwardproxy/caddyauth_imported" ) func init() { @@ -44,20 +45,11 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { args := d.RemainingArgs() switch subdirective { case "basic_auth": - if len(args) != 2 { - return d.ArgErr() - } - if len(args[0]) == 0 { - return d.Err("empty usernames are not allowed") - } - // TODO: Evaluate policy of allowing empty passwords. - if strings.Contains(args[0], ":") { - return d.Err("character ':' in usernames is not allowed") - } - if h.AuthCredentials == nil { - h.AuthCredentials = [][]byte{} + bam, err := caddyauthimported.ParseCaddyfileForHTTPBasicAuth(d) + if err != nil { + return err } - h.AuthCredentials = append(h.AuthCredentials, EncodeAuthCredentials(args[0], args[1])) + h.BasicAuthModule = *bam case "hosts": if len(args) == 0 { return d.ArgErr() diff --git a/forwardproxy.go b/forwardproxy.go index 39ffe0a9..f5395a5e 100644 --- a/forwardproxy.go +++ b/forwardproxy.go @@ -20,7 +20,6 @@ import ( "bufio" "bytes" "context" - "crypto/subtle" "crypto/tls" "encoding/base64" "errors" @@ -35,12 +34,12 @@ import ( "strings" "sync" "time" - "unicode/utf8" caddy "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/forwardproxy/httpclient" + caddyauthimported "github.com/proofrock/forwardproxy/caddyauth_imported" "go.uber.org/zap" "golang.org/x/net/proxy" ) @@ -53,7 +52,8 @@ func init() { // // EXPERIMENTAL: This handler is still experimental and subject to breaking changes. type Handler struct { - logger *zap.Logger + logger *zap.Logger + BasicAuthModule caddyauthimported.HTTPBasicAuth // Filename of the PAC file to serve. PACPath string `json:"pac_path,omitempty"` @@ -90,9 +90,6 @@ type Handler struct { upstream *url.URL // address of upstream proxy aclRules []aclRule - - // TODO: temporary/deprecated - we should try to reuse existing authentication modules instead! - AuthCredentials [][]byte `json:"auth_credentials,omitempty"` // slice with base64-encoded credentials } // CaddyModule returns the Caddy module information. @@ -107,6 +104,8 @@ func (Handler) CaddyModule() caddy.ModuleInfo { func (h *Handler) Provision(ctx caddy.Context) error { h.logger = ctx.Logger(h) + h.BasicAuthModule.Provision(ctx) + if h.DialTimeout <= 0 { h.DialTimeout = caddy.Duration(30 * time.Second) } @@ -145,7 +144,7 @@ func (h *Handler) Provision(ctx caddy.Context) error { h.aclRules = append(h.aclRules, &aclAllRule{allow: true}) if h.ProbeResistance != nil { - if h.AuthCredentials == nil { + if len(h.BasicAuthModule.AccountList) == 0 { return fmt.Errorf("probe resistance requires authentication") } if len(h.ProbeResistance.Domain) > 0 { @@ -227,7 +226,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht } var authErr error - if h.AuthCredentials != nil { + if len(h.BasicAuthModule.Accounts) > 0 { authErr = h.checkCredentials(r) } if h.ProbeResistance != nil && len(h.ProbeResistance.Domain) > 0 && reqHost == h.ProbeResistance.Domain { @@ -403,45 +402,30 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht } func (h Handler) checkCredentials(r *http.Request) error { - pa := strings.Split(r.Header.Get("Proxy-Authorization"), " ") - if len(pa) != 2 { - return errors.New("Proxy-Authorization is required! Expected format: ") - } - if strings.ToLower(pa[0]) != "basic" { - return errors.New("auth type is not supported") - } - for _, creds := range h.AuthCredentials { - if subtle.ConstantTimeCompare(creds, []byte(pa[1])) == 1 { - repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) - buf := make([]byte, base64.StdEncoding.DecodedLen(len(creds))) - _, _ = base64.StdEncoding.Decode(buf, creds) // should not err ever since we are decoding a known good input - cred := string(buf) - repl.Set("http.auth.user.id", cred[:strings.IndexByte(cred, ':')]) - // Please do not consider this to be timing-attack-safe code. Simple equality is almost - // mindlessly substituted with constant time algo and there ARE known issues with this code, - // e.g. size of smallest credentials is guessable. TODO: protect from all the attacks! Hash? - return nil - } - } repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) - buf := make([]byte, base64.StdEncoding.DecodedLen(len([]byte(pa[1])))) - n, err := base64.StdEncoding.Decode(buf, []byte(pa[1])) - if err != nil { - repl.Set("http.auth.user.id", "invalidbase64:"+err.Error()) - return err - } - if utf8.Valid(buf[:n]) { - cred := string(buf[:n]) - i := strings.IndexByte(cred, ':') - if i >= 0 { - repl.Set("http.auth.user.id", "invalid:"+cred[:i]) - } else { - repl.Set("http.auth.user.id", "invalidformat:"+cred) - } + + usr, validAuth, err := h.BasicAuthModule.AuthenticateNoCredsPrompt(r) + if validAuth { + repl.Set("http.auth.user.id", usr.ID) + return nil + } + + // [POC] Old code differentiated between invalid credentials and invalid base64 ecc. + // which is not easy here. But it can be done, if this POC is validated by + // caddy's team. + if usr.ID != "" { + repl.Set("http.auth.user.id", "invalid:"+usr.ID) } else { - repl.Set("http.auth.user.id", "invalid::") + repl.Set("http.auth.user.id", "invalidformat::") + } + + // [POC] Change to error message, reporting the error. Need to differentiate between + // types of errors (err is != nil only if malformed, but I didn't want to change too much + // the AuthenticateNoCredsPrompt method that is copied from AuthenticateNoCredsPrompt) + if err == nil { + err = errors.New("auth error") } - return errors.New("invalid credentials") + return errors.New("invalid credentials: " + err.Error()) } func (h Handler) shouldServePACFile(r *http.Request) bool {