From 26d21f5425b6decb42e8672e783cf25caae84ca6 Mon Sep 17 00:00:00 2001 From: Victoria Dye Date: Fri, 5 May 2023 11:37:07 -0700 Subject: [PATCH 1/7] bundle-server: introduce 'authorize' step to 'serve' Add an 'authorize' function to the bundle web server for managing access to bundle server content. This function returns one of two states: - Deny, which indicates that bundle server content should not be served. This is configured with the desired 4XX response code and (optional) headers. Invoking 'ApplyResult()' with this result will fill in the response information and trigger 'serve' to exit immediately. - Allow, indicating that the requstor may access the requested resource. This does not return an immediate response, but if headers are specified, they will be added to the eventual response. After applying this result with 'ApplyResult()', 'serve' will continue on to add the appropriate content to the response. For now, the 'authorize' function is always nil, so it's never invoked. In later patches, it will be configured via a new '--auth-config' option to the web server. Signed-off-by: Victoria Dye --- cmd/git-bundle-web-server/bundle-server.go | 13 ++ cmd/git-bundle-web-server/main.go | 4 + pkg/auth/auth-result.go | 75 +++++++++++ pkg/auth/auth-result_test.go | 146 +++++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 pkg/auth/auth-result.go create mode 100644 pkg/auth/auth-result_test.go diff --git a/cmd/git-bundle-web-server/bundle-server.go b/cmd/git-bundle-web-server/bundle-server.go index 0851aa1..b495f1a 100644 --- a/cmd/git-bundle-web-server/bundle-server.go +++ b/cmd/git-bundle-web-server/bundle-server.go @@ -20,13 +20,17 @@ import ( "github.com/git-ecosystem/git-bundle-server/internal/core" "github.com/git-ecosystem/git-bundle-server/internal/git" "github.com/git-ecosystem/git-bundle-server/internal/log" + "github.com/git-ecosystem/git-bundle-server/pkg/auth" ) +type authFunc func(*http.Request, string, string) auth.AuthResult + type bundleWebServer struct { logger log.TraceLogger server *http.Server serverWaitGroup *sync.WaitGroup listenAndServeFunc func() error + authorize authFunc } func NewBundleWebServer(logger log.TraceLogger, @@ -34,10 +38,12 @@ func NewBundleWebServer(logger log.TraceLogger, certFile string, keyFile string, tlsMinVersion uint16, clientCAFile string, + middlewareAuthorize authFunc, ) (*bundleWebServer, error) { bundleServer := &bundleWebServer{ logger: logger, serverWaitGroup: &sync.WaitGroup{}, + authorize: middlewareAuthorize, } // Configure the http.Server @@ -107,6 +113,13 @@ func (b *bundleWebServer) serve(w http.ResponseWriter, r *http.Request) { route := owner + "/" + repo + if b.authorize != nil { + authResult := b.authorize(r, owner, repo) + if authResult.ApplyResult(w) { + return + } + } + userProvider := common.NewUserProvider() fileSystem := common.NewFileSystem() commandExecutor := cmd.NewCommandExecutor(b.logger) diff --git a/cmd/git-bundle-web-server/main.go b/cmd/git-bundle-web-server/main.go index feb1255..2204a59 100644 --- a/cmd/git-bundle-web-server/main.go +++ b/cmd/git-bundle-web-server/main.go @@ -29,12 +29,16 @@ func main() { tlsMinVersion := utils.GetFlagValue[uint16](parser, "tls-version") clientCA := utils.GetFlagValue[string](parser, "client-ca") + // Configure auth + middlewareAuthorize := authFunc(nil) + // Configure the server bundleServer, err := NewBundleWebServer(logger, port, cert, key, tlsMinVersion, clientCA, + middlewareAuthorize, ) if err != nil { logger.Fatal(ctx, err) diff --git a/pkg/auth/auth-result.go b/pkg/auth/auth-result.go new file mode 100644 index 0000000..be6e7e1 --- /dev/null +++ b/pkg/auth/auth-result.go @@ -0,0 +1,75 @@ +package auth + +import ( + "fmt" + "net/http" +) + +// The Header type captures HTTP response header information. +type Header struct { + Key string + Value string +} + +// The AuthResult represents the result of authenticating/authorizing via an +// AuthMiddleware's Authorize function. +type AuthResult struct { + applyResultFunc func(http.ResponseWriter) bool +} + +// ApplyResult applies the AuthResult's configuration to the provided +// http.ResponseWriter w and returns whether the web server should immediately +// send the response (for an AuthResult created with Deny()) or continue on to +// get and serve bundle server content (for an AuthResult created with +// Accept()). If the AuthResult is invalid (e.g., created with AuthResult{}), +// ApplyResult will indicate an immediate 500 response. +func (a *AuthResult) ApplyResult(w http.ResponseWriter) bool { + if a.applyResultFunc == nil { + // AuthResult was initialized incorrectly - throw an ISE & exit + w.WriteHeader(http.StatusInternalServerError) + return true + } else { + return a.applyResultFunc(w) + } +} + +func writeCustomHeaders(w http.ResponseWriter, headers []Header) { + for _, h := range headers { + w.Header().Add(h.Key, h.Value) + } +} + +// Deny creates an AuthResult instance indicating that the bundle web server +// should not serve the requested content and instead return an error response. +// The response will have the status indicated by code (*must* be 4XX) and +// include HTTP headers specified by the headers arg(s). Repeated headers (e.g. +// multiple WWW-Authenticate headers) will be added to the response in the order +// they are provided to this function. +func Deny(code int, headers ...Header) AuthResult { + // Make sure the code is a 4XX + if code < 400 || code > 499 { + panic(fmt.Sprintf("invalid auth middleware response code (must be 4XX, got %d)", code)) + } + + // Configure ApplyResult to write the response & exit + return AuthResult{ + applyResultFunc: func(w http.ResponseWriter) bool { + writeCustomHeaders(w, headers) + w.WriteHeader(code) + return true + }, + } +} + +// Allow creates an AuthResult instance indicating that the bundle web server +// should serve the requested content. If headers are specified, they will be +// applied to the http.ResponseWriter and applied to the response. Repeated +// headers are applied in the order they are provided to this function. +func Allow(headers ...Header) AuthResult { + return AuthResult{ + applyResultFunc: func(w http.ResponseWriter) bool { + writeCustomHeaders(w, headers) + return false + }, + } +} diff --git a/pkg/auth/auth-result_test.go b/pkg/auth/auth-result_test.go new file mode 100644 index 0000000..e622f49 --- /dev/null +++ b/pkg/auth/auth-result_test.go @@ -0,0 +1,146 @@ +package auth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/git-ecosystem/git-bundle-server/pkg/auth" + "github.com/stretchr/testify/assert" +) + +var denyTests = []struct { + title string + + code int + headers []auth.Header + expectedInitPanic bool + + expectedHeaders http.Header +}{ + { + "Invalid code causes panic", + 500, + []auth.Header{}, + true, + nil, + }, + { + "Valid code with no headers", + 404, + []auth.Header{}, + false, + map[string][]string{}, + }, + { + "Valid code with unique headers", + 401, + []auth.Header{{"WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`}}, + false, + map[string][]string{"Www-Authenticate": {`Basic realm="restricted", charset="UTF-8"`}}, + }, + { + "Valid code with repeated headers", + 401, + []auth.Header{ + {"www-authenticate", `Basic realm="example.com"`}, + {"WWW-Authenticate", `Bearer authorize="idp.example.com/oauth"`}, + }, + false, + map[string][]string{"Www-Authenticate": { + `Basic realm="example.com"`, + `Bearer authorize="idp.example.com/oauth"`, + }}, + }, +} + +func Test_Deny(t *testing.T) { + for _, tt := range denyTests { + t.Run(tt.title, func(t *testing.T) { + w := httptest.NewRecorder() + + // Create the AuthResult, call WriteResponse + if tt.expectedInitPanic { + assert.Panics(t, func() { auth.Deny(tt.code, tt.headers...) }) + return + } + result := auth.Deny(tt.code, tt.headers...) + wroteResponse := result.ApplyResult(w) + + // Response has been written; should exit + assert.True(t, wroteResponse) + + // Check code and content + assert.Equal(t, tt.code, w.Code) + assert.Equal(t, tt.expectedHeaders, w.Header()) + assert.Empty(t, w.Body) + }) + } +} + +var allowTests = []struct { + title string + + headers []auth.Header + expectedHeaders http.Header +}{ + { + "Allow with no headers", + []auth.Header{}, + map[string][]string{}, + }, + { + "Allow with headers", + []auth.Header{{"Cache-Control", "no-store"}}, + map[string][]string{"Cache-Control": {"no-store"}}, + }, + { + "Allow with repeated headers", + []auth.Header{ + {"FAKE-HEADER", "first value"}, + {"fake-header", "second value"}, + }, + map[string][]string{"Fake-Header": { + "first value", + "second value", + }}, + }, +} + +func Test_Allow(t *testing.T) { + for _, tt := range allowTests { + t.Run(tt.title, func(t *testing.T) { + w := httptest.NewRecorder() + + // Create the AuthResult, call WriteResponse + result := auth.Allow(tt.headers...) + wroteResponse := result.ApplyResult(w) + + // Make sure we aren't exiting + assert.False(t, wroteResponse) + + // Make sure no code or body was written, but headers are + assert.Equal(t, 200, w.Code) // default code + assert.Equal(t, tt.expectedHeaders, w.Header()) + assert.Empty(t, w.Body) + }) + } +} + +func Test_AuthResult(t *testing.T) { + t.Run("Default AuthResult writes 500 response", func(t *testing.T) { + w := httptest.NewRecorder() + + // Create the AuthResult, call WriteResponse + result := auth.AuthResult{} + wroteResponse := result.ApplyResult(w) + + // Response has been written; should exit + assert.True(t, wroteResponse) + + // Check code and content + assert.Equal(t, 500, w.Code) + assert.Empty(t, w.Header()) + assert.Empty(t, w.Body) + }) +} From 0a65d0a54df37149824a61e07c59a954a0f37678 Mon Sep 17 00:00:00 2001 From: Victoria Dye Date: Fri, 5 May 2023 11:22:57 -0700 Subject: [PATCH 2/7] bundle-server: introduce '--auth-config' web server option Add an '--auth-config' flag to specify the path to a JSON file containing information used to configure bundle server auth, as well as a function for parsing the config. For now, no modes are configured, so attempting to pass a value to the option will fail. Update the 'git-bundle-web-server' manpage to describe the auth config and its usage. Also add 'auth-config.md' technical documentation with more detail around the schema of the auth config file. These will be updated as new modes are introduced. Signed-off-by: Victoria Dye --- cmd/git-bundle-server/web-server.go | 6 +++- cmd/git-bundle-web-server/main.go | 46 +++++++++++++++++++++++++++++ cmd/utils/common-args.go | 1 + docs/man/git-bundle-web-server.adoc | 18 +++++++++++ docs/man/server-options.asc | 4 +++ docs/technical/auth-config.md | 37 +++++++++++++++++++++++ pkg/auth/middleware.go | 20 +++++++++++++ 7 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 docs/technical/auth-config.md create mode 100644 pkg/auth/middleware.go diff --git a/cmd/git-bundle-server/web-server.go b/cmd/git-bundle-server/web-server.go index 2683d26..4531ab7 100644 --- a/cmd/git-bundle-server/web-server.go +++ b/cmd/git-bundle-server/web-server.go @@ -77,7 +77,11 @@ func (w *webServerCmd) startServer(ctx context.Context, args []string) error { parser.Visit(func(f *flag.Flag) { if webServerFlags.Lookup(f.Name) != nil { value := f.Value.String() - if f.Name == "cert" || f.Name == "key" || f.Name == "client-ca" { + if f.Name == "cert" || + f.Name == "key" || + f.Name == "client-ca" || + f.Name == "auth-config" { + // Need the absolute value of the path value, err = filepath.Abs(value) if err != nil { diff --git a/cmd/git-bundle-web-server/main.go b/cmd/git-bundle-web-server/main.go index 2204a59..371fdc1 100644 --- a/cmd/git-bundle-web-server/main.go +++ b/cmd/git-bundle-web-server/main.go @@ -2,15 +2,43 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "os" + "strings" "github.com/git-ecosystem/git-bundle-server/cmd/utils" "github.com/git-ecosystem/git-bundle-server/internal/argparse" "github.com/git-ecosystem/git-bundle-server/internal/log" + "github.com/git-ecosystem/git-bundle-server/pkg/auth" ) +func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) { + var config authConfig + fileBytes, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + err = json.Unmarshal(fileBytes, &config) + if err != nil { + return nil, err + } + + switch strings.ToLower(config.AuthMode) { + default: + return nil, fmt.Errorf("unrecognized auth mode '%s'", config.AuthMode) + } +} + +type authConfig struct { + AuthMode string `json:"mode"` + + // Per-middleware custom config + Parameters json.RawMessage `json:"parameters,omitempty"` +} + func main() { log.WithTraceLogger(context.Background(), func(ctx context.Context, logger log.TraceLogger) { parser := argparse.NewArgParser(logger, "git-bundle-web-server [--port ] [--cert --key ]") @@ -28,9 +56,27 @@ func main() { key := utils.GetFlagValue[string](parser, "key") tlsMinVersion := utils.GetFlagValue[uint16](parser, "tls-version") clientCA := utils.GetFlagValue[string](parser, "client-ca") + authConfig := utils.GetFlagValue[string](parser, "auth-config") // Configure auth + var err error middlewareAuthorize := authFunc(nil) + if authConfig != "" { + middleware, err := parseAuthConfig(authConfig) + if err != nil { + logger.Fatalf(ctx, "Invalid auth config: %w", err) + } + if middleware == nil { + // Up until this point, everything indicates that a user intends + // to use - and has properly configured - custom auth. However, + // despite there being no error from the initializer, the + // middleware was empty. This is almost certainly incorrect, so + // we exit. + logger.Fatalf(ctx, "Middleware is nil, but no error was returned from initializer. "+ + "If no middleware is desired, remove the --auth-config option.") + } + middlewareAuthorize = middleware.Authorize + } // Configure the server bundleServer, err := NewBundleWebServer(logger, diff --git a/cmd/utils/common-args.go b/cmd/utils/common-args.go index 863d00d..8848ff1 100644 --- a/cmd/utils/common-args.go +++ b/cmd/utils/common-args.go @@ -86,6 +86,7 @@ func WebServerFlags(parser argParser) (*flag.FlagSet, func(context.Context)) { tlsVersion := tlsVersionValue(tls.VersionTLS12) f.Var(&tlsVersion, "tls-version", "The minimum TLS version the server will accept") f.String("client-ca", "", "The path to the client authentication certificate authority PEM") + f.String("auth-config", "", "File containing the configuration for server auth middleware") // Function to call for additional arg validation (may exit with 'Usage()') validationFunc := func(ctx context.Context) { diff --git a/docs/man/git-bundle-web-server.adoc b/docs/man/git-bundle-web-server.adoc index 4610d6a..0ef6a18 100644 --- a/docs/man/git-bundle-web-server.adoc +++ b/docs/man/git-bundle-web-server.adoc @@ -27,6 +27,24 @@ web-server* for managing the web server process on their systems. include::server-options.asc[] +== CONFIGURING AUTH + +The *--auth-config* option configures authentication middleware for the server, +either using a built-in mode or with a custom plugin. The auth config specified +by that option is a JSON file that identifies the type of access control +requested and information needed to configure it. + +=== Schema + +The auth config JSON contains the following fields: + +*mode* (string):: + The auth mode to use. Not case-sensitive. + +*parameters* (object):: + A structure containing mode-specific key-value configuration fields, if + applicable. May be optional, depending on *mode*. + == SEE ALSO man:git-bundle-server[1], man:git-bundle[1], man:git-fetch[1] diff --git a/docs/man/server-options.asc b/docs/man/server-options.asc index a586831..e94a087 100644 --- a/docs/man/server-options.asc +++ b/docs/man/server-options.asc @@ -29,3 +29,7 @@ configured for TLS, this option is a no-op. Require that requests to the bundle server include a client certificate that can be validated by the certificate authority file at the specified _path_. No-op if *--cert* and *--key* are not configured. + +*--auth-config* _path_::: + Use the JSON contents of the specified file to configure + authentication/authorization for requests to the web server. diff --git a/docs/technical/auth-config.md b/docs/technical/auth-config.md new file mode 100644 index 0000000..3cb9319 --- /dev/null +++ b/docs/technical/auth-config.md @@ -0,0 +1,37 @@ +# Configuring access control + +User access to web server endpoints on the bundle server is configured via the +`--auth-config` option to `git-bundle-web-server` and/or `git-bundle-server +web-server`. The auth config is a JSON file that identifies the type of access +control requested and information needed to configure it. + +## Schema + +The JSON file contains the following fields: + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
modestring + The auth mode to use. Not case-sensitive. +
parametersobject + A structure containing mode-specific key-value configuration + fields, if applicable. +
diff --git a/pkg/auth/middleware.go b/pkg/auth/middleware.go new file mode 100644 index 0000000..7fb91a0 --- /dev/null +++ b/pkg/auth/middleware.go @@ -0,0 +1,20 @@ +package auth + +import ( + "net/http" +) + +// AuthMiddleware provides custom authN/authZ functionality to validate requests +// to the bundle web server. +// +// BE CAREFUL! Accesses to the loaded AuthMiddleware instance will *not* be +// thread-safe. Custom implementations should ensure any writes to any common +// state are properly locked. +type AuthMiddleware interface { + // Authorize interprets the contents of a bundle server request of a valid + // format (i.e., //[/]) and returns an AuthResult + // indicating whether the request should be allowed or denied. If the + // AuthResult is invalid (not created with Allow() or Deny()), the server + // will respond with a 500 status. + Authorize(r *http.Request, owner string, repo string) AuthResult +} From 64261683cccfb0bc5ebf0a3e4254cae2922948b1 Mon Sep 17 00:00:00 2001 From: Victoria Dye Date: Wed, 10 May 2023 15:50:36 -0700 Subject: [PATCH 3/7] go.mod: upgrade to 1.20 Upgrade the minimum Go version for the project. This update is necessary to take advantage of some useful new features (including, in an upcoming patch, the ability to convert between variable- and fixed-length byte slices). Signed-off-by: Victoria Dye --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 02dd6ad..83544fe 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/git-ecosystem/git-bundle-server -go 1.19 +go 1.20 require ( github.com/google/uuid v1.3.0 From b40169eb975b347f7c755b9cb2929e5f06d82d29 Mon Sep 17 00:00:00 2001 From: Victoria Dye Date: Fri, 5 May 2023 15:42:51 -0700 Subject: [PATCH 4/7] bundle-server: implement simple user/pass auth middleware Create and test a built-in auth mode for a single fixed username/password authenticated against a provided 'Authorization: Basic' header, implementing the AuthMiddleware interface. Add the mode to 'parseAuthConfig()' in 'git-bundle-web-server' to enable its usage in the web server. Update documentation for the mode in 'docs/technical/auth-config.md' and 'git-bundle-web-server' manpage, and an explicit example JSON in the 'examples/auth/config' directory. Signed-off-by: Victoria Dye --- cmd/git-bundle-web-server/main.go | 3 + docs/man/git-bundle-web-server.adoc | 20 ++++ docs/technical/auth-config.md | 106 +++++++++++++++++- examples/auth/README.md | 19 ++++ examples/auth/config/fixed.json | 7 ++ internal/auth/middleware.go | 78 +++++++++++++ internal/auth/middleware_test.go | 164 ++++++++++++++++++++++++++++ 7 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 examples/auth/README.md create mode 100644 examples/auth/config/fixed.json create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/middleware_test.go diff --git a/cmd/git-bundle-web-server/main.go b/cmd/git-bundle-web-server/main.go index 371fdc1..0096f9c 100644 --- a/cmd/git-bundle-web-server/main.go +++ b/cmd/git-bundle-web-server/main.go @@ -10,6 +10,7 @@ import ( "github.com/git-ecosystem/git-bundle-server/cmd/utils" "github.com/git-ecosystem/git-bundle-server/internal/argparse" + auth_internal "github.com/git-ecosystem/git-bundle-server/internal/auth" "github.com/git-ecosystem/git-bundle-server/internal/log" "github.com/git-ecosystem/git-bundle-server/pkg/auth" ) @@ -27,6 +28,8 @@ func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) { } switch strings.ToLower(config.AuthMode) { + case "fixed": + return auth_internal.NewFixedCredentialAuth(config.Parameters) default: return nil, fmt.Errorf("unrecognized auth mode '%s'", config.AuthMode) } diff --git a/docs/man/git-bundle-web-server.adoc b/docs/man/git-bundle-web-server.adoc index 0ef6a18..871e27b 100644 --- a/docs/man/git-bundle-web-server.adoc +++ b/docs/man/git-bundle-web-server.adoc @@ -40,11 +40,31 @@ The auth config JSON contains the following fields: *mode* (string):: The auth mode to use. Not case-sensitive. ++ +Available options: + + - _fixed_ *parameters* (object):: A structure containing mode-specific key-value configuration fields, if applicable. May be optional, depending on *mode*. +=== Examples + +Static, server-wide username & password ("admin" & "bundle_server", +respectively): + +[source,json] +---- +{ + "mode": "fixed", + "parameters": { + "username": "admin", + "passwordHash": "c3c3520adf2f6e25672ba55dc70bcb3dd8b4ef3341bce1a5f38c5eca6571f372" + } +} +---- + == SEE ALSO man:git-bundle-server[1], man:git-bundle[1], man:git-fetch[1] diff --git a/docs/technical/auth-config.md b/docs/technical/auth-config.md index 3cb9319..bdf624c 100644 --- a/docs/technical/auth-config.md +++ b/docs/technical/auth-config.md @@ -22,7 +22,11 @@ The JSON file contains the following fields: mode string - The auth mode to use. Not case-sensitive. +

