Skip to content

Commit 065d28d

Browse files
committed
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 technical documentation and add an example plugin (built from a standalone '.go' file) & config. Signed-off-by: Victoria Dye <[email protected]>
1 parent a5237e7 commit 065d28d

File tree

6 files changed

+287
-1
lines changed

6 files changed

+287
-1
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@
66
/_docs/
77
/_test/
88
node_modules/
9+
10+
*.so

cmd/git-bundle-web-server/main.go

+75
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
package main
22

33
import (
4+
"bytes"
45
"context"
6+
"crypto/sha256"
7+
"encoding/hex"
58
"encoding/json"
69
"flag"
710
"fmt"
11+
"hash"
12+
"io"
813
"os"
14+
"plugin"
915
"strings"
1016

1117
"github.com/git-ecosystem/git-bundle-server/cmd/utils"
@@ -14,6 +20,21 @@ import (
1420
"github.com/git-ecosystem/git-bundle-server/pkg/auth"
1521
)
1622

23+
func getPluginChecksum(pluginPath string) (hash.Hash, error) {
24+
file, err := os.Open(pluginPath)
25+
if err != nil {
26+
return nil, err
27+
}
28+
defer file.Close()
29+
30+
checksum := sha256.New()
31+
if _, err := io.Copy(checksum, file); err != nil {
32+
return nil, err
33+
}
34+
35+
return checksum, nil
36+
}
37+
1738
func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) {
1839
var config authConfig
1940
fileBytes, err := os.ReadFile(configPath)
@@ -29,6 +50,55 @@ func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) {
2950
switch strings.ToLower(config.AuthMode) {
3051
case "fixed":
3152
return auth.NewFixedCredentialAuth(config.Parameters)
53+
case "plugin":
54+
if len(config.Path) == 0 {
55+
return nil, fmt.Errorf("plugin .so is empty")
56+
}
57+
if len(config.Initializer) == 0 {
58+
return nil, fmt.Errorf("plugin initializer symbol is empty")
59+
}
60+
if len(config.Checksum) == 0 {
61+
return nil, fmt.Errorf("SHA256 checksum of plugin file is empty")
62+
}
63+
64+
// First, verify plugin checksum matches expected
65+
// Note: time-of-check/time-of-use could be exploited here (anywhere
66+
// between the checksum check and invoking the initializer). There's not
67+
// much we can realistically do about that short of rewriting the plugin
68+
// package, so we advise users to carefully control access to their
69+
// system & limit write permissions on their plugin files as a
70+
// mitigation (see docs/technical/auth-config.md).
71+
expectedChecksum, err := hex.DecodeString(config.Checksum)
72+
if err != nil {
73+
return nil, fmt.Errorf("plugin checksum is invalid: %w", err)
74+
}
75+
checksum, err := getPluginChecksum(config.Path)
76+
if err != nil {
77+
return nil, fmt.Errorf("could not calculate plugin checksum: %w", err)
78+
}
79+
80+
if !bytes.Equal(expectedChecksum, checksum.Sum(nil)) {
81+
return nil, fmt.Errorf("specified hash does not match plugin checksum")
82+
}
83+
84+
// Load the plugin and find the initializer function
85+
p, err := plugin.Open(config.Path)
86+
if err != nil {
87+
return nil, fmt.Errorf("could not load auth plugin: %w", err)
88+
}
89+
90+
rawInit, err := p.Lookup(config.Initializer)
91+
if err != nil {
92+
return nil, fmt.Errorf("failed to load initializer: %w", err)
93+
}
94+
95+
initializer, ok := rawInit.(func(json.RawMessage) (auth.AuthMiddleware, error))
96+
if !ok {
97+
return nil, fmt.Errorf("initializer function has incorrect signature")
98+
}
99+
100+
// Call the initializer
101+
return initializer(config.Parameters)
32102
default:
33103
return nil, fmt.Errorf("unrecognized auth mode '%s'", config.AuthMode)
34104
}
@@ -37,6 +107,11 @@ func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) {
37107
type authConfig struct {
38108
AuthMode string `json:"mode"`
39109

110+
// Plugin-specific settings
111+
Path string `json:"path,omitempty"`
112+
Initializer string `json:"initializer,omitempty"`
113+
Checksum string `json:"sha256,omitempty"`
114+
40115
// Per-middleware custom config
41116
Parameters json.RawMessage `json:"parameters,omitempty"`
42117
}

docs/technical/auth-config.md

+124-1
Original file line numberDiff line numberDiff line change
@@ -12,31 +12,71 @@ The JSON file contains the following fields:
1212
<table>
1313
<thead>
1414
<tr>
15+
<th/>
1516
<th>Field</th>
1617
<th>Type</th>
1718
<th>Description</th>
1819
</tr>
1920
</thead>
2021
<tbody>
2122
<tr>
23+
<th rowspan="2">Common fields</th>
2224
<td><code>mode</code></td>
2325
<td>string</td>
2426
<td>
2527
<p>The auth mode to use. Not case-sensitive.</p>
2628
Available options:
2729
<ul>
2830
<li><code>fixed</code></li>
31+
<li><code>plugin</code></li>
2932
</ul>
3033
</td>
3134
</tr>
3235
<tr>
33-
<td><code>parameters</code></td>
36+
<td><code>parameters</code> (optional; depends on mode)</td>
3437
<td>object</td>
3538
<td>
3639
A structure containing mode-specific key-value configuration
3740
fields, if applicable.
3841
</td>
3942
</tr>
43+
<tr>
44+
<th rowspan="3"><code>plugin</code>-specific fields</th>
45+
<td><code>path</code></td>
46+
<td>string</td>
47+
<td>
48+
The absolute path to the auth plugin <code>.so</code> file.
49+
</td>
50+
</tr>
51+
<tr>
52+
<td><code>initializer</code></td>
53+
<td>string</td>
54+
<td>
55+
The name of the symbol within the plugin binary that can invoked
56+
to create the <code>AuthMiddleware</code>. The initializer:
57+
<ul>
58+
<li>
59+
Must have the signature
60+
<code>func(json.RawMessage) (AuthMiddleware, error)</code>.
61+
</li>
62+
<li>
63+
Must be exported in its package (i.e.,
64+
<code>UpperCamelCase</code> name).
65+
</li>
66+
</ul>
67+
See <a href="#plugin-mode">Plugin mode</a> for more details.
68+
</td>
69+
</tr>
70+
<tr>
71+
<td><code>sha256</code></td>
72+
<td>string</td>
73+
<td>
74+
The SHA256 checksum of the plugin <code>.so</code> file,
75+
rendered as a hex string. If the checksum does not match the
76+
calculated checksum of the plugin file, the web server will
77+
refuse to start.
78+
</td>
79+
</tr>
4080
</tbody>
4181
</table>
4282

@@ -139,3 +179,86 @@ Invalid:
139179
}
140180
}
141181
```
182+
183+
## Plugin mode
184+
185+
**Mode: `plugin`**
186+
187+
Plugin mode allows users to develop their custom auth middleware to serve a more
188+
specific platform or need than the built-in modes (e.g., host-based federated
189+
access). The bundle server makes use of Go's [`plugin`][plugin] package to load
190+
the plugin and create an instance of the specified middleware.
191+
192+
### The plugin
193+
194+
The plugin is a `.so` shared library built using `go build`'s
195+
`-buildmode=plugin` option. The custom auth middleware must implement the
196+
`AuthMiddleware` interface defined in the exported `auth` package of this
197+
repository. Additionally, the plugin must contain an initializer function that
198+
creates and returns the custom `AuthMiddleware` interface. The function
199+
signature of this initializer is:
200+
201+
```go
202+
func (json.RawMessage) (AuthMiddleware, error)
203+
```
204+
205+
- The `json.RawMessage` input is the raw bytes of the `parameters` object (empty
206+
if `parameters` is not in the auth config JSON).
207+
- The `AuthMiddleware` is an instance of the plugin's custom `AuthMiddleware`
208+
implementation. If this is `nil` and `error` is not `nil`, the web server will
209+
fail to start.
210+
- If the `AuthMiddleware` cannot be initialized, the `error` captures the
211+
context of the failure. If `error` is not `nil`, the web server will fail to
212+
start.
213+
214+
> **Note**
215+
>
216+
> While this project is in a pre-release/alpha state, the `AuthMiddleware`
217+
> and initializer interfaces may change, breaking older plugins.
218+
219+
After the `AuthMiddleware` is loaded, its `Authorize()` function will be called
220+
for each valid route request. The `AuthResult` returned must be created with one
221+
of `Accept()` or `Reject()`; an accepted request will continue on to the logic
222+
for serving bundle server content, a rejected one will return immediately with
223+
the specified code and headers.
224+
225+
Note that these requests may be processed in parallel, therefore **it is up to
226+
the developer of the plugin to ensure their middleware's `Authorize()` function
227+
is thread-safe**! Failure to do so could create race conditions and lead to
228+
unexpected behavior.
229+
230+
### The config
231+
232+
When using `plugin` mode in the auth config, there are a few additional fields
233+
that must be specified that are not required for built-in modes: `path`,
234+
`initializer`, `sha256`.
235+
236+
There are multiple ways to determine the SHA256 checksum of a file, but an
237+
easy way to do so on the command line is:
238+
239+
```bash
240+
shasum -a 256 path/to/your/plugin.so
241+
```
242+
243+
> **Warning**
244+
>
245+
> In the current plugin-loading implementation, the SHA256 checksum of the
246+
> specified is calculated and compared before reading in the plugin. This opens
247+
> up the possibility of a [time-of-check/time-of-use][toctou] attack wherein a
248+
> malicious actor replaces a valid plugin file with their own plugin _after_ the
249+
> checksum verification of the "good" file but before the plugin is loaded into
250+
> memory.
251+
>
252+
> To mitigate this risk, ensure 'write' permissions are disabled on your plugin
253+
> file. And, as always, practice caution when running third party code that
254+
> interacts with credentials and other sensitive information.
255+
256+
[plugin]: https://pkg.go.dev/plugin
257+
[toctou]: https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use
258+
259+
### Examples
260+
261+
An example plugin and corresponding config can be found in the
262+
[`examples/auth`][examples-dir] directory of this repository.
263+
264+
[examples-dir]: ../../examples/auth

examples/auth/README.md

+35
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,38 @@ authentication][basic] with username "admin" and password "bundle_server".
1717

1818
[fixed-config]: ./config/fixed.json
1919
[basic]: ../../docs/technical/auth-config.md#basic-auth-server-wide
20+
21+
## Plugin mode
22+
23+
The example plugin implemented in [`_plugins/simple-plugin.go`][simple-plugin]
24+
can be built (from this directory) with:
25+
26+
```bash
27+
go build -buildmode=plugin -o ./plugins/ ./_plugins/simple-plugin.go
28+
```
29+
30+
which will create `simple-plugin.so` - this is your plugin file.
31+
32+
To use this plugin with `git-bundle-web-server`, the config in
33+
[`config/plugin.json`][plugin-config] needs to be updated with the SHA256
34+
checksum of the plugin. This value can be determined by running (from this
35+
directory):
36+
37+
```bash
38+
shasum -a 256 ./_plugins/simple-plugin.so
39+
```
40+
41+
The configured `simple-plugin.so` auth middleware implements Basic
42+
authentication with a hardcoded username "admin" and a password that is based on
43+
the requested route (if the requested route is `test/repo` or
44+
`test/repo/bundle-123456.bundle`, the password is "test_repo").
45+
46+
> **Note**
47+
>
48+
> The example `plugin.json` contains a relative, rather than absolute, path to
49+
> the plugin file, relative to the root of this repository. This is meant to
50+
> facilitate more portable testing and is _not_ recommended for typical use;
51+
> please use an absolute path to identify your plugin file.
52+
53+
[simple-plugin]: ./_plugins/simple-plugin.go
54+
[plugin-config]: ./config/plugin.json
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package main
2+
3+
import (
4+
"crypto/sha256"
5+
"crypto/subtle"
6+
"encoding/json"
7+
"net/http"
8+
9+
"github.com/git-ecosystem/git-bundle-server/pkg/auth"
10+
)
11+
12+
type simplePluginAuth struct {
13+
usernameHash [32]byte
14+
}
15+
16+
// Example auth plugin: basic auth with username "admin" and password
17+
// "{owner}_{repo}" (based on the owner & repo from the route).
18+
// DO NOT USE THIS IN A PRODUCTION BUNDLE SERVER.
19+
func NewSimplePluginAuth(_ json.RawMessage) (auth.AuthMiddleware, error) {
20+
return &simplePluginAuth{
21+
usernameHash: sha256.Sum256([]byte("admin")),
22+
}, nil
23+
}
24+
25+
// Nearly identical to Basic auth, but with a per-request password
26+
func (a *simplePluginAuth) Authorize(r *http.Request, owner string, repo string) *auth.AuthResult {
27+
username, password, ok := r.BasicAuth()
28+
if ok {
29+
usernameHash := sha256.Sum256([]byte(username))
30+
passwordHash := sha256.Sum256([]byte(password))
31+
32+
perRoutePasswordHash := sha256.Sum256([]byte(owner + "_" + repo))
33+
34+
usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], a.usernameHash[:]) == 1)
35+
passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], perRoutePasswordHash[:]) == 1)
36+
37+
if usernameMatch && passwordMatch {
38+
return auth.Authorized()
39+
} else {
40+
return auth.Forbidden()
41+
}
42+
}
43+
44+
return auth.Unauthorized(auth.Header("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`))
45+
}

examples/auth/config/plugin.json

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"mode": "plugin",
3+
"path": "examples/auth/_plugins/simple-plugin.so",
4+
"initializer": "NewSimplePluginAuth",
5+
"sha256": "<plugin SHA256 checksum>"
6+
}

0 commit comments

Comments
 (0)