HTTP Message Signature Auth for certificate_request (#35139)

**Related issue:** Resolves #34278
This commit is contained in:
Dante Catalfamo
2025-11-06 12:06:00 -05:00
committed by GitHub
parent 26ebb310d5
commit 66dd8081be
6 changed files with 236 additions and 3 deletions
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
@@ -17,6 +18,21 @@ type key int
const hostIdentityKey key = 0
var sigAuthenticatedEndpoints = []*regexp.Regexp{
regexp.MustCompile(`^/api/(?:v1|latest)/fleet/certificate_authorities/\d+/request_certificate$`),
regexp.MustCompile(`^/api/fleet/orbit/`),
regexp.MustCompile(`/osquery/`),
}
func IsSigAuthEndpoint(path string) bool {
for _, endp := range sigAuthenticatedEndpoints {
if endp.Match([]byte(path)) {
return true
}
}
return false
}
// NewContext creates a new context.Context with host identity cert.
func NewContext(ctx context.Context, hostIdentity types.HostIdentityCertificate) context.Context {
return context.WithValue(ctx, hostIdentityKey, hostIdentity)
@@ -43,7 +59,7 @@ func Middleware(ds fleet.Datastore, requireSignature bool, logger kitlog.Logger)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if !strings.Contains(req.URL.Path, "/api/fleet/orbit/") && !strings.Contains(req.URL.Path, "/osquery/") {
if !IsSigAuthEndpoint(req.URL.Path) {
next.ServeHTTP(w, req)
return
}
+11 -1
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
@@ -21,9 +22,18 @@ import (
// This code largely adapted from fleet/website/api/controllers/get-est-device-certificate.js
func (svc *Service) RequestCertificate(ctx context.Context, p fleet.RequestCertificatePayload) (*string, error) {
if err := svc.authz.Authorize(ctx, &fleet.RequestCertificatePayload{}, fleet.ActionWrite); err != nil {
auth, authOk := authz.FromContext(ctx)
if !authOk {
// This shouldn't be possible
return nil, &fleet.BadRequestError{Message: "Missing authentication authorization context"}
}
if auth.AuthnMethod() == authz.AuthnHTTPMessageSignature {
// Message Signature auth is not granular, device already checked and authorized in middleware
svc.authz.SkipAuthorization(ctx)
} else if err := svc.authz.Authorize(ctx, &fleet.RequestCertificatePayload{}, fleet.ActionWrite); err != nil {
return nil, err
}
ca, err := svc.ds.GetCertificateAuthorityByID(ctx, p.ID, true)
if err != nil {
return nil, err
+32 -1
View File
@@ -10,7 +10,10 @@ import (
"time"
"github.com/fleetdm/fleet/v4/ee/server/service/est"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
"github.com/fleetdm/fleet/v4/server/authz"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -127,6 +130,8 @@ func TestRequestCertificate(t *testing.T) {
Password: ptr.String("test-password"),
}
useDefaultAuthContext := true
baseSetupForTests := func() (*Service, context.Context) {
ds := new(mock.Store)
@@ -155,7 +160,13 @@ func TestRequestCertificate(t *testing.T) {
est.WithLogger(logger),
),
}
ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
authCtx := &authz_ctx.AuthorizationContext{}
ctx := authz_ctx.NewContext(context.Background(), authCtx)
if useDefaultAuthContext {
authCtx.SetAuthnMethod(authz_ctx.AuthnUserToken)
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
}
oauthIntrospectResponse = defaultOauthIntrospectResponse
oauthIntrospectStatus = http.StatusOK
@@ -195,6 +206,26 @@ func TestRequestCertificate(t *testing.T) {
require.Equal(t, "-----BEGIN CERTIFICATE-----\n"+hydrantSimpleEnrollResponse+"\n-----END CERTIFICATE-----\n", *cert)
})
t.Run("Request a certificate - Happy path, no IDP, http sig auth", func(t *testing.T) {
useDefaultAuthContext = false
defer func() { useDefaultAuthContext = true }()
svc, ctx := baseSetupForTests()
authCtx, ok := authz_ctx.FromContext(ctx)
require.True(t, ok)
authCtx.SetAuthnMethod(authz_ctx.AuthnHTTPMessageSignature)
ctx = httpsig.NewContext(ctx, types.HostIdentityCertificate{HostID: ptr.Uint(1), NotValidAfter: time.Now().Add(24 * time.Hour)})
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
ID: hydrantCA.ID,
CSR: goodCSR,
})
require.NoError(t, err)
require.NotNil(t, cert)
require.Equal(t, "-----BEGIN CERTIFICATE-----\n"+hydrantSimpleEnrollResponse+"\n-----END CERTIFICATE-----\n", *cert)
})
t.Run("Request a certificate - Happy path, no IDP, UPN does not match IDP info(should pass)", func(t *testing.T) {
svc, ctx := baseSetupForTests()
+4
View File
@@ -43,6 +43,10 @@ const (
// authentication token. This authentication mode does not support granular
// authorization.
AuthnOrbitToken
// AuthnHTTPMessageSignature is when authentication is done via HTTP Message Signature,
// backed by the device's SCEP Identity certificate. This authentication method does not support
// granular authorization.
AuthnHTTPMessageSignature
)
// AuthorizationContext contains the context information used for the
+20
View File
@@ -3,7 +3,11 @@ package auth
import (
"context"
"net/http"
"time"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
"github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/token"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
@@ -40,6 +44,22 @@ func AuthenticatedUser(svc fleet.Service, next endpoint.Endpoint) endpoint.Endpo
return next(ctx, request)
}
requestPath, _ := ctx.Value(kithttp.ContextKeyRequestPath).(string)
httpSig, sigOk := httpsig.FromContext(ctx)
if sigOk && httpsig.IsSigAuthEndpoint(requestPath) {
if time.Now().After(httpSig.NotValidAfter) {
return nil, fleet.NewAuthFailedError("host identity certificate expired")
}
if httpSig.HostID == nil {
return nil, fleet.NewAuthFailedError("identity certificate is not linked to a specific host")
}
if ac, ok := authz.FromContext(ctx); ok {
ac.SetAuthnMethod(authz.AuthnHTTPMessageSignature)
}
return next(ctx, request)
}
// if not successful, try again this time with errors
sessionKey, ok := token.FromContext(ctx)
if !ok {
+152
View File
@@ -0,0 +1,152 @@
package auth
import (
"context"
"errors"
"testing"
"time"
"github.com/aws/smithy-go/ptr"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
"github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/fleet"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/stretchr/testify/assert"
)
func TestHTTPMessageSignAuth(t *testing.T) {
var nextCalled bool
var nextCtx context.Context
next := func(ctx context.Context, request any) (response any, err error) {
nextCalled = true
nextCtx = ctx
return nil, nil
}
// Pass in a nil service interface. We shouldn't hit the place where it gets called while
// http only signatures
endpoint := AuthenticatedUser(nil, next)
tcs := []struct {
Name string
Path string
Err string
Called bool
HostIdentCert *types.HostIdentityCertificate
}{
{
Name: "no http auth path",
Path: "/some/path",
Err: "no auth token",
},
{
Name: "no http auth path with cert",
Path: "/some/path",
Err: "no auth token",
HostIdentCert: &types.HostIdentityCertificate{},
},
{
Name: "no http auth path with good cert context",
Path: "/some/path",
Err: "no auth token",
HostIdentCert: &types.HostIdentityCertificate{
NotValidAfter: time.Now().Add(24 * time.Hour),
HostID: ptr.Uint(1),
},
},
{
Name: "auth path with no cert",
Path: "/osquery/",
Err: "no auth token",
},
{
Name: "auth path with good cert context",
Path: "/osquery/",
Err: "",
HostIdentCert: &types.HostIdentityCertificate{
NotValidAfter: time.Now().Add(24 * time.Hour),
HostID: ptr.Uint(1),
},
Called: true,
},
{
Name: "auth path with good cert context 2",
Path: "/api/fleet/orbit/foo",
Err: "",
HostIdentCert: &types.HostIdentityCertificate{
NotValidAfter: time.Now().Add(24 * time.Hour),
HostID: ptr.Uint(1),
},
Called: true,
},
{
Name: "auth path with good cert context 3",
Path: "/api/v1/fleet/certificate_authorities/3/request_certificate",
Err: "",
HostIdentCert: &types.HostIdentityCertificate{
NotValidAfter: time.Now().Add(24 * time.Hour),
HostID: ptr.Uint(1),
},
Called: true,
},
{
Name: "auth path with zeroed cert context",
Path: "/osquery/",
Err: "host identity certificate expired",
HostIdentCert: &types.HostIdentityCertificate{},
},
{
Name: "auth path with expired cert context",
Path: "/osquery/",
Err: "host identity certificate expired",
HostIdentCert: &types.HostIdentityCertificate{
NotValidAfter: time.Now().Add(-24 * time.Hour),
HostID: ptr.Uint(1),
},
},
{
Name: "auth path with missing host id",
Path: "/osquery/",
Err: "identity certificate is not linked to a specific host",
HostIdentCert: &types.HostIdentityCertificate{
NotValidAfter: time.Now().Add(24 * time.Hour),
},
},
}
for _, tc := range tcs {
t.Run(tc.Name, func(t *testing.T) {
nextCalled = false
nextCtx = nil
ctx := context.Background()
ctx = authz.NewContext(ctx, &authz.AuthorizationContext{})
ctx = context.WithValue(ctx, kithttp.ContextKeyRequestPath, tc.Path)
if tc.HostIdentCert != nil {
ctx = httpsig.NewContext(ctx, *tc.HostIdentCert)
}
_, err := endpoint(ctx, nil)
if tc.Err == "" {
assert.NoError(t, err)
} else {
authErr := &fleet.AuthFailedError{}
authHeaderErr := &fleet.AuthHeaderRequiredError{}
switch {
case errors.As(err, &authErr):
assert.Contains(t, authErr.Internal(), tc.Err)
case errors.As(err, &authHeaderErr):
assert.Contains(t, authHeaderErr.Internal(), tc.Err)
default:
assert.ErrorContains(t, err, tc.Err)
}
}
assert.Equal(t, tc.Called, nextCalled)
if tc.Called {
assert.NotNil(t, ctx)
auth, ok := authz.FromContext(nextCtx)
assert.True(t, ok)
assert.Equal(t, authz.AuthnHTTPMessageSignature, auth.AuthnMethod())
}
})
}
}