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

authproxy: MDM device identity authenticated HTTP requests #80

Merged
merged 13 commits into from
Aug 28, 2023
51 changes: 39 additions & 12 deletions cmd/nanomdm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/micromdm/nanomdm/cli"
mdmhttp "github.com/micromdm/nanomdm/http"
httpapi "github.com/micromdm/nanomdm/http/api"
"github.com/micromdm/nanomdm/http/authproxy"
httpmdm "github.com/micromdm/nanomdm/http/mdm"
"github.com/micromdm/nanomdm/log/stdlogfmt"
"github.com/micromdm/nanomdm/push/buford"
Expand All @@ -34,13 +35,20 @@ const (
endpointMDM = "/mdm"
endpointCheckin = "/checkin"

endpointAuthProxy = "/authproxy/"

endpointAPIPushCert = "/v1/pushcert"
endpointAPIPush = "/v1/push/"
endpointAPIEnqueue = "/v1/enqueue/"
endpointAPIMigration = "/migration"
endpointAPIVersion = "/version"
)

const (
EnrollmentIDHeader = "X-Enrollment-ID"
TraceIDHeader = "X-Trace-ID"
)

func main() {
cliStorage := cli.NewStorage()
flag.Var(&cliStorage.Storage, "storage", "name of storage backend")
Expand All @@ -62,6 +70,7 @@ func main() {
flMigration = flag.Bool("migration", false, "HTTP endpoint for enrollment migrations")
flRetro = flag.Bool("retro", false, "Allow retroactive certificate-authorization association")
flDMURLPfx = flag.String("dm", "", "URL to send Declarative Management requests to")
flAuthProxy = flag.String("auth-proxy-url", "", "Reverse proxy URL target for MDM-authenticated HTTP requests")
)
flag.Parse()

Expand Down Expand Up @@ -129,6 +138,17 @@ func main() {
mdmService = dump.New(mdmService, os.Stdout)
}

// helper for authorizing MDM clients requests
certAuthMiddleware := func(h http.Handler) http.Handler {
h = httpmdm.CertVerifyMiddleware(h, verifier, logger.With("handler", "cert-verify"))
if *flCertHeader != "" {
h = httpmdm.CertExtractPEMHeaderMiddleware(h, *flCertHeader, logger.With("handler", "cert-extract"))
} else {
h = httpmdm.CertExtractMdmSignatureMiddleware(h, logger.With("handler", "cert-extract"))
}
return h
}

// register 'core' MDM HTTP handler
var mdmHandler http.Handler
if *flCheckin {
Expand All @@ -138,26 +158,33 @@ func main() {
// if we don't use a check-in handler then do both
mdmHandler = httpmdm.CheckinAndCommandHandler(mdmService, logger.With("handler", "checkin-command"))
}
mdmHandler = httpmdm.CertVerifyMiddleware(mdmHandler, verifier, logger.With("handler", "cert-verify"))
if *flCertHeader != "" {
mdmHandler = httpmdm.CertExtractPEMHeaderMiddleware(mdmHandler, *flCertHeader, logger.With("handler", "cert-extract"))
} else {
mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, logger.With("handler", "cert-extract"))
}
mdmHandler = certAuthMiddleware(mdmHandler)
mux.Handle(endpointMDM, mdmHandler)

if *flCheckin {
// if we specified a separate check-in handler, set it up
var checkinHandler http.Handler
checkinHandler = httpmdm.CheckinHandler(mdmService, logger.With("handler", "checkin"))
checkinHandler = httpmdm.CertVerifyMiddleware(checkinHandler, verifier, logger.With("handler", "cert-verify"))
if *flCertHeader != "" {
checkinHandler = httpmdm.CertExtractPEMHeaderMiddleware(checkinHandler, *flCertHeader, logger.With("handler", "cert-extract"))
} else {
checkinHandler = httpmdm.CertExtractMdmSignatureMiddleware(checkinHandler, logger.With("handler", "cert-extract"))
}
checkinHandler = certAuthMiddleware(checkinHandler)
mux.Handle(endpointCheckin, checkinHandler)
}

if *flAuthProxy != "" {
var authProxyHandler http.Handler
authProxyHandler, err = authproxy.New(*flAuthProxy,
authproxy.WithLogger(logger.With("handler", "authproxy")),
authproxy.WithHeaderFunc(EnrollmentIDHeader, httpmdm.GetEnrollmentID),
authproxy.WithHeaderFunc(TraceIDHeader, mdmhttp.GetTraceID),
)
if err != nil {
stdlog.Fatal(err)
}
logger.Debug("msg", "authproxy setup", "url", *flAuthProxy)
authProxyHandler = http.StripPrefix(endpointAuthProxy, authProxyHandler)
authProxyHandler = httpmdm.CertWithEnrollmentIDMiddleware(authProxyHandler, certauth.HashCert, mdmStorage, true, logger.With("handler", "with-enrollment-id"))
authProxyHandler = certAuthMiddleware(authProxyHandler)
mux.Handle(endpointAuthProxy, authProxyHandler)
}
}

