Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic support & documentation for built-in and plugin-based auth #51

Merged
merged 7 commits into from
May 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
6 changes: 5 additions & 1 deletion cmd/git-bundle-server/web-server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions cmd/git-bundle-web-server/bundle-server.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,30 @@ 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,
port string,
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -172,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)
}

Expand Down
128 changes: 128 additions & 0 deletions cmd/git-bundle-web-server/main.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,122 @@
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"
"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"
)

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)
if err != nil {
return nil, err
}

err = json.Unmarshal(fileBytes, &config)
if err != nil {
return nil, err
}

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)
}
}

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"`
}

func main() {
log.WithTraceLogger(context.Background(), func(ctx context.Context, logger log.TraceLogger) {
parser := argparse.NewArgParser(logger, "git-bundle-web-server [--port <port>] [--cert <filename> --key <filename>]")
Expand All @@ -28,13 +134,35 @@ 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,
port,
cert, key,
tlsMinVersion,
clientCA,
middlewareAuthorize,
)
if err != nil {
logger.Fatal(ctx, err)
Expand Down
1 change: 1 addition & 0 deletions cmd/utils/common-args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
87 changes: 87 additions & 0 deletions docs/man/git-bundle-web-server.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,93 @@ 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.
+
Available options:

- _fixed_

*parameters* (object)::
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):

[source,json]
----
{
"mode": "fixed",
"parameters": {
"username": "admin",
"passwordHash": "c3c3520adf2f6e25672ba55dc70bcb3dd8b4ef3341bce1a5f38c5eca6571f372"
}
}
----

***

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]
4 changes: 4 additions & 0 deletions docs/man/server-options.asc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading