Tighten conditional-access SCEP challenge validation (#48041)

**Related issue:** N/A

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

## Summary

Tightens validation in the conditional-access SCEP challenge middleware
to reject enrollment requests that use a secret outside the expected
scope. Adds unit test coverage for the middleware.

## Reproduction

The `challengeMiddleware` in `ee/server/service/condaccess/scep.go`
calls `ds.VerifyEnrollSecret()` but discards the returned secret (`_,
err :=`), so it only checks that *some* valid enroll secret exists. It
does not verify the secret's scope. Any valid secret from any scope
passes the challenge.

This was confirmed with a unit test using mock secrets scoped to
different teams. Before the fix, the middleware accepted all of them
indiscriminately. The server-side profile generation
(`server/service/conditional_access_idp.go`) only ever embeds a
global-scope secret as the SCEP challenge, so only global secrets should
be accepted.

## How it was tested

1. **Unit tests (`scep_test.go`)** - table-driven test with 5 cases
exercising the `challengeMiddleware` directly:
   - Empty challenge -> rejected ("missing challenge")
   - Unknown secret -> rejected ("invalid challenge")
- Team-scoped secret (team A) -> rejected ("invalid challenge") *[new
behavior]*
- Team-scoped secret (team B) -> rejected ("invalid challenge") *[new
behavior]*
- Global secret (team_id = nil) -> accepted, signer invoked, cert
returned

2. **Ran `go test -v ./ee/server/service/condaccess/`** - all tests
pass.

3. **Ran `make lint-go-incremental`** - 0 issues.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved validation for conditional access SCEP enrollment, including
rejecting team-scoped enrollment secrets.
* Updated challenge middleware behavior to better handle enrollment
secret verification outcomes and associated error messaging.

* **Tests**
* Added comprehensive coverage for conditional access SCEP challenge
validation, including cases for missing, unknown, team-scoped, and
global enrollment secrets, plus signer invocation expectations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Sharon Katz
2026-06-24 16:53:37 -04:00
committed by GitHub
parent 7eca15c8f6
commit d00963c330
3 changed files with 101 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
- Improved input validation for conditional access SCEP enrollment.
+6 -1
View File
@@ -96,13 +96,18 @@ func challengeMiddleware(ds fleet.Datastore, next scepserver.CSRSignerContext) s
if m.ChallengePassword == "" {
return nil, errors.New("missing challenge")
}
_, err := ds.VerifyEnrollSecret(ctx, m.ChallengePassword)
secret, err := ds.VerifyEnrollSecret(ctx, m.ChallengePassword)
switch {
case fleet.IsNotFound(err):
return nil, errors.New("invalid challenge")
case err != nil:
return nil, fmt.Errorf("verifying enrollment secret: %w", err)
}
// Only global enroll secrets (team_id IS NULL) are valid for
// conditional-access SCEP. Reject team-scoped secrets.
if secret.TeamID != nil {
return nil, errors.New("invalid challenge")
}
return next.SignCSRContext(ctx, m)
}
}
+94
View File
@@ -0,0 +1,94 @@
package condaccess
import (
"context"
"crypto/x509"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
"github.com/fleetdm/fleet/v4/server/mock"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/smallstep/scep"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChallengeMiddleware(t *testing.T) {
teamAID := uint(1)
teamBID := uint(2)
cases := []struct {
name string
challenge string
wantErr string
wantSignCalled bool
}{
{
name: "empty challenge is rejected",
challenge: "",
wantErr: "missing challenge",
},
{
name: "unknown secret is rejected",
challenge: "unknown-secret",
wantErr: "invalid challenge",
},
{
name: "team-scoped secret is rejected",
challenge: "secret-team-a",
wantErr: "invalid challenge",
},
{
name: "different team-scoped secret is also rejected",
challenge: "secret-team-b",
wantErr: "invalid challenge",
},
{
name: "global secret is accepted",
challenge: "global-secret",
wantSignCalled: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ds := new(mock.DataStore)
ds.VerifyEnrollSecretFunc = func(_ context.Context, secret string) (*fleet.EnrollSecret, error) {
switch secret {
case "secret-team-a":
return &fleet.EnrollSecret{Secret: secret, TeamID: &teamAID}, nil
case "secret-team-b":
return &fleet.EnrollSecret{Secret: secret, TeamID: &teamBID}, nil
case "global-secret":
return &fleet.EnrollSecret{Secret: secret, TeamID: nil}, nil
default:
return nil, common_mysql.NotFound("enroll_secret")
}
}
signCalled := false
dummySigner := scepserver.CSRSignerContextFunc(
func(_ context.Context, _ *scep.CSRReqMessage) (*x509.Certificate, error) {
signCalled = true
return &x509.Certificate{}, nil
},
)
mw := challengeMiddleware(ds, dummySigner)
cert, err := mw.SignCSRContext(t.Context(), &scep.CSRReqMessage{
ChallengePassword: tc.challenge,
})
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
assert.Nil(t, cert)
} else {
require.NoError(t, err)
assert.NotNil(t, cert)
}
assert.Equal(t, tc.wantSignCalled, signCalled, "unexpected signer invocation")
})
}
}