The auth mode to use. Not case-sensitive.

+ Available options: +
    +
  • fixed
  • +
@@ -35,3 +39,103 @@ The JSON file contains the following fields: + +## Built-in modes + +### Fixed/single-user auth (server-wide) + +**Mode: `fixed`** + +This mode implements [Basic authentication][basic-rfc], authenticating each +request against a fixed username/password pair that is global to the web server. + +[basic-rfc]: https://datatracker.ietf.org/doc/html/rfc7617 + +#### Parameters + +The `parameters` object _must_ be specified for this mode, and both of the +fields below are required. + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
usernamestring + The username string for authentication. The username must + not contain a colon (":"). +
passwordHashstring +

+ The SHA256 hash of the password string. There are no + restrictions on characters used for the password. +

+

+ The hash of a string can be generated on the command line + with the command + echo -n '<your string>' | shasum -a 256. +

+
+ +#### Examples + +Valid (username `admin`, password `test`): + +```json +{ + "mode": "fixed", + "parameters": { + "username": "admin", + "passwordHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } +} +``` + +Valid (empty username & password): + +```json +{ + "mode": "fixed", + "parameters": { + "usernameHash": "", + "passwordHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } +} +``` + +Invalid: + +```json +{ + "mode": "fixed", + "parameters": { + "username": "admin", + "passwordHash": "test123" + } +} +``` + +Invalid: + +```json +{ + "mode": "fixed", + "parameters": { + "username": "admin:MY_PASSWORD", + } +} +``` diff --git a/examples/auth/README.md b/examples/auth/README.md new file mode 100644 index 0000000..ec4de4f --- /dev/null +++ b/examples/auth/README.md @@ -0,0 +1,19 @@ +# Auth configuration examples + +This directory contains examples of auth configurations that may be used as a +reference for setting up auth for a bundle server. + +> **Warning** +> +> The examples contained within this directory should not be used directly in a +> production context due to publicly-visible (in this repo) credentials. + +## Built-in modes + +### Fixed credential/single-user auth + +The file [`config/fixed.json`][fixed-config] configures [Basic +authentication][basic] with username "admin" and password "bundle_server". + +[fixed-config]: ./config/fixed.json +[basic]: ../../docs/technical/auth-config.md#basic-auth-server-wide diff --git a/examples/auth/config/fixed.json b/examples/auth/config/fixed.json new file mode 100644 index 0000000..53d0d72 --- /dev/null +++ b/examples/auth/config/fixed.json @@ -0,0 +1,7 @@ +{ + "mode": "fixed", + "parameters": { + "username": "admin", + "passwordHash": "c3c3520adf2f6e25672ba55dc70bcb3dd8b4ef3341bce1a5f38c5eca6571f372" + } +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..ca92b61 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,78 @@ +package auth + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/git-ecosystem/git-bundle-server/pkg/auth" +) + +/* Built-in auth modes */ +// Authorize users with credentials matching a static username/password pair +// that applies to the whole server. +type fixedCredentialAuth struct { + usernameHash [32]byte + passwordHash [32]byte +} + +type fixedCredentialAuthParams struct { + Username string `json:"username"` + PasswordHash string `json:"passwordHash"` +} + +func NewFixedCredentialAuth(rawParameters json.RawMessage) (auth.AuthMiddleware, error) { + if len(rawParameters) == 0 { + return nil, fmt.Errorf("parameters JSON must exist") + } + + var params fixedCredentialAuthParams + err := json.Unmarshal(rawParameters, ¶ms) + if err != nil { + return nil, err + } + + // Check for invalid username characters + if strings.Contains(params.Username, ":") { + return nil, fmt.Errorf("username contains a colon (\":\")") + } + + // Make sure password hash is a valid hash + passwordHashBytes, err := hex.DecodeString(params.PasswordHash) + if err != nil { + return nil, fmt.Errorf("passwordHash is invalid: %w", err) + } else if len(passwordHashBytes) != 32 { + return nil, fmt.Errorf("passwordHash is incorrect length (%d vs. expected 32)", len(passwordHashBytes)) + } + + return &fixedCredentialAuth{ + usernameHash: sha256.Sum256([]byte(params.Username)), + passwordHash: [32]byte(passwordHashBytes), + }, nil +} + +func (a *fixedCredentialAuth) Authorize(r *http.Request, _ string, _ string) auth.AuthResult { + username, password, ok := r.BasicAuth() + if ok { + usernameHash := sha256.Sum256([]byte(username)) + passwordHash := sha256.Sum256([]byte(password)) + + usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], a.usernameHash[:]) == 1) + passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], a.passwordHash[:]) == 1) + + if usernameMatch && passwordMatch { + return auth.Allow() + } else { + // Return a 404 status even though the issue is that the user is + // forbidden so we don't indirectly reveal which repositories are + // configured in the bundle server. + return auth.Deny(404) + } + } + + return auth.Deny(401, auth.Header{Key: "WWW-Authenticate", Value: `Basic realm="restricted", charset="UTF-8"`}) +} diff --git a/internal/auth/middleware_test.go b/internal/auth/middleware_test.go new file mode 100644 index 0000000..b2f7a39 --- /dev/null +++ b/internal/auth/middleware_test.go @@ -0,0 +1,164 @@ +package auth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/git-ecosystem/git-bundle-server/internal/auth" + "github.com/stretchr/testify/assert" +) + +var basicAuthTests = []struct { + title string + + // Inputs + parameters string + authHeader string + + // Expected outputs + authInitializationError bool + expectedDoExit bool + expectedResponseCode int + expectedHeaders http.Header +}{ + { + "No auth with expected username, password returns 401", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "", + false, + true, + 401, + map[string][]string{ + "Www-Authenticate": {`Basic realm="restricted", charset="UTF-8"`}, + }, + }, + { + "Garbage auth header returns 401", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic *asdf====", + false, + true, + 401, + map[string][]string{ + "Www-Authenticate": {`Basic realm="restricted", charset="UTF-8"`}, + }, + }, + { + "Incorrect username returns 404", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic aW52YWxpZDp0ZXN0MTIz", // Base64 encoded "invalid:test123" + false, + true, + 404, + map[string][]string{}, + }, + { + "Correct username and password returns Authorized", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic YWRtaW46dGVzdDEyMw==", // Base64 encoded "admin:test123" + false, + false, + 200, + nil, + }, + { + "Empty username and password with expected auth returns Forbidden", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic Og==", // Base64 encoded ":" + false, + true, + 404, + map[string][]string{}, + }, + { + "Empty username and password is valid, return Authorized", + `{ "username": "", "passwordHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }`, // password: + "Basic Og==", // Base64 encoded ":" + false, + false, + 200, + nil, + }, + { + "Extra JSON parameters are ignored", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae", "extra": [true, false] }`, // password: test123 + "Basic YWRtaW46dGVzdDEyMw==", // Base64 encoded "admin:test123" + false, + false, + 200, + nil, + }, + { + "Empty parameter JSON throws error", + "{}", + "Basic Og==", // Base64 encoded ":" + true, + true, + -1, + nil, + }, + { + "Missing parameter JSON throws error", + "", + "", + true, + true, + -1, + nil, + }, + { + "Malformed parameter JSON throws error", + `{abc: "def"`, + "", // Base64 encoded ":" + true, + true, + -1, + nil, + }, + { + "Username with colon throws error", + `{ "username": "example:user", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic ZXhhbXBsZTp1c2VyOnRlc3QxMjM=", // Base64 encoded "example:user:test123" + true, + true, + -1, + nil, + }, +} + +func Test_FixedCredentialAuth(t *testing.T) { + for _, tt := range basicAuthTests { + t.Run(tt.title, func(t *testing.T) { + // Construct the request + req, err := http.NewRequest("GET", "test/repo", nil) + assert.Nil(t, err) + + if len(tt.authHeader) > 0 { + req.Header.Set("Authorization", tt.authHeader) + } + + // Create the auth middleware + auth, err := auth.NewFixedCredentialAuth([]byte(tt.parameters)) + if tt.authInitializationError { + assert.NotNil(t, err) + return + } + assert.Nil(t, err) + + result := auth.Authorize(req, "test", "repo") + + wExpect := httptest.NewRecorder() + if tt.expectedDoExit { + wExpect.HeaderMap = tt.expectedHeaders //lint:ignore SA1019 set headers manually for test + wExpect.WriteHeader(tt.expectedResponseCode) + } + + wActual := httptest.NewRecorder() + actualDoExit := result.ApplyResult(wActual) + + assert.Equal(t, tt.expectedDoExit, actualDoExit) + assert.Equal(t, wExpect, wActual) + }) + } +} From cd37b45fc62445330b332589f8c3cadff1baee85 Mon Sep 17 00:00:00 2001 From: Victoria Dye Date: Wed, 10 May 2023 11:36:47 -0700 Subject: [PATCH 5/7] test: verify that bundle server started Update the 'bundleServer.startWebServer()' method to wait for indication that the web server has successfully started (the "Server is running at address..." message) before returning successfully. If the process stops before that, or a timeout of 2s is reached, an error is thrown. However, because the "Server is running..." printout happens in parallel with the setup done in 'ListenAndServe[TLS]()', it is possible that the message is printed and immediately followed with an exit due to a server startup error. To mitigate that, add a 0.1s pause before printing the message and, in the tests, only set the status to 'ok' if the 'close' event has not been triggered by the time the startup message is processed. Signed-off-by: Victoria Dye --- cmd/git-bundle-web-server/bundle-server.go | 10 +++++++ test/shared/classes/bundleServer.ts | 30 +++++++++++++++++-- .../features/step_definitions/bundleServer.ts | 2 +- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/cmd/git-bundle-web-server/bundle-server.go b/cmd/git-bundle-web-server/bundle-server.go index b495f1a..8e03eed 100644 --- a/cmd/git-bundle-web-server/bundle-server.go +++ b/cmd/git-bundle-web-server/bundle-server.go @@ -185,6 +185,16 @@ func (b *bundleWebServer) StartServerAsync(ctx context.Context) { } }(ctx) + // Wait 0.1s before reporting that the server is started in case + // 'listenAndServeFunc' exits immediately. + // + // It's a hack, but a necessary one because 'ListenAndServe[TLS]()' doesn't + // have any mechanism of notifying if it starts successfully, only that it + // fails. We could get around that by copying/reimplementing those functions + // with a print statement inserted at the right place, but that's way more + // cumbersome than just adding a delay here (see: + // https://stackoverflow.com/questions/53332667/how-to-notify-when-http-server-starts-successfully). + time.Sleep(time.Millisecond * 100) fmt.Println("Server is running at address " + b.server.Addr) } diff --git a/test/shared/classes/bundleServer.ts b/test/shared/classes/bundleServer.ts index 90aa36b..94ccef0 100644 --- a/test/shared/classes/bundleServer.ts +++ b/test/shared/classes/bundleServer.ts @@ -21,12 +21,38 @@ export class BundleServer { this.bundleWebServerCmd = bundleWebServerCmd } - startWebServer(port: number): void { + async startWebServer(port: number): Promise { if (this.webServerProcess) { throw new Error("Tried to start web server, but web server is already running") } - this.webServerProcess = child_process.spawn(this.bundleWebServerCmd, ["--port", String(port)]) + const webServerProcess = child_process.spawn(this.bundleWebServerCmd, ["--port", String(port)]) + this.webServerProcess = webServerProcess this.bundleUriBase = `http://localhost:${port}/` + + // Now, ensure the server is running + var timer: NodeJS.Timeout | undefined + var ok: boolean | undefined + + await Promise.race([ + new Promise(resolve => webServerProcess.stdout.on('data', (data: string) => { + if (data.includes("Server is running at address") && ok === undefined) { + ok = true + resolve() // server is running + } + })), + new Promise(resolve => webServerProcess.on('close', () => { + ok = false + resolve() // program failed to start/exited early + })), + new Promise(resolve => timer = setTimeout(resolve, 2000)) // fallback timeout + ]) + + // If it's still running, clear the timeout so it doesn't delay shutdown + clearTimeout(timer) + + if (!ok) { + throw new Error('Failed to start web server') + } } init(remote: RemoteRepo, routePrefix: string, route: string = ""): child_process.SpawnSyncReturns { diff --git a/test/shared/features/step_definitions/bundleServer.ts b/test/shared/features/step_definitions/bundleServer.ts index 5d1cc26..220e5b8 100644 --- a/test/shared/features/step_definitions/bundleServer.ts +++ b/test/shared/features/step_definitions/bundleServer.ts @@ -2,5 +2,5 @@ import { Given } from '@cucumber/cucumber' import { BundleServerWorldBase } from '../../support/world' Given('the bundle web server was started at port {int}', async function (this: BundleServerWorldBase, port: number) { - this.bundleServer.startWebServer(port) + await this.bundleServer.startWebServer(port) }) From 1ae36b740a77afc99ad02930b9f9837419e7f9e6 Mon Sep 17 00:00:00 2001 From: Victoria Dye Date: Wed, 10 May 2023 12:01:50 -0700 Subject: [PATCH 6/7] test: add auth integration tests Add tests of the bundle web server with no auth and with a 'fixed' auth configuration. Exercise cases where the user provides no credentials, incorrect credentials, and correct credentials and the expected bundle server responses for each. Signed-off-by: Victoria Dye --- test/integration/features/auth.feature | 35 ++++++++++++++ .../features/step_definitions/auth.ts | 24 ++++++++++ .../features/step_definitions/bundleServer.ts | 48 ++++++++++++++++++- test/integration/features/support/world.ts | 1 + test/shared/classes/bundleServer.ts | 8 +++- 5 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 test/integration/features/auth.feature create mode 100644 test/integration/features/step_definitions/auth.ts diff --git a/test/integration/features/auth.feature b/test/integration/features/auth.feature new file mode 100644 index 0000000..f38764b --- /dev/null +++ b/test/integration/features/auth.feature @@ -0,0 +1,35 @@ +Feature: Auth configuration on the web server + + Background: The bundle server has an initialized route + Given no bundle server repository exists at route 'integration/auth' + Given a new remote repository with main branch 'main' + Given the remote is cloned + Given 5 commits are pushed to the remote branch 'main' + Given a bundle server repository is created at route 'integration/auth' for the remote + + Scenario: With no auth config, bundle list can be accessed anonymously + Given the bundle web server was started at port 8080 + When I request the bundle list + Then the response code is 200 + Then the response is a valid bundle list + + Scenario: If basic auth is required but none is sent, get a 401 + Given an auth config with username 'my_username' and password 'p4sSW0rD!' + Given the bundle web server was started at port 8080 with auth config + When I request the bundle list + Then the response code is 401 + Then the response is empty + + Scenario: If basic auth is required and we send bad credentials, get a 404 + Given an auth config with username 'my_username' and password 'p4sSW0rD!' + Given the bundle web server was started at port 8080 with auth config + When I request the bundle list with username 'my_userName' and password 'password!' + Then the response code is 404 + Then the response is empty + + Scenario: If basic auth is required and we send the right credentials, can access the bundle list + Given an auth config with username 'my_username' and password 'p4sSW0rD!' + Given the bundle web server was started at port 8080 with auth config + When I request the bundle list with username 'my_username' and password 'p4sSW0rD!' + Then the response code is 200 + Then the response is a valid bundle list diff --git a/test/integration/features/step_definitions/auth.ts b/test/integration/features/step_definitions/auth.ts new file mode 100644 index 0000000..5285883 --- /dev/null +++ b/test/integration/features/step_definitions/auth.ts @@ -0,0 +1,24 @@ +import { IntegrationBundleServerWorld } from '../support/world' +import { Given } from '@cucumber/cucumber' +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto'; + +Given('an auth config with username {string} and password {string}', + async function (this: IntegrationBundleServerWorld, user: string, pass: string) { + const config = { + "mode": "fixed", + "parameters": { + "username": user, + "passwordHash": crypto.createHash('sha256').update(pass).digest('hex') + } + } + fs.writeFileSync(path.join(this.trashDirectory, "auth-config.json"), JSON.stringify(config)) + } +) + +Given('the bundle web server was started at port {int} with auth config', + async function (this: IntegrationBundleServerWorld, port: number) { + await this.bundleServer.startWebServer(port, path.join(this.trashDirectory, "auth-config.json")) + } +) diff --git a/test/integration/features/step_definitions/bundleServer.ts b/test/integration/features/step_definitions/bundleServer.ts index da330a0..1f5b545 100644 --- a/test/integration/features/step_definitions/bundleServer.ts +++ b/test/integration/features/step_definitions/bundleServer.ts @@ -1,6 +1,6 @@ import * as assert from 'assert' import { IntegrationBundleServerWorld } from '../support/world' -import { Given, Then } from '@cucumber/cucumber' +import { Given, When, Then } from '@cucumber/cucumber' import * as utils from '../../../shared/support/utils' import * as fs from 'fs' @@ -18,6 +18,27 @@ Given('no bundle server repository exists at route {string}', async function (th } }) +When('I request the bundle list', async function (this: IntegrationBundleServerWorld) { + this.requestResponse = await fetch(this.bundleServer.bundleUri(), { + method: 'GET', + headers: { + Accept: 'text/plain', + }, + }); +}) + +When('I request the bundle list with username {string} and password {string}', + async function (this: IntegrationBundleServerWorld, user: string, pass: string) { + this.requestResponse = await fetch(this.bundleServer.bundleUri(), { + method: 'GET', + headers: { + Accept: 'text/plain', + Authorization: 'Basic ' + Buffer.from(`${user}:${pass}`, 'utf8').toString('base64') + }, + }); + } +) + Then('a bundle server repository exists at route {string}', async function (this: IntegrationBundleServerWorld, route: string) { var repoRoot = utils.repoRoot(route) assert.equal(fs.existsSync(repoRoot), true) @@ -71,3 +92,28 @@ Then('the route exists in the routes file', async function (this: IntegrationBun throw new Error("Route not set") } }) + +Then('the response code is {int}', async function (this: IntegrationBundleServerWorld, code: number) { + if (!this.requestResponse) { + throw new Error("Request response not set") + } + assert.strictEqual(this.requestResponse.status, code) +}) + +Then('the response is a valid bundle list', async function (this: IntegrationBundleServerWorld) { + if (!this.requestResponse) { + throw new Error("Request response not set") + } + + const data = await this.requestResponse.text() + assert.notStrictEqual(data, "") +}) + +Then('the response is empty', async function (this: IntegrationBundleServerWorld) { + if (!this.requestResponse) { + throw new Error("Request response not set") + } + + const data = await this.requestResponse.text() + assert.strictEqual(data, "") +}) diff --git a/test/integration/features/support/world.ts b/test/integration/features/support/world.ts index 2525eed..cb4b227 100644 --- a/test/integration/features/support/world.ts +++ b/test/integration/features/support/world.ts @@ -10,6 +10,7 @@ export class IntegrationBundleServerWorld extends BundleServerWorldBase { local: ClonedRepository | undefined commandResult: child_process.SpawnSyncReturns | undefined + requestResponse: Response | undefined runCommand(commandArgs: string): void { this.commandResult = child_process.spawnSync(`${this.parameters.bundleServerCommand} ${commandArgs}`, [], { shell: true }) diff --git a/test/shared/classes/bundleServer.ts b/test/shared/classes/bundleServer.ts index 94ccef0..5a19b77 100644 --- a/test/shared/classes/bundleServer.ts +++ b/test/shared/classes/bundleServer.ts @@ -21,11 +21,15 @@ export class BundleServer { this.bundleWebServerCmd = bundleWebServerCmd } - async startWebServer(port: number): Promise { + async startWebServer(port: number, authConfig: string = ""): Promise { if (this.webServerProcess) { throw new Error("Tried to start web server, but web server is already running") } - const webServerProcess = child_process.spawn(this.bundleWebServerCmd, ["--port", String(port)]) + let args = ["--port", String(port)] + if (authConfig !== "") { + args = args.concat(["--auth-config", authConfig]) + } + const webServerProcess = child_process.spawn(this.bundleWebServerCmd, args) this.webServerProcess = webServerProcess this.bundleUriBase = `http://localhost:${port}/` From fdb0d44fc9c24ccf9cb808aae1669e9741c2b8cb Mon Sep 17 00:00:00 2001 From: Victoria Dye Date: Fri, 5 May 2023 17:00:34 -0700 Subject: [PATCH 7/7] bundle-server: implement plugin-based auth Implement and document auth configurations using user-specified runtime plugins. The goal of this change is to allow users to customize their access control to a more specific application or domain than is supported by built-in modes. A plugin is loaded in 'git-bundle-web-server' from its 'path' by first comparing the file contents to a specified SHA256 checksum; the web server fails to start if there is a mismatch. Otherwise, the plugin is loaded and the specified 'initializer' symbol is looked up. If that symbol exists, the initializer is called, creating the 'AuthMiddleware' instance and/or an error. From there, 'git-bundle-web-server' passes the middleware's 'Authorize' function reference to the created 'bundleWebServer' so that it is invoked after parsing the route of each request. Additionally, update manpage and technical documentation. Add an example plugin (built from a standalone '.go' file) & config to 'examples/auth/'. Signed-off-by: Victoria Dye --- .gitignore | 2 + cmd/git-bundle-web-server/main.go | 75 ++++++++++++++ docs/man/git-bundle-web-server.adoc | 49 ++++++++++ docs/technical/auth-config.md | 125 +++++++++++++++++++++++- examples/auth/README.md | 35 +++++++ examples/auth/_plugins/simple-plugin.go | 45 +++++++++ examples/auth/config/plugin.json | 6 ++ 7 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 examples/auth/_plugins/simple-plugin.go create mode 100644 examples/auth/config/plugin.json diff --git a/.gitignore b/.gitignore index d865eba..ed0320f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ /_docs/ /_test/ node_modules/ + +*.so diff --git a/cmd/git-bundle-web-server/main.go b/cmd/git-bundle-web-server/main.go index 0096f9c..19b621e 100644 --- a/cmd/git-bundle-web-server/main.go +++ b/cmd/git-bundle-web-server/main.go @@ -1,11 +1,17 @@ package main import ( + "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "flag" "fmt" + "hash" + "io" "os" + "plugin" "strings" "github.com/git-ecosystem/git-bundle-server/cmd/utils" @@ -15,6 +21,21 @@ import ( "github.com/git-ecosystem/git-bundle-server/pkg/auth" ) +func getPluginChecksum(pluginPath string) (hash.Hash, error) { + file, err := os.Open(pluginPath) + if err != nil { + return nil, err + } + defer file.Close() + + checksum := sha256.New() + if _, err := io.Copy(checksum, file); err != nil { + return nil, err + } + + return checksum, nil +} + func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) { var config authConfig fileBytes, err := os.ReadFile(configPath) @@ -30,6 +51,55 @@ func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) { switch strings.ToLower(config.AuthMode) { case "fixed": return auth_internal.NewFixedCredentialAuth(config.Parameters) + case "plugin": + if len(config.Path) == 0 { + return nil, fmt.Errorf("plugin .so is empty") + } + if len(config.Initializer) == 0 { + return nil, fmt.Errorf("plugin initializer symbol is empty") + } + if len(config.Checksum) == 0 { + return nil, fmt.Errorf("SHA256 checksum of plugin file is empty") + } + + // First, verify plugin checksum matches expected + // Note: time-of-check/time-of-use could be exploited here (anywhere + // between the checksum check and invoking the initializer). There's not + // much we can realistically do about that short of rewriting the plugin + // package, so we advise users to carefully control access to their + // system & limit write permissions on their plugin files as a + // mitigation (see docs/technical/auth-config.md). + expectedChecksum, err := hex.DecodeString(config.Checksum) + if err != nil { + return nil, fmt.Errorf("plugin checksum is invalid: %w", err) + } + checksum, err := getPluginChecksum(config.Path) + if err != nil { + return nil, fmt.Errorf("could not calculate plugin checksum: %w", err) + } + + if !bytes.Equal(expectedChecksum, checksum.Sum(nil)) { + return nil, fmt.Errorf("specified hash does not match plugin checksum") + } + + // Load the plugin and find the initializer function + p, err := plugin.Open(config.Path) + if err != nil { + return nil, fmt.Errorf("could not load auth plugin: %w", err) + } + + rawInit, err := p.Lookup(config.Initializer) + if err != nil { + return nil, fmt.Errorf("failed to load initializer: %w", err) + } + + initializer, ok := rawInit.(func(json.RawMessage) (auth.AuthMiddleware, error)) + if !ok { + return nil, fmt.Errorf("initializer function has incorrect signature") + } + + // Call the initializer + return initializer(config.Parameters) default: return nil, fmt.Errorf("unrecognized auth mode '%s'", config.AuthMode) } @@ -38,6 +108,11 @@ func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) { type authConfig struct { AuthMode string `json:"mode"` + // Plugin-specific settings + Path string `json:"path,omitempty"` + Initializer string `json:"initializer,omitempty"` + Checksum string `json:"sha256,omitempty"` + // Per-middleware custom config Parameters json.RawMessage `json:"parameters,omitempty"` } diff --git a/docs/man/git-bundle-web-server.adoc b/docs/man/git-bundle-web-server.adoc index 871e27b..8174336 100644 --- a/docs/man/git-bundle-web-server.adoc +++ b/docs/man/git-bundle-web-server.adoc @@ -49,8 +49,34 @@ Available options: A structure containing mode-specific key-value configuration fields, if applicable. May be optional, depending on *mode*. +*path* (string) - *plugin*-only:: + The absolute path to the auth plugin .so file. + +*initializer* (string) - *plugin*-only:: + The name of the symbol within the plugin binary that can invoked to create an + 'AuthMiddleware' instance. The initializer: + + - Must have the signature 'func(json.RawMessage) (AuthMiddleware, error)'. + - Must be exported in its package (i.e., UpperCamelCase name). + +*sha256* (string) - *plugin*-only:: + The SHA256 checksum of the plugin .so file, rendered as a hex string. If the + checksum does not match the calculated checksum of the plugin file, the web + server will refuse to start. ++ +The checksum can be determined using man:shasum[1]: ++ +[source,console] +---- +$ shasum -a 256 /path/to/your/plugin.so +---- + === Examples +The following examples demonstrate typical usage of built-in and plugin modes. + +*** + Static, server-wide username & password ("admin" & "bundle_server", respectively): @@ -65,6 +91,29 @@ respectively): } ---- +*** + +A custom auth plugin implementation: + + - The path to the Go plugin file is '/path/to/plugin.so' + - The file contains the symbol + 'func NewSimplePluginAuth(rawParams json.RawMessage) (AuthMiddleware, error)' + - The initializer ignores 'rawParams' + - The SHA256 checksum of '/path/to/plugin.so' is + '49db14bb838417a0292e293d0a6e90e82ed26fccb0d78670827c8c8516d2cca6' + +[source,json] +---- +{ + "mode": "plugin", + "path": "/path/to/plugin.so", + "initializer": "NewSimplePluginAuth", + "sha256": "49db14bb838417a0292e293d0a6e90e82ed26fccb0d78670827c8c8516d2cca6" +} +---- + +*** + == SEE ALSO man:git-bundle-server[1], man:git-bundle[1], man:git-fetch[1] diff --git a/docs/technical/auth-config.md b/docs/technical/auth-config.md index bdf624c..1edcf7d 100644 --- a/docs/technical/auth-config.md +++ b/docs/technical/auth-config.md @@ -12,6 +12,7 @@ The JSON file contains the following fields: + @@ -19,6 +20,7 @@ The JSON file contains the following fields: + - + + + + + + + + + + + + + + + + +
Field Type Description
Common fields mode string @@ -26,17 +28,55 @@ The JSON file contains the following fields: Available options:
  • fixed
  • +
  • plugin
