Files
fleet/server/service/frontend.go
T
Jordan Montgomery 62bc01831f Pass along android fully_managed flag through SSO callback (#41120)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41088

Fixes an unreleased bug where the Android fully managed enroll page
wouldn't be shown if end user auth was enabled. Passes along the flag to
the SSO callback code. There doesn't seem to be any tests that cover
this enroll page so I didn't add/update any however the change is simple
and manually tested to verify the device enrolls and the EUA user link
gets set

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)

## Testing

- [x] QA'd all new/changed functionality manually

For unreleased bug fixes in a release candidate, one of:

- [x] Confirmed that the fix is not expected to adversely impact load
test results
2026-03-06 09:46:56 -05:00

256 lines
7.6 KiB
Go

package service
import (
"context"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
assetfs "github.com/elazarl/go-bindata-assetfs"
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
"github.com/fleetdm/fleet/v4/server/bindata"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
"github.com/klauspost/compress/gzhttp"
)
func newBinaryFileSystem(root string) *assetfs.AssetFS {
return &assetfs.AssetFS{
Asset: bindata.Asset,
AssetDir: bindata.AssetDir,
AssetInfo: bindata.AssetInfo,
Prefix: root,
}
}
func ServeFrontend(urlPrefix string, sandbox bool, logger *slog.Logger) http.Handler {
herr := func(ctx context.Context, w http.ResponseWriter, err string) {
logger.ErrorContext(ctx, err)
http.Error(w, err, http.StatusInternalServerError)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
endpointer.WriteBrowserSecurityHeaders(w)
// The following check is to prevent a misconfigured osquery from submitting
// data to the root endpoint (the osquery remote API uses POST for all its endpoints).
// See https://github.com/fleetdm/fleet/issues/16182.
if r.Method == "POST" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
fs := newBinaryFileSystem("/frontend")
ctx := r.Context()
file, err := fs.Open("templates/react.tmpl")
if err != nil {
herr(ctx, w, "load react template: "+err.Error())
return
}
data, err := io.ReadAll(file)
if err != nil {
herr(ctx, w, "read bindata file: "+err.Error())
return
}
t, err := template.New("react").Parse(string(data))
if err != nil {
herr(ctx, w, "create react template: "+err.Error())
return
}
serverType := "on-premise"
if sandbox {
serverType = "sandbox"
}
if err := t.Execute(w, struct {
URLPrefix string
ServerType string
}{
URLPrefix: urlPrefix,
ServerType: serverType,
}); err != nil {
herr(ctx, w, "execute react template: "+err.Error())
return
}
})
}
// ServeEndUserEnrollOTA implements the entrypoint handler for the /enroll
// path, used to add hosts in "BYOD" mode (currently, iPhone/iPad/Android).
func ServeEndUserEnrollOTA(
svc fleet.Service,
urlPrefix string,
ds fleet.Datastore,
logger *slog.Logger,
) http.Handler {
herr := func(ctx context.Context, w http.ResponseWriter, err string) {
logger.ErrorContext(ctx, err)
http.Error(w, err, http.StatusInternalServerError)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
endpointer.WriteBrowserSecurityHeaders(w)
ctx := r.Context()
setupRequired, err := svc.SetupRequired(ctx)
if err != nil {
herr(ctx, w, "setup required err: "+err.Error())
return
}
if setupRequired {
herr(ctx, w, "fleet instance not setup")
return
}
appCfg, err := ds.AppConfig(r.Context())
if err != nil {
herr(ctx, w, "load appconfig err: "+err.Error())
return
}
errorMsg := r.URL.Query().Get("error")
if errorMsg != "" {
if err := renderEnrollPage(w, appCfg, urlPrefix, "", errorMsg); err != nil {
herr(ctx, w, err.Error())
}
return
}
enrollSecret := r.URL.Query().Get("enroll_secret")
if enrollSecret == "" {
if err := renderEnrollPage(w, appCfg, urlPrefix, "", "This URL is invalid. : Enroll secret is invalid. Please contact your IT admin."); err != nil {
herr(ctx, w, err.Error())
}
return
}
authRequired, err := shared_mdm.RequiresEnrollOTAAuthentication(r.Context(), ds,
enrollSecret, appCfg.MDM.MacOSSetup.EnableEndUserAuthentication)
if err != nil {
herr(ctx, w, "check if authentication is required err: "+err.Error())
return
}
if authRequired {
// check if authentication cookie is present, in which case we go ahead with
// offering the enrollment profile to download.
var cookieIdPRef string
if byodCookie, _ := r.Cookie(shared_mdm.BYODIdpCookieName); byodCookie != nil {
cookieIdPRef = byodCookie.Value
// if the cookie is present, we should also receive a (matching) enroll reference
if cookieIdPRef != "" {
enrollRef := r.URL.Query().Get("enrollment_reference")
if cookieIdPRef != enrollRef {
cookieIdPRef = "" // cookie does not match the enroll reference, so we ignore it and require authentication
}
}
}
if cookieIdPRef == "" {
// IdP authentication has not been completed yet, initiate it by
// redirecting to the configured IdP provider.
if err := initiateOTAEnrollSSO(svc, w, r, enrollSecret); err != nil {
herr(ctx, w, "initiate IdP SSO authentication err: "+err.Error())
return
}
return
}
}
// if we get here, IdP SSO authentication is either not required, or has
// been successfully completed (we have a cookie with the IdP account
// reference).
if err := renderEnrollPage(w, appCfg, urlPrefix, enrollSecret, ""); err != nil {
herr(ctx, w, err.Error())
return
}
})
}
func generateEnrollOTAURL(fleetURL string, enrollSecret string) (string, error) {
path, err := url.JoinPath(fleetURL, "/api/v1/fleet/enrollment_profiles/ota")
if err != nil {
return "", fmt.Errorf("creating path for end user ota enrollment url: %w", err)
}
enrollURL, err := url.Parse(path)
if err != nil {
return "", fmt.Errorf("parsing end user ota enrollment url: %w", err)
}
q := enrollURL.Query()
q.Set("enroll_secret", enrollSecret)
enrollURL.RawQuery = q.Encode()
return enrollURL.String(), nil
}
func renderEnrollPage(w io.Writer, appCfg *fleet.AppConfig, urlPrefix, enrollSecret, errorMessage string) error {
fs := newBinaryFileSystem("/frontend")
file, err := fs.Open("templates/enroll-ota.html")
if err != nil {
return fmt.Errorf("load enroll ota template: %w", err)
}
data, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("read bindata file: %w", err)
}
t, err := template.New("enroll-ota").Parse(string(data))
if err != nil {
return fmt.Errorf("create react template: %w", err)
}
enrollURL, err := generateEnrollOTAURL(urlPrefix, enrollSecret)
if err != nil {
return fmt.Errorf("generate enroll ota url: %w", err)
}
if err := t.Execute(w, struct {
EnrollURL string
URLPrefix string
ErrorMessage string
AndroidMDMEnabled bool
MacMDMEnabled bool
AndroidFeatureEnabled bool
}{
URLPrefix: urlPrefix,
EnrollURL: enrollURL,
ErrorMessage: errorMessage,
AndroidMDMEnabled: appCfg.MDM.AndroidEnabledAndConfigured,
MacMDMEnabled: appCfg.MDM.EnabledAndConfigured,
AndroidFeatureEnabled: true,
}); err != nil {
return fmt.Errorf("execute react template: %w", err)
}
return nil
}
func initiateOTAEnrollSSO(svc fleet.Service, w http.ResponseWriter, r *http.Request, enrollSecret string) error {
requestURL := "/enroll?enroll_secret=" + url.QueryEscape(enrollSecret)
// pass the fully_managed parameter for Android enrollments so that it is returned after the callback, else the
// user won't get the android fully managed page
if r.URL.Query().Get("fully_managed") == "true" {
requestURL += "&fully_managed=true"
}
ssnID, ssnDurationSecs, idpURL, err := svc.InitiateMDMSSO(r.Context(), "ota_enroll", requestURL, "")
if err != nil {
return err
}
setSSOCookie(w, ssnID, ssnDurationSecs)
http.Redirect(w, r, idpURL, http.StatusSeeOther)
return nil
}
func ServeStaticAssets(path string) http.Handler {
contentTypes := []string{"text/javascript", "text/css"}
withoutGzip := http.StripPrefix(path, http.FileServer(newBinaryFileSystem("/assets")))
withOpts, err := gzhttp.NewWrapper(gzhttp.ContentTypes(contentTypes))
if err != nil { // fall back to serving without gzip if serving with gzip somehow fails
return withoutGzip
}
return withOpts(withoutGzip)
}