if *flAPIKey != "" {
Expand Down
16 changes: 16 additions & 0 deletions docs/operations-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ Print version and exit.

NanoMDM supports a MicroMDM-compatible [webhook callback](https://github.com/micromdm/micromdm/blob/main/docs/user-guide/api-and-webhooks.md) option. This switch turns on the webhook and specifies the URL.

### -auth-proxy-url string

* Reverse proxy URL target for MDM-authenticated HTTP requests

Enables the authentication proxy and reverse proxies HTTP requests from the server's `/authproxy/` endpoint to this URL if the client provides the device's enrollment authentication. See below for more information.

## HTTP endpoints & APIs

### MDM
Expand Down Expand Up @@ -313,6 +319,16 @@ The migration endpoint (as talked about above under the `-migration` switch) is

Returns a JSON response with the version of the running NanoMDM server.

### Authentication Proxy

* Endpoint: `/authproxy/`

If the `-auth-proxy-url` flag is provided then URLs that begin with `/authproxy/` will be reverse-proxied to the given target URL. Importantly this endpoint will authenticate the incoming request in the same way as other MDM endpoints (i.e. Check-In or Command Report and Response) — including whether we're using TLS client configuration or not (the `-cert-header` flag). Put together this allow you to have MDM-authenticated content retrieval.

This feature is ostensibly to support Declarative Device Management and in particular the ability for some "Asset" declarations to use "MDM" authentication for their content. For example the `com.apple.asset.data` declaration supports an [Authentication key](https://github.com/apple/device-management/blob/2bb1726786047949b5b1aa923be33b9ba0f83e37/declarative/declarations/assets/data.yaml#L40-L54) for configuring this ability.

As an example example if this feature is enabled and a request comes to the server as `/authproxy/foo/bar` and the `-auth-proxy-url` was set to, say, `http://[::1]:9008` then NanoMDM will reverse proxy this URL to `http://[::1]:9008/foo/bar`. An HTP 502 Bad Gateway response is sent back to the client for any issues proxying.

# Enrollment Migration (nano2nano)

The `nano2nano` tool extracts migration enrollment data from a given storage backend and sends it to a NanoMDM migration endpoint. In this way you can effectively migrate between database backends. For example if you started with a `file` backend you could migrate to a `mysql` backend and vice versa. Note that MDM servers must have *exactly* the same server URL for migrations to operate.
Expand Down
89 changes: 89 additions & 0 deletions http/authproxy/authproxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Package authproxy is a simple reverse proxy for Apple MDM clients.
package authproxy

import (
"context"
"net/http"
"net/http/httputil"
"net/url"

"github.com/micromdm/nanomdm/log"
"github.com/micromdm/nanomdm/log/ctxlog"
)

// HeaderFunc takes an HTTP request and returns a string value.
// Ostensibly to be set in a header on the proxy target.
type HeaderFunc func(context.Context) string

type config struct {
logger log.Logger
fwdSig bool
headerFuncs map[string]HeaderFunc
}

type Option func(*config)

// WithLogger sets a logger for error reporting.
func WithLogger(logger log.Logger) Option {
return func(c *config) {
c.logger = logger
}
}

// WithHeaderFunc configures fn to be called and added as an HTTP header to the proxy target request.
func WithHeaderFunc(header string, fn HeaderFunc) Option {
return func(c *config) {
c.headerFuncs[header] = fn
}
}

// WithForwardMDMSignature forwards the MDM-Signature header onto the proxy destination.
// This option is off by default because the header adds about two kilobytes to the request.
func WithForwardMDMSignature() Option {
return func(c *config) {
c.fwdSig = true
}
}

// New creates a new NanoMDM enrollment authenticating reverse proxy.
// This reverse proxy is mostly the standard httputil proxy. It depends
// on middleware HTTP handlers to enforce authentication and set the
// context value for the enrollment ID.
func New(dest string, opts ...Option) (*httputil.ReverseProxy, error) {
config := &config{
logger: log.NopLogger,
headerFuncs: make(map[string]HeaderFunc),
}
for _, opt := range opts {
opt(config)
}
target, err := url.Parse(dest)
if err != nil {
return nil, err
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
ctxlog.Logger(r.Context(), config.logger).Info("err", err)
// use the same error as the standrad reverse proxy
w.WriteHeader(http.StatusBadGateway)
}
dir := proxy.Director
proxy.Director = func(req *http.Request) {
dir(req)
req.Host = target.Host
if !config.fwdSig {
// save the effort of forwarding this huge header
req.Header.Del("Mdm-Signature")
}
// set any headers we want to forward.
for k, fn := range config.headerFuncs {
if k == "" || fn == nil {
continue
}
if v := fn(req.Context()); v != "" {
req.Header.Set(k, v)
}
}
}
return proxy, nil
}
6 changes: 6 additions & 0 deletions http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ func VersionHandler(version string) http.HandlerFunc {

type ctxKeyTraceID struct{}

// GetTraceID returns the trace ID from ctx.
func GetTraceID(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyTraceID{}).(string)
return id
}

// TraceLoggingMiddleware sets up a trace ID in the request context and
// logs HTTP requests.
func TraceLoggingMiddleware(next http.Handler, logger log.Logger, traceID func(*http.Request) string) http.HandlerFunc {
Expand Down
70 changes: 70 additions & 0 deletions http/mdm/mdm_cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import (
mdmhttp "github.com/micromdm/nanomdm/http"
"github.com/micromdm/nanomdm/log"
"github.com/micromdm/nanomdm/log/ctxlog"
"github.com/micromdm/nanomdm/storage"
)

type contextKeyCert struct{}

var contextEnrollmentID struct{}

// CertExtractPEMHeaderMiddleware extracts the MDM enrollment identity
// certificate from the request into the HTTP request context. It looks
// at the request header which should be a URL-encoded PEM certificate.
Expand Down Expand Up @@ -128,3 +131,70 @@ func CertVerifyMiddleware(next http.Handler, verifier CertVerifier, logger log.L
next.ServeHTTP(w, r)
}
}

// GetEnrollmentID retrieves the MDM enrollment ID from ctx.
func GetEnrollmentID(ctx context.Context) string {
id, _ := ctx.Value(contextEnrollmentID).(string)
return id
}

type HashFn func(*x509.Certificate) string

// CertWithEnrollmentIDMiddleware tries to associate the enrollment ID to the request context.
// It does this by looking up the certificate on the context, hashing it with
// hasher, looking up the hash in storage, and setting the ID on the context.
//
// The next handler will be called even if cert or ID is not found unless
// enforce is true. This way next is able to use the existence of the ID on
// the context to make its own decisions.
func CertWithEnrollmentIDMiddleware(next http.Handler, hasher HashFn, store storage.CertAuthRetriever, enforce bool, logger log.Logger) http.HandlerFunc {
jessepeterson marked this conversation as resolved.
Show resolved Hide resolved
if store == nil || hasher == nil {
panic("store and hasher must not be nil")
}
return func(w http.ResponseWriter, r *http.Request) {
cert := GetCert(r.Context())
if cert == nil {
if enforce {
ctxlog.Logger(r.Context(), logger).Info(
"err", "missing certificate",
)
// we cannot send a 401 to the client as it has MDM protocol semantics
// i.e. the device may unenroll
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusBadRequest)
return
} else {
ctxlog.Logger(r.Context(), logger).Debug(
"msg", "missing certificate",
)
next.ServeHTTP(w, r)
return
}
}
id, err := store.EnrollmentFromHash(r.Context(), hasher(cert))
if err != nil {
ctxlog.Logger(r.Context(), logger).Info(
"msg", "retreiving enrollment from hash",
"err", err,
)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if id == "" {
if enforce {
ctxlog.Logger(r.Context(), logger).Info(
"err", "missing enrollment id",
)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusBadRequest)
return
} else {
ctxlog.Logger(r.Context(), logger).Debug(
"msg", "missing enrollment id",
)
next.ServeHTTP(w, r)
return
}
}
ctx := context.WithValue(r.Context(), contextEnrollmentID, id)
next.ServeHTTP(w, r.WithContext(ctx))
}
}
77 changes: 77 additions & 0 deletions http/mdm/mdm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package mdm

import (
"bytes"
"context"
"crypto/x509"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/micromdm/nanomdm/log"
)

const (
testHash = "ZZZYYYXXX"
testID = "AAABBBCCC"
)

func testHashCert(_ *x509.Certificate) string {
return testHash
}

type testCertAuthRetriever struct{}

func (c *testCertAuthRetriever) EnrollmentFromHash(ctx context.Context, hash string) (string, error) {
if hash != testHash {
return "", errors.New("invalid test hash")
}
return testID, nil
}

func TestCertWithEnrollmentIDMiddleware(t *testing.T) {
response := []byte("mock response")

// mock handler
var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(response)
})

handler = CertWithEnrollmentIDMiddleware(handler, testHashCert, &testCertAuthRetriever{}, true, log.NopLogger)

req, err := http.NewRequest("GET", "/test", nil)
if err != nil {
t.Fatal(err)
}

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

// we requested enforcement, and did not include a cert, so make sure we get a BadResponse
if have, want := rr.Code, http.StatusBadRequest; have != want {
t.Errorf("have: %d, want: %d", have, want)
}

req, err = http.NewRequest("GET", "/test", nil)
if err != nil {
t.Fatal(err)
}

// mock "cert"
req = req.WithContext(context.WithValue(req.Context(), contextKeyCert{}, &x509.Certificate{}))

rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)

// now that we have a "cert" included, we should get an OK
if have, want := rr.Code, http.StatusOK; have != want {
t.Errorf("have: %d, want: %d", have, want)
}

// verify the actual body, too
if !bytes.Equal(rr.Body.Bytes(), response) {
t.Error("body not equal")
}
}
Loading