Cloudfront signing for in-house apps (#37650)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #33756

# 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.

## Testing

- [x] Added/updated automated tests
- [ ] 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)

- [x] QA'd all new/changed functionality manually
This commit is contained in:
Jonathan Katz
2026-01-05 16:30:31 -05:00
committed by GitHub
parent cc50ede021
commit fc4106c688
19 changed files with 159 additions and 35 deletions
+1 -1
View File
@@ -30,6 +30,6 @@ func (c commonFailingStore) Cleanup(ctx context.Context, usedIconIDs []string, r
return 0, nil
}
func (c commonFailingStore) Sign(_ context.Context, _ string) (string, error) {
func (c commonFailingStore) Sign(_ context.Context, _ string, _ time.Duration) (string, error) {
return "", fmt.Errorf("%s store not properly configured", c.Entity)
}
@@ -133,7 +133,7 @@ func (i *SoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs [
return count, ctxerr.Wrap(ctx, errors.Join(errs...), "delete unused software installers")
}
func (i *SoftwareInstallerStore) Sign(ctx context.Context, _ string) (string, error) {
func (i *SoftwareInstallerStore) Sign(ctx context.Context, _ string, _ time.Duration) (string, error) {
return "", ctxerr.New(ctx, "signing not supported for software installers in filesystem store")
}
@@ -130,7 +130,7 @@ func (s *SoftwareTitleIconStore) Cleanup(ctx context.Context, usedIconIDs []stri
return count, ctxerr.Wrap(ctx, errors.Join(errs...), "delete unused software title icons")
}
func (s *SoftwareTitleIconStore) Sign(ctx context.Context, _ string) (string, error) {
func (s *SoftwareTitleIconStore) Sign(ctx context.Context, _ string, _ time.Duration) (string, error) {
return "", ctxerr.New(ctx, "signing not supported for software title icons in filesystem store")
}
@@ -91,7 +91,7 @@ func TestSoftwareTitleIconStore(t *testing.T) {
require.Equal(t, 1, n)
assertIconsOnDisk(t, dir, []string{id0})
_, err = store.Sign(ctx, id0)
_, err = store.Sign(ctx, id0, fleet.SoftwareTitleIconSignedURLExpiry)
require.Error(t, err)
require.Contains(t, err.Error(), "signing not supported for software title icons in filesystem store")
}
+2 -4
View File
@@ -20,8 +20,6 @@ import (
"golang.org/x/sync/errgroup"
)
const signedURLExpiresIn = 6 * time.Hour
// commonFileStore implements the common Get, Put, Exists, Sign and Cleanup
// operations typical for storage of files in the SoftwareInstallers S3 bucket
// configuration. It is used by the SoftwareInstallerStore and the
@@ -193,7 +191,7 @@ func (s *commonFileStore) Cleanup(ctx context.Context, usedFileIDs []string, rem
return int(deleted.Load()), ctxerr.Wrapf(ctx, err, "deleting %s in S3 store", s.fileLabel)
}
func (s *commonFileStore) Sign(ctx context.Context, fileID string) (string, error) {
func (s *commonFileStore) Sign(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
if s.cloudFrontConfig == nil {
return "", ctxerr.Wrapf(ctx, fleet.ErrNotConfigured, "signing %s URL in S3 store", s.fileLabel)
}
@@ -202,7 +200,7 @@ func (s *commonFileStore) Sign(ctx context.Context, fileID string) (string, erro
return "", ctxerr.Wrapf(ctx, err, "building URL for %s with ID %s in S3 store", s.fileLabel, fileID)
}
signer := sign.NewURLSigner(s.cloudFrontConfig.SigningPublicKeyID, s.cloudFrontConfig.Signer)
signedURL, err := signer.Sign(urlToAccess, time.Now().Add(signedURLExpiresIn))
signedURL, err := signer.Sign(urlToAccess, time.Now().Add(expiresIn))
if err != nil {
return "", ctxerr.Wrapf(ctx, err, "signing %s URL %s in S3 store", s.fileLabel, urlToAccess)
}
+3 -1
View File
@@ -987,6 +987,8 @@ type MDMAppleDDMActivation struct {
Type string `json:"Type"` // "com.apple.activation.simple"
}
const BootstrapPackageSignedURLExpiry = 6 * time.Hour
// MDMBootstrapPackageStore is the interface to store and retrieve bootstrap
// package files. Fleet supports storing to the database and to an S3 bucket.
type MDMBootstrapPackageStore interface {
@@ -994,7 +996,7 @@ type MDMBootstrapPackageStore interface {
Put(ctx context.Context, packageID string, content io.ReadSeeker) error
Exists(ctx context.Context, packageID string) (bool, error)
Cleanup(ctx context.Context, usedPackageIDs []string, removeCreatedBefore time.Time) (int, error)
Sign(ctx context.Context, fileID string) (string, error)
Sign(ctx context.Context, fileID string, expiresIn time.Duration) (string, error)
}
// MDMAppleMachineInfo is a [device's information][1] sent as part of an MDM enrollment profile request
+6
View File
@@ -1,5 +1,11 @@
package fleet
import (
"time"
)
const InHouseAppSignedURLExpiry = 5 * time.Minute
type InHouseAppPayload struct {
TeamID *uint
Title string // app name
+2 -1
View File
@@ -18,6 +18,7 @@ import (
// MaxSoftwareInstallerSize is the maximum size allowed for software
// installers. This is enforced by the endpoints that upload installers.
const MaxSoftwareInstallerSize = 3000 * units.MiB
const SoftwareInstallerSignedURLExpiry = 6 * time.Hour
// SoftwareInstallerStore is the interface to store and retrieve software
// installer files. Fleet supports storing to the local filesystem and to an
@@ -27,7 +28,7 @@ type SoftwareInstallerStore interface {
Put(ctx context.Context, installerID string, content io.ReadSeeker) error
Exists(ctx context.Context, installerID string) (bool, error)
Cleanup(ctx context.Context, usedInstallerIDs []string, removeCreatedBefore time.Time) (int, error)
Sign(ctx context.Context, fileID string) (string, error)
Sign(ctx context.Context, fileID string, expiresIn time.Duration) (string, error)
}
// SoftwareInstallDetails contains all of the information
+3 -1
View File
@@ -10,6 +10,8 @@ import (
var SoftwareTitleIconURLRegex = regexp.MustCompile(`fleet/software/titles/\d+/icon\?team_id=\d+`)
const SoftwareTitleIconSignedURLExpiry = 6 * time.Hour
type UploadSoftwareTitleIconPayload struct {
TitleID uint
TeamID uint
@@ -42,7 +44,7 @@ type SoftwareTitleIconStore interface {
Get(ctx context.Context, iconID string) (io.ReadCloser, int64, error)
Exists(ctx context.Context, iconID string) (bool, error)
Cleanup(ctx context.Context, usedIconIDs []string, removeCreatedBefore time.Time) (int, error)
Sign(ctx context.Context, iconID string) (string, error)
Sign(ctx context.Context, iconID string, expiresIn time.Duration) (string, error)
}
type DetailsForSoftwareIconActivity struct {
+3 -3
View File
@@ -21,7 +21,7 @@ type ExistsFunc func(ctx context.Context, packageID string) (bool, error)
type CleanupFunc func(ctx context.Context, usedPackageIDs []string, removeCreatedBefore time.Time) (int, error)
type SignFunc func(ctx context.Context, fileID string) (string, error)
type SignFunc func(ctx context.Context, fileID string, expiresIn time.Duration) (string, error)
type MDMBootstrapPackageStore struct {
GetFunc GetFunc
@@ -70,9 +70,9 @@ func (s *MDMBootstrapPackageStore) Cleanup(ctx context.Context, usedPackageIDs [
return s.CleanupFunc(ctx, usedPackageIDs, removeCreatedBefore)
}
func (s *MDMBootstrapPackageStore) Sign(ctx context.Context, fileID string) (string, error) {
func (s *MDMBootstrapPackageStore) Sign(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
s.mu.Lock()
s.SignFuncInvoked = true
s.mu.Unlock()
return s.SignFunc(ctx, fileID)
return s.SignFunc(ctx, fileID, expiresIn)
}
@@ -21,7 +21,7 @@ type ExistsFunc func(ctx context.Context, installerID string) (bool, error)
type CleanupFunc func(ctx context.Context, usedInstallerIDs []string, removeCreatedBefore time.Time) (int, error)
type SignFunc func(ctx context.Context, fileID string) (string, error)
type SignFunc func(ctx context.Context, fileID string, expiresIn time.Duration) (string, error)
type SoftwareInstallerStore struct {
GetFunc GetFunc
@@ -70,9 +70,9 @@ func (s *SoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs [
return s.CleanupFunc(ctx, usedInstallerIDs, removeCreatedBefore)
}
func (s *SoftwareInstallerStore) Sign(ctx context.Context, fileID string) (string, error) {
func (s *SoftwareInstallerStore) Sign(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
s.mu.Lock()
s.SignFuncInvoked = true
s.mu.Unlock()
return s.SignFunc(ctx, fileID)
return s.SignFunc(ctx, fileID, expiresIn)
}
+51 -4
View File
@@ -12,12 +12,14 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/s3"
"github.com/fleetdm/fleet/v4/server/fleet"
software_mock "github.com/fleetdm/fleet/v4/server/mock/software"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log"
kitlog "github.com/go-kit/log"
"github.com/jmoiron/sqlx"
@@ -105,7 +107,7 @@ func (s *integrationInstallTestSuite) TestSoftwareInstallerSignedURL() {
myInstallerID = installerID
return nil
}
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
return "https://example.com/signed", nil
}
@@ -166,7 +168,7 @@ func (s *integrationInstallTestSuite) TestSoftwareInstallerSignedURL() {
require.Equal(t, filename, orbitSoftwareResp.SoftwareInstallerURL.Filename)
// Error in signing -- we simply don't return the URL
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
return "", errors.New("error signing")
}
orbitSoftwareResp = orbitGetSoftwareInstallResponse{}
@@ -187,8 +189,8 @@ func (s *integrationInstallTestSuite) TestSoftwareInstallerSignedURL() {
}
s3Store, err := s3.NewTestSoftwareInstallerStore(s3Config)
require.NoError(t, err)
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
return s3Store.Sign(ctx, fileID)
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
return s3Store.Sign(ctx, fileID, fleet.SoftwareInstallerSignedURLExpiry)
}
s.DoJSON("POST", "/api/fleet/orbit/software_install/details", orbitGetSoftwareInstallRequest{
InstallUUID: installUUID,
@@ -215,3 +217,48 @@ func getLatestSoftwareInstallExecID(t *testing.T, ds *mysql.Datastore, hostID ui
})
return installUUID
}
func (s *integrationInstallTestSuite) TestGetInHouseAppManifestSignedURL() {
// Test that the signed URL is used if cloudfrontsigner is configured
t := s.T()
teamID := ptr.Uint(0)
signURL := `https://example.cloudfront.net/software-installers/storage_id?Expires=1766462733&Signature=some_signature&Key-Pair-Id=ABC123XYZ`
// Set up mocks
var myInstallerID string
s.softwareInstallStore.ExistsFunc = func(ctx context.Context, installerID string) (bool, error) {
return installerID == myInstallerID, nil
}
s.softwareInstallStore.PutFunc = func(ctx context.Context, installerID string, content io.ReadSeeker) error {
myInstallerID = installerID
return nil
}
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
return signURL, nil
}
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{Filename: "ipa_test.ipa"}, http.StatusOK, "")
var titleResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{
SoftwareTitleListOptions: fleet.SoftwareTitleListOptions{Platform: "ios"},
}, http.StatusOK, &titleResp, "team_id", "0")
require.Len(t, titleResp.SoftwareTitles, 1)
require.Equal(t, "ipa_test", titleResp.SoftwareTitles[0].Name)
titleID := titleResp.SoftwareTitles[0].ID
readManifest := func(res *http.Response) []byte {
buf, err := io.ReadAll(res.Body)
require.NoError(t, err)
res.Body.Close()
return buf
}
res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/in_house_app/manifest?team_id=%d", titleID, *teamID),
jsonMustMarshal(t, getInHouseAppManifestRequest{TitleID: titleID, TeamID: teamID}), http.StatusOK)
manifest := readManifest(res)
require.NotNil(t, manifest)
escapedURL := `https://example.cloudfront.net/software-installers/storage_id?Expires=1766462733&amp;Signature=some_signature&amp;Key-Pair-Id=ABC123XYZ`
require.Contains(t, string(manifest), escapedURL)
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -1719,6 +1720,36 @@ func (s *integrationMDMTestSuite) TestInHouseAppSelfInstall() {
s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, titleID), nil, http.StatusAccepted, headers)
}
func (s *integrationMDMTestSuite) TestGetInHouseAppManifestUnsignedURL() {
// Test that the Fleet URL is used if cloudfrontsigner is nil
t := s.T()
s.setSkipWorkerJobs(t)
teamID := ptr.Uint(0)
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{Filename: "ipa_test.ipa"}, http.StatusOK, "")
var titleResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{
SoftwareTitleListOptions: fleet.SoftwareTitleListOptions{Platform: "ios"},
}, http.StatusOK, &titleResp, "team_id", "0")
require.Len(t, titleResp.SoftwareTitles, 1)
require.Equal(t, "ipa_test", titleResp.SoftwareTitles[0].Name)
titleID := titleResp.SoftwareTitles[0].ID
readManifest := func(res *http.Response) []byte {
buf, err := io.ReadAll(res.Body)
require.NoError(t, err)
res.Body.Close()
return buf
}
res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/in_house_app/manifest?team_id=%d", titleID, *teamID),
jsonMustMarshal(t, getInHouseAppManifestRequest{TitleID: titleID, TeamID: teamID}), http.StatusOK)
manifest := readManifest(res)
require.NotNil(t, manifest)
require.Contains(t, string(manifest), fmt.Sprintf("/%d/in_house_app?team_id=%d", titleID, *teamID))
}
func (s *integrationMDMTestSuite) addHostIdentityCertificate(hostUUID string, certSerial uint64) {
t := s.T()
s.setSkipWorkerJobs(t)
+1 -1
View File
@@ -627,7 +627,7 @@ func (a *AppleMDM) getSignedURL(ctx context.Context, meta *fleet.MDMAppleBootstr
var url string
if a.BootstrapPackageStore != nil {
pkgID := hex.EncodeToString(meta.Sha256)
signedURL, err := a.BootstrapPackageStore.Sign(ctx, pkgID)
signedURL, err := a.BootstrapPackageStore.Sign(ctx, pkgID, fleet.BootstrapPackageSignedURLExpiry)
switch {
case errors.Is(err, fleet.ErrNotConfigured):
// no CDN configured, fall back to the MDM URL
+3 -3
View File
@@ -1267,14 +1267,14 @@ func TestGetSignedURL(t *testing.T) {
// Signer not configured
mockStore := &mock.MDMBootstrapPackageStore{}
a.BootstrapPackageStore = mockStore
mockStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
mockStore.SignFunc = func(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
return "bozo", fleet.ErrNotConfigured
}
assert.Empty(t, a.getSignedURL(ctx, meta))
assert.Empty(t, buf.String())
// Test happy path
mockStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
mockStore.SignFunc = func(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
return "signed", nil
}
mockStore.ExistsFunc = func(ctx context.Context, packageID string) (bool, error) {
@@ -1289,7 +1289,7 @@ func TestGetSignedURL(t *testing.T) {
mockStore.ExistsFuncInvoked = false
// Test error -- sign failed
mockStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
mockStore.SignFunc = func(ctx context.Context, fileID string, expiresIn time.Duration) (string, error) {
return "", errors.New("test error")
}
assert.Empty(t, a.getSignedURL(ctx, meta))