Skip to content

Commit

Permalink
bundle-server: implement plugin-based auth
Browse files Browse the repository at this point in the history
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 technical documentation and add an example plugin
(built from a standalone '.go' file) & config.

Signed-off-by: Victoria Dye <[email protected]>
  • Loading branch information
vdye committed May 10, 2023
1 parent a5237e7 commit 065d28d
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@
/_docs/
/_test/
node_modules/

*.so
75 changes: 75 additions & 0 deletions cmd/git-bundle-web-server/main.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -14,6 +20,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)
Expand All @@ -29,6 +50,55 @@ func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) {
switch strings.ToLower(config.AuthMode) {
case "fixed":
return auth.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)
}
Expand All @@ -37,6 +107,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"`
}
Expand Down
125 changes: 124 additions & 1 deletion docs/technical/auth-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,71 @@ The JSON file contains the following fields:
<table>
<thead>
<tr>
<th/>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<th rowspan="2">Common fields</th>
<td><code>mode</code></td>
<td>string</td>
<td>
<p>The auth mode to use. Not case-sensitive.</p>
Available options:
<ul>
<li><code>fixed</code></li>
<li><code>plugin</code></li>
</ul>
</td>
</tr>
<tr>
<td><code>parameters</code></td>
<td><code>parameters</code> (optional; depends on mode)</td>
<td>object</td>
<td>
A structure containing mode-specific key-value configuration
fields, if applicable.
</td>
</tr>
<tr>
<th rowspan="3"><code>plugin</code>-specific fields</th>
<td><code>path</code></td>
<td>string</td>
<td>
The absolute path to the auth plugin <code>.so</code> file.
</td>
</tr>
<tr>
<td><code>initializer</code></td>
<td>string</td>
<td>
The name of the symbol within the plugin binary that can invoked
to create the <code>AuthMiddleware</code>. The initializer:
<ul>
<li>
Must have the signature
<code>func(json.RawMessage) (AuthMiddleware, error)</code>.
</li>
<li>
Must be exported in its package (i.e.,
<code>UpperCamelCase</code> name).
</li>
</ul>
See <a href="#plugin-mode">Plugin mode</a> for more details.
</td>
</tr>
<tr>
<td><code>sha256</code></td>
<td>string</td>
<td>
The SHA256 checksum of the plugin <code>.so</code> 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.
</td>
</tr>
</tbody>
</table>

Expand Down Expand Up @@ -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 `Accept()` or `Reject()`; 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
35 changes: 35 additions & 0 deletions examples/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 45 additions & 0 deletions examples/auth/_plugins/simple-plugin.go
Original file line number Diff line number Diff line change
@@ -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.Authorized()
} else {
return auth.Forbidden()
}
}

return auth.Unauthorized(auth.Header("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`))
}
6 changes: 6 additions & 0 deletions examples/auth/config/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"mode": "plugin",
"path": "examples/auth/_plugins/simple-plugin.so",
"initializer": "NewSimplePluginAuth",
"sha256": "<plugin SHA256 checksum>"
}

0 comments on commit 065d28d

Please sign in to comment.