From ec13561d8981dc0c13d74a87e15c684299d679a4 Mon Sep 17 00:00:00 2001 From: Victoria Dye Date: Fri, 5 May 2023 17:00:34 -0700 Subject: [PATCH] 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..f02d9ee 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 is calculated and compared before reading in the plugin. 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..7901b6e --- /dev/null +++ b/examples/auth/config/plugin.json @@ -0,0 +1,6 @@ +{ + "mode": "plugin", + "path": "examples/auth/_plugins/simple-plugin.so", + "initializer": "NewSimplePluginAuth", + "sha256": "" +} \ No newline at end of file