fleet/server/mdm/maintainedapps/installers.go
Konstantin Sykulev b643b326ee
Generate SHA from file if FMA sha is no_check (#30558)
fixes: #30325

Related to incorrect behavior introduced at
https://github.com/fleetdm/fleet/pull/28945

- [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] Added/updated automated tests
- [x] Manual QA for all new/changed functionality


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

* **New Features**
* When uploading software batches, if the installer SHA is set to
"no_check," the system will now automatically generate and use the
SHA256 checksum of the installer file.
* **Bug Fixes**
* Fixed an issue ensuring the latest Google Chrome version is pulled
during Fleet-maintained app updates.
* Corrected the display of the SHA256 hash in the UI and API to show
valid values.
* Improved handling of installer uploads to ensure a valid SHA256
checksum is always applied, even when "no_check" is specified.
* **Tests**
* Added a test to verify correct SHA256 hash calculation for installer
files.
* Extended integration tests to validate batch software installer
operations for maintained apps with SHA256 hash checks.
* Added tests covering behavior when SHA256 checksum is marked as
"no_check" for maintained apps.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ian Littman <iansltx@gmail.com>
2025-07-07 11:05:19 -05:00

108 lines
3.2 KiB
Go

package maintained_apps
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
// InstallerTimeout is the timeout duration for downloading and adding a maintained app.
const InstallerTimeout = 15 * time.Minute
// DownloadInstaller downloads the maintained app installer located at the given URL.
func DownloadInstaller(ctx context.Context, installerURL string, client *http.Client) (*fleet.TempFileReader, string, error) {
// validate the URL before doing the request
_, err := url.ParseRequestURI(installerURL)
if err != nil {
return nil, "", fleet.NewInvalidArgumentError(
"fleet_maintained_app.url",
fmt.Sprintf("Couldn't download maintained app installer. URL (%q) is invalid", installerURL),
)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, installerURL, nil)
if err != nil {
return nil, "", ctxerr.Wrapf(ctx, err, "creating request for URL %s", installerURL)
}
resp, err := client.Do(req)
if err != nil {
return nil, "", ctxerr.Wrapf(ctx, err, "performing request for URL %s", installerURL)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, "", fleet.NewInvalidArgumentError(
"fleet_maintained_app.url",
fmt.Sprintf("Couldn't download maintained app installer. URL (%q) doesn't exist. Please make sure that URLs are publicy accessible to the internet.", installerURL),
)
}
// Allow all 2xx and 3xx status codes in this pass.
if resp.StatusCode > 400 {
return nil, "", fleet.NewInvalidArgumentError(
"fleet_maintained_app.url",
fmt.Sprintf("Couldn't download maintained app installer. URL (%q) received response status code %d.", installerURL, resp.StatusCode),
)
}
tfr, err := fleet.NewTempFileReader(resp.Body, nil)
if err != nil {
return nil, "", ctxerr.Wrapf(ctx, err, "reading installer %q contents", installerURL)
}
return tfr, FilenameFromResponse(resp), nil
}
func FilenameFromResponse(resp *http.Response) string {
var filename string
cdh, ok := resp.Header["Content-Disposition"]
if ok && len(cdh) > 0 {
_, params, err := mime.ParseMediaType(cdh[0])
if err == nil {
filename = params["filename"]
} else {
// fallback for responses that include a filename in their content-disposition header
// but the header isn't technically RFC compliant
cdhParts := strings.Split(cdh[0], "filename=")
if len(cdhParts) > 1 {
unescapedFilename, err := url.QueryUnescape(cdhParts[1])
if err == nil {
filename = unescapedFilename
}
}
}
}
// Fall back on extracting the filename from the URL
// This is OK for the first 20 apps we support, but we should do something more robust once we
// support more apps.
if filename == "" && resp.Request.URL.Path != "" {
filename = path.Base(resp.Request.URL.Path)
}
return filename
}
func SHA256FromInstallerFile(installerTFR *fleet.TempFileReader) (string, error) {
h := sha256.New()
_, _ = io.Copy(h, installerTFR) // writes to a Hash can never fail
gotHash := hex.EncodeToString(h.Sum(nil))
if err := installerTFR.Rewind(); err != nil {
return "", fmt.Errorf("rewind installer reader: %w", err)
}
return gotHash, nil
}