parametersparameters (optional; depends on mode) object A structure containing mode-specific key-value configuration fields, if applicable.
plugin-onlypathstring + The absolute path to the auth plugin .so file. +
initializerstring + The name of the symbol within the plugin binary that can invoked + to create the AuthMiddleware. The initializer: +
    +
  • + Must have the signature + func(json.RawMessage) (AuthMiddleware, error). +
  • +
  • + Must be exported in its package (i.e., + UpperCamelCase name). +
  • +
+ See Plugin mode for more details. +
sha256string + The SHA256 checksum of the plugin .so file, + rendered as a hex string. If the checksum does not match the + calculated checksum of the plugin file, the web server will + refuse to start. +
@@ -139,3 +179,86 @@ Invalid: } } ``` + +## Plugin mode + +**Mode: `plugin`** + +Plugin mode allows users to develop their custom auth middleware to serve a more +specific platform or need than the built-in modes (e.g., host-based federated +access). The bundle server makes use of Go's [`plugin`][plugin] package to load +the plugin and create an instance of the specified middleware. + +### The plugin + +The plugin is a `.so` shared library built using `go build`'s +`-buildmode=plugin` option. The custom auth middleware must implement the +`AuthMiddleware` interface defined in the exported `auth` package of this +repository. Additionally, the plugin must contain an initializer function that +creates and returns the custom `AuthMiddleware` interface. The function +signature of this initializer is: + +```go +func (json.RawMessage) (AuthMiddleware, error) +``` + +- The `json.RawMessage` input is the raw bytes of the `parameters` object (empty + if `parameters` is not in the auth config JSON). +- The `AuthMiddleware` is an instance of the plugin's custom `AuthMiddleware` + implementation. If this is `nil` and `error` is not `nil`, the web server will + fail to start. +- If the `AuthMiddleware` cannot be initialized, the `error` captures the + context of the failure. If `error` is not `nil`, the web server will fail to + start. + +> **Note** +> +> While this project is in a pre-release/alpha state, the `AuthMiddleware` +> and initializer interfaces may change, breaking older plugins. + +After the `AuthMiddleware` is loaded, its `Authorize()` function will be called +for each valid route request. The `AuthResult` returned must be created with one +of `Allow()` or `Deny()`; an accepted request will continue on to the logic for +serving bundle server content, a rejected one will return immediately with the +specified code and headers. + +Note that these requests may be processed in parallel, therefore **it is up to +the developer of the plugin to ensure their middleware's `Authorize()` function +is thread-safe**! Failure to do so could create race conditions and lead to +unexpected behavior. + +### The config + +When using `plugin` mode in the auth config, there are a few additional fields +that must be specified that are not required for built-in modes: `path`, +`initializer`, `sha256`. + +There are multiple ways to determine the SHA256 checksum of a file, but an +easy way to do so on the command line is: + +```bash +shasum -a 256 path/to/your/plugin.so +``` + +> **Warning** +> +> In the current plugin-loading implementation, the SHA256 checksum of the +> specified plugin is calculated and compared before loading its symbols. This +> opens up the possibility of a [time-of-check/time-of-use][toctou] attack +> wherein a malicious actor replaces a valid plugin file with their own plugin +> _after_ the checksum verification of the "good" file but before the plugin is +> loaded into memory. +> +> To mitigate this risk, ensure 'write' permissions are disabled on your plugin +> file. And, as always, practice caution when running third party code that +> interacts with credentials and other sensitive information. + +[plugin]: https://pkg.go.dev/plugin +[toctou]: https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use + +### Examples + +An example plugin and corresponding config can be found in the +[`examples/auth`][examples-dir] directory of this repository. + +[examples-dir]: ../../examples/auth diff --git a/examples/auth/README.md b/examples/auth/README.md index ec4de4f..a47b080 100644 --- a/examples/auth/README.md +++ b/examples/auth/README.md @@ -17,3 +17,38 @@ authentication][basic] with username "admin" and password "bundle_server". [fixed-config]: ./config/fixed.json [basic]: ../../docs/technical/auth-config.md#basic-auth-server-wide + +## Plugin mode + +The example plugin implemented in [`_plugins/simple-plugin.go`][simple-plugin] +can be built (from this directory) with: + +```bash +go build -buildmode=plugin -o ./plugins/ ./_plugins/simple-plugin.go +``` + +which will create `simple-plugin.so` - this is your plugin file. + +To use this plugin with `git-bundle-web-server`, the config in +[`config/plugin.json`][plugin-config] needs to be updated with the SHA256 +checksum of the plugin. This value can be determined by running (from this +directory): + +```bash +shasum -a 256 ./_plugins/simple-plugin.so +``` + +The configured `simple-plugin.so` auth middleware implements Basic +authentication with a hardcoded username "admin" and a password that is based on +the requested route (if the requested route is `test/repo` or +`test/repo/bundle-123456.bundle`, the password is "test_repo"). + +> **Note** +> +> The example `plugin.json` contains a relative, rather than absolute, path to +> the plugin file, relative to the root of this repository. This is meant to +> facilitate more portable testing and is _not_ recommended for typical use; +> please use an absolute path to identify your plugin file. + +[simple-plugin]: ./_plugins/simple-plugin.go +[plugin-config]: ./config/plugin.json diff --git a/examples/auth/_plugins/simple-plugin.go b/examples/auth/_plugins/simple-plugin.go new file mode 100644 index 0000000..b7120e6 --- /dev/null +++ b/examples/auth/_plugins/simple-plugin.go @@ -0,0 +1,45 @@ +package main + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/json" + "net/http" + + "github.com/git-ecosystem/git-bundle-server/pkg/auth" +) + +type simplePluginAuth struct { + usernameHash [32]byte +} + +// Example auth plugin: basic auth with username "admin" and password +// "{owner}_{repo}" (based on the owner & repo from the route). +// DO NOT USE THIS IN A PRODUCTION BUNDLE SERVER. +func NewSimplePluginAuth(_ json.RawMessage) (auth.AuthMiddleware, error) { + return &simplePluginAuth{ + usernameHash: sha256.Sum256([]byte("admin")), + }, nil +} + +// Nearly identical to Basic auth, but with a per-request password +func (a *simplePluginAuth) Authorize(r *http.Request, owner string, repo string) auth.AuthResult { + username, password, ok := r.BasicAuth() + if ok { + usernameHash := sha256.Sum256([]byte(username)) + passwordHash := sha256.Sum256([]byte(password)) + + perRoutePasswordHash := sha256.Sum256([]byte(owner + "_" + repo)) + + usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], a.usernameHash[:]) == 1) + passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], perRoutePasswordHash[:]) == 1) + + if usernameMatch && passwordMatch { + return auth.Allow() + } else { + return auth.Deny(404) + } + } + + return auth.Deny(401, auth.Header{"WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`}) +} diff --git a/examples/auth/config/plugin.json b/examples/auth/config/plugin.json new file mode 100644 index 0000000..f9c2927 --- /dev/null +++ b/examples/auth/config/plugin.json @@ -0,0 +1,6 @@ +{ + "mode": "plugin", + "path": "examples/auth/_plugins/simple-plugin.so", + "initializer": "NewSimplePluginAuth", + "sha256": "" +}