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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const InHouseAppSignedURLExpiry = 5 * time.Minute
|
||||
|
||||
type InHouseAppPayload struct {
|
||||
TeamID *uint
|
||||
Title string // app name
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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&Signature=some_signature&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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user