mirror of
https://github.com/fleetdm/fleet
synced 2026-05-16 21:48:48 +00:00
There are still some TODOs particularly within Gitops test code which will be worked on in a followup PR # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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) - [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] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [ ] 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 - [x] Alerted the release DRI if additional load testing is needed ## Database migrations - [x] Checked table schema to confirm autoupdate - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). ## New Fleet configuration settings - [ ] Setting(s) is/are explicitly excluded from GitOps If you didn't check the box above, follow this checklist for GitOps-enabled settings: - [ ] Verified that the setting is exported via `fleetctl generate-gitops` - [x] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [x] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [x] Verified that any relevant UI is disabled when GitOps mode is enabled --------- Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com> Co-authored-by: Magnus Jensen <magnus@fleetdm.com> Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com>
222 lines
9.2 KiB
Go
222 lines
9.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/go-kit/log/level"
|
|
)
|
|
|
|
// 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 {
|
|
return nil, err
|
|
}
|
|
ca, err := svc.ds.GetCertificateAuthorityByID(ctx, p.ID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ca.Type != string(fleet.CATypeHydrant) {
|
|
return nil, &fleet.BadRequestError{Message: "This API currently only supports Hydrant Certificate Authorities."}
|
|
}
|
|
if ca.ClientSecret == nil {
|
|
return nil, &fleet.BadRequestError{Message: "Certificate authority does not have a client secret configured."}
|
|
}
|
|
certificateRequest, err := svc.parseCSR(ctx, p.CSR)
|
|
if err != nil {
|
|
level.Error(svc.logger).Log("msg", "Failed to parse CSR during certificate request", "err", err)
|
|
return nil, InvalidCSRError{}
|
|
}
|
|
|
|
idpUsername := ""
|
|
if p.IDPClientID != nil || p.IDPToken != nil || p.IDPOauthURL != nil {
|
|
if p.IDPClientID == nil || p.IDPToken == nil || p.IDPOauthURL == nil {
|
|
return nil, &fleet.BadRequestError{Message: "IDP Client ID, Token, and OAuth URL all must be provided, if any are provided when requesting a certificate."}
|
|
}
|
|
|
|
csrEmail, csrUsername, err := svc.extractCSRUserInfo(ctx, certificateRequest)
|
|
if err != nil {
|
|
level.Error(svc.logger).Log("msg", "CSR did not have expected format for IDP verification", "err", err)
|
|
return nil, InvalidCSRError{}
|
|
}
|
|
|
|
introspectionResponse, err := svc.introspectIDPToken(ctx, *p.IDPClientID, *p.IDPToken, *p.IDPOauthURL)
|
|
if err != nil {
|
|
level.Error(svc.logger).Log("msg", "Failed to introspect IDP token during certificate request", "idp_url", *p.IDPOauthURL, "err", err)
|
|
return nil, InvalidIDPTokenError{}
|
|
}
|
|
if !introspectionResponse.Active {
|
|
level.Error(svc.logger).Log("msg", "Failing Certificate Request due to inactive IDP token", "idp_url", *p.IDPOauthURL)
|
|
return nil, InvalidIDPTokenError{}
|
|
}
|
|
// This field is technically optional in the spec though its omittance may indicate an incompatible IDP or setup
|
|
if introspectionResponse.Username == nil || len(*introspectionResponse.Username) == 0 {
|
|
level.Error(svc.logger).Log("msg", "Failing Certificate Request due to missing username in IDP token introspection response")
|
|
return nil, InvalidIDPTokenError{}
|
|
}
|
|
|
|
idpUsername = *introspectionResponse.Username
|
|
|
|
// the email should either equal the username or include it as a prefix, i.e.
|
|
// email=username@example.com and username=username
|
|
if !strings.HasPrefix(csrEmail, csrUsername) {
|
|
level.Error(svc.logger).Log("msg", "Failing Certificate Request due to mismatch between CSR email and UPN", "csr_email", csrEmail, "csr_upn", csrUsername)
|
|
return nil, InvalidCSRError{}
|
|
}
|
|
if csrEmail != *introspectionResponse.Username {
|
|
level.Error(svc.logger).Log("msg", "Failing Certificate Request due to mismatch between CSR email and IDP token username", "csr_email", csrEmail, "idp_username", *introspectionResponse.Username)
|
|
// The email in the CSR must match the username from the IDP token introspection
|
|
return nil, InvalidIDPTokenError{}
|
|
}
|
|
}
|
|
|
|
csrForRequest := strings.ReplaceAll(p.CSR, "-----BEGIN CERTIFICATE REQUEST-----", "")
|
|
csrForRequest = strings.ReplaceAll(csrForRequest, "-----END CERTIFICATE REQUEST-----", "")
|
|
csrForRequest = strings.ReplaceAll(csrForRequest, "\\n", "")
|
|
|
|
certificate, err := svc.hydrantService.GetCertificate(ctx, fleet.HydrantCA{
|
|
Name: *ca.Name,
|
|
URL: *ca.URL,
|
|
ClientID: *ca.ClientID,
|
|
ClientSecret: *ca.ClientSecret,
|
|
}, csrForRequest)
|
|
if err != nil {
|
|
level.Error(svc.logger).Log("msg", "Hydrant certificate request failed", "ca_id", ca.ID, "error", err)
|
|
// Bad request may seem like a strange error here but there are many cases where a malformed
|
|
// CSR can cause this error and Hydrant's API often returns a 5XX error even in these cases
|
|
// so it is not always possible to distinguish between an error caused by a bad request or
|
|
// an internal CA error.
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Hydrant certificate request failed: %s", err.Error())}
|
|
}
|
|
level.Info(svc.logger).Log("msg", "Successfully retrieved a certificate from Hydrant", "ca_id", ca.ID, "idp_username", idpUsername)
|
|
// Wrap the certificate in a PEM block for easier consumption by the client
|
|
return ptr.String("-----BEGIN CERTIFICATE-----\n" + string(certificate.Certificate) + "\n-----END CERTIFICATE-----\n"), nil
|
|
}
|
|
|
|
func (svc *Service) introspectIDPToken(ctx context.Context, idpClientID, idpToken, idpOauthURL string) (*oauthIntrospectionResponse, error) {
|
|
httpClient := fleethttp.NewClient(fleethttp.WithTimeout(20 * time.Second))
|
|
introspectionRequest := url.Values{
|
|
"client_id": []string{idpClientID},
|
|
"token": []string{idpToken},
|
|
}
|
|
introspectionBody := introspectionRequest.Encode()
|
|
req, err := http.NewRequestWithContext(ctx, "POST", idpOauthURL, strings.NewReader(introspectionBody))
|
|
if err != nil {
|
|
return nil, ctxerr.Wrapf(ctx, err, "Failed to create introspection request")
|
|
}
|
|
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrapf(ctx, err, "Failed to introspect IDP token")
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, ctxerr.Errorf(ctx, "IDP token introspection failed with status code %d", resp.StatusCode)
|
|
}
|
|
|
|
oauthIntrospectionResponse := &oauthIntrospectionResponse{}
|
|
if err := json.NewDecoder(resp.Body).Decode(oauthIntrospectionResponse); err != nil {
|
|
return nil, ctxerr.Wrapf(ctx, err, "Failed to decode IDP token introspection response")
|
|
}
|
|
|
|
return oauthIntrospectionResponse, nil
|
|
}
|
|
|
|
func (svc *Service) parseCSR(ctx context.Context, csr string) (*x509.CertificateRequest, error) {
|
|
// unescape newlines
|
|
block, _ := pem.Decode([]byte(strings.ReplaceAll(csr, "\\n", "\n")))
|
|
if block == nil {
|
|
return nil, ctxerr.New(ctx, "invalid CSR format")
|
|
}
|
|
|
|
req, err := x509.ParseCertificateRequest(block.Bytes)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to parse CSR")
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// Extract email and UPN fields from the provided CSR. Assumes there is exactly 1 email and that there is a UPN SAN extension, will
|
|
// error otherwise
|
|
func (svc *Service) extractCSRUserInfo(ctx context.Context, req *x509.CertificateRequest) (string, string, error) {
|
|
if len(req.EmailAddresses) < 1 {
|
|
return "", "", ctxerr.New(ctx, "CSR does not contain an email address")
|
|
}
|
|
|
|
if len(req.EmailAddresses) > 1 {
|
|
return "", "", ctxerr.Errorf(ctx, "CSR contains %d email addresses, only 1 is supported", len(req.EmailAddresses))
|
|
}
|
|
csrEmail := req.EmailAddresses[0]
|
|
|
|
upn, err := extractCSRUPN(req)
|
|
if err != nil {
|
|
return "", "", ctxerr.Wrap(ctx, err, "failed to extract UPN from CSR")
|
|
}
|
|
if upn == nil {
|
|
return "", "", ctxerr.New(ctx, "CSR does not contain a UPN")
|
|
}
|
|
|
|
return csrEmail, *upn, nil
|
|
}
|
|
|
|
// The go standard library does not provide a way to extract the UPN from a CSR, so we must do it
|
|
// manually by first finding the SAN extension then looking in othernames for the UPN and parsing it.
|
|
func extractCSRUPN(csr *x509.CertificateRequest) (*string, error) {
|
|
sanOID := asn1.ObjectIdentifier{2, 5, 29, 17}
|
|
upnOID := asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}
|
|
for _, ext := range csr.Extensions {
|
|
if ext.Id.Equal(sanOID) {
|
|
nameValues := []asn1.RawValue{}
|
|
if _, err := asn1.Unmarshal(ext.Value, &nameValues); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal SAN extension: %w", err)
|
|
}
|
|
for _, names := range nameValues {
|
|
// We are looking for the othernames(tag 0) in the SAN extension
|
|
if names.Tag == 0 {
|
|
var oid asn1.ObjectIdentifier
|
|
var rawValue asn1.RawValue
|
|
var err error
|
|
remainingBytes := names.Bytes
|
|
// This will be a sequence of OID-value pairs that we must parse
|
|
for len(remainingBytes) > 0 {
|
|
remainingBytes, err = asn1.Unmarshal(names.Bytes, &oid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal othername OID: %w", err)
|
|
}
|
|
// I am not sure what this would indicate. Perhaps a malformed CSR?
|
|
if len(remainingBytes) == 0 {
|
|
return nil, fmt.Errorf("unexpected end of input bytes after unmarshalling othername OID %s but before unmarshaling value", oid.String())
|
|
}
|
|
remainingBytes, err = asn1.Unmarshal(remainingBytes, &rawValue)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal othername value: %w", err)
|
|
}
|
|
if oid.Equal(upnOID) {
|
|
// Unmarshal the raw value into a string
|
|
var upn asn1.RawValue
|
|
if _, err := asn1.Unmarshal(rawValue.Bytes, &upn); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal UPN value: %w", err)
|
|
}
|
|
upnString := string(upn.Bytes)
|
|
return &upnString, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil, nil // No UPN found
|
|
}
|