mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
add missing validation for scripts, tests (#42424)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41500 # 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 - [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) - [x] QA'd all new/changed functionality manually
This commit is contained in:
parent
d84beaa43f
commit
028ff2adf6
7 changed files with 314 additions and 4 deletions
1
changes/41500-validate-scripts
Normal file
1
changes/41500-validate-scripts
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added validation for software install, uninstall, and post-install scripts.
|
||||
|
|
@ -2,6 +2,7 @@ package service
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -105,6 +106,22 @@ func (svc *Service) AddFleetMaintainedApp(
|
|||
uninstallScript = app.UninstallScript
|
||||
}
|
||||
|
||||
// Validate script contents (size, UTF-8, shebang for non-Windows platforms).
|
||||
for _, sv := range []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"install script", installScript},
|
||||
{"post-install script", postInstallScript},
|
||||
{"uninstall script", uninstallScript},
|
||||
} {
|
||||
if err := fleet.ValidateSoftwareInstallerScript(sv.content, app.Platform); err != nil {
|
||||
return 0, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("Couldn't add. %s validation failed: %s", sv.name, err.Error()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maintainedAppID := &app.ID
|
||||
if strings.TrimSpace(installScript) != strings.TrimSpace(app.InstallScript) ||
|
||||
strings.TrimSpace(uninstallScript) != strings.TrimSpace(app.UninstallScript) {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,26 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
return nil, ctxerr.Wrap(ctx, err, "adding metadata to payload")
|
||||
}
|
||||
|
||||
// Validate install/post-install/uninstall script contents for non-script
|
||||
// packages. Script packages (.sh/.ps1) are already validated in
|
||||
// addScriptPackageMetadata.
|
||||
if !fleet.IsScriptPackage(payload.Extension) {
|
||||
for _, scriptVal := range []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"install script", payload.InstallScript},
|
||||
{"post-install script", payload.PostInstallScript},
|
||||
{"uninstall script", payload.UninstallScript},
|
||||
} {
|
||||
if err := fleet.ValidateSoftwareInstallerScript(scriptVal.content, payload.Platform); err != nil {
|
||||
return nil, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("Couldn't add. %s validation failed: %s", scriptVal.name, err.Error()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if payload.AutomaticInstall && payload.AutomaticInstallQuery == "" {
|
||||
switch {
|
||||
//
|
||||
|
|
@ -525,6 +545,12 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
}
|
||||
}
|
||||
|
||||
if err := fleet.ValidateSoftwareInstallerScript(installScript, existingInstaller.Platform); err != nil {
|
||||
return nil, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("Couldn't edit. install script validation failed: %s", err.Error()),
|
||||
}
|
||||
}
|
||||
|
||||
if installScript != existingInstaller.InstallScript {
|
||||
dirty["InstallScript"] = true
|
||||
}
|
||||
|
|
@ -538,6 +564,13 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
payload.PostInstallScript = &emptyScript
|
||||
} else {
|
||||
postInstallScript := file.Dos2UnixNewlines(*payload.PostInstallScript)
|
||||
|
||||
if err := fleet.ValidateSoftwareInstallerScript(postInstallScript, existingInstaller.Platform); err != nil {
|
||||
return nil, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("Couldn't edit. post-install script validation failed: %s", err.Error()),
|
||||
}
|
||||
}
|
||||
|
||||
if postInstallScript != existingInstaller.PostInstallScript {
|
||||
dirty["PostInstallScript"] = true
|
||||
}
|
||||
|
|
@ -563,6 +596,12 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
|||
}
|
||||
}
|
||||
|
||||
if err := fleet.ValidateSoftwareInstallerScript(uninstallScript, existingInstaller.Platform); err != nil {
|
||||
return nil, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("Couldn't edit. uninstall script validation failed: %s", err.Error()),
|
||||
}
|
||||
}
|
||||
|
||||
payloadForUninstallScript := &fleet.UploadSoftwareInstallerPayload{
|
||||
Extension: existingInstaller.Extension,
|
||||
UninstallScript: uninstallScript,
|
||||
|
|
@ -2647,6 +2686,24 @@ func (svc *Service) softwareBatchUpload(
|
|||
return fmt.Errorf("processing uninstall script: %w", err)
|
||||
}
|
||||
|
||||
// Validate install/post-install/uninstall script contents for
|
||||
// non-script packages. Script packages are already validated in
|
||||
// addScriptPackageMetadata.
|
||||
if !fleet.IsScriptPackage(installer.Extension) {
|
||||
for _, sv := range []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"install script", installer.InstallScript},
|
||||
{"post-install script", installer.PostInstallScript},
|
||||
{"uninstall script", installer.UninstallScript},
|
||||
} {
|
||||
if err := fleet.ValidateSoftwareInstallerScript(sv.content, installer.Platform); err != nil {
|
||||
return fmt.Errorf("Couldn't edit software. %s validation failed: %s", sv.name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if filename was empty, try to extract it from the URL with the
|
||||
// now-known extension
|
||||
if installer.Filename == "" {
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ const (
|
|||
RunScriptDisabledErrMsg = "Scripts are disabled for this host. To run scripts, deploy the fleetd agent with scripts enabled."
|
||||
RunScriptsOrbitDisabledErrMsg = "Couldn't run script. To run a script, deploy the fleetd agent with --enable-scripts."
|
||||
RunScriptAsyncScriptEnqueuedMsg = "Script is running or will run when the host comes online."
|
||||
RunScripSavedMaxLenErrMsg = "Script is too large. It's limited to 500,000 characters (approximately 10,000 lines)."
|
||||
RunScriptSavedMaxLenErrMsg = "Script is too large. It's limited to 500,000 characters (approximately 10,000 lines)."
|
||||
RunScripUnsavedMaxLenErrMsg = "Script is too large. It's limited to 10,000 characters (approximately 125 lines)."
|
||||
RunScriptGatewayTimeoutErrMsg = "Gateway timeout. Fleet didn't hear back from the host and doesn't know if the script ran. Please make sure your load balancer timeout isn't shorter than the Fleet server timeout."
|
||||
|
||||
|
|
|
|||
|
|
@ -387,8 +387,9 @@ const (
|
|||
|
||||
// anchored, so that it matches to the end of the line
|
||||
var (
|
||||
scriptHashbangValidation = regexp.MustCompile(`^#!\s*(:?/usr)?/bin/(ba|z)?sh(?:\s*|\s+.*)$`)
|
||||
ErrUnsupportedInterpreter = errors.New(`Interpreter not supported. Supported interpreters are "#!/bin/sh", "#!/bin/bash", "#!/bin/zsh", "#!/usr/bin/env python3", or an absolute path to "python" / "python3".`)
|
||||
scriptHashbangValidation = regexp.MustCompile(`^#!\s*(:?/usr)?/bin/(ba|z)?sh(?:\s*|\s+.*)$`)
|
||||
ErrUnsupportedInterpreter = errors.New(`Interpreter not supported. Supported interpreters are "#!/bin/sh", "#!/bin/bash", "#!/bin/zsh", "#!/usr/bin/env python3", or an absolute path to "python" / "python3".`)
|
||||
ErrUnsupportedShellInterpreter = errors.New(`Interpreter not supported. Shell scripts must run in "#!/bin/sh", "#!/bin/bash", or "#!/bin/zsh."`)
|
||||
)
|
||||
|
||||
type ShebangKind int
|
||||
|
|
@ -514,7 +515,7 @@ func ValidateHostScriptContents(s string, isSavedScript bool) error {
|
|||
}
|
||||
|
||||
maxLen := SavedScriptMaxRuneLen
|
||||
maxLenErrMsg := RunScripSavedMaxLenErrMsg
|
||||
maxLenErrMsg := RunScriptSavedMaxLenErrMsg
|
||||
if !isSavedScript {
|
||||
maxLen = UnsavedScriptMaxRuneLen
|
||||
maxLenErrMsg = RunScripUnsavedMaxLenErrMsg
|
||||
|
|
@ -546,6 +547,49 @@ func ValidateHostScriptContents(s string, isSavedScript bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidateSoftwareInstallerScript validates the content of a software installer
|
||||
// script (install, post-install, or uninstall). Unlike ValidateHostScriptContents,
|
||||
// empty scripts are valid (meaning "no script"). The platform parameter determines
|
||||
// whether shebang validation is applied (only for "darwin" and "linux"; skipped
|
||||
// for "windows" since those use PowerShell).
|
||||
func ValidateSoftwareInstallerScript(s, platform string) error {
|
||||
// Empty scripts are valid — they mean "no script provided".
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size check: use the saved-script limit (500,000 runes).
|
||||
if len(s) > utf8.UTFMax*SavedScriptMaxRuneLen {
|
||||
return errors.New(RunScriptSavedMaxLenErrMsg)
|
||||
}
|
||||
if utf8.RuneCountInString(s) > SavedScriptMaxRuneLen {
|
||||
return errors.New(RunScriptSavedMaxLenErrMsg)
|
||||
}
|
||||
|
||||
// Binary check: must be valid UTF-8.
|
||||
if !utf8.ValidString(s) {
|
||||
return errors.New("Wrong data format. Only plain text allowed.")
|
||||
}
|
||||
|
||||
// Shebang/interpreter check: only for darwin and linux (shell scripts).
|
||||
// Windows uses PowerShell, which doesn't use shebangs.
|
||||
if platform != "windows" {
|
||||
kind, _, err := ShebangInfo(s)
|
||||
if err != nil {
|
||||
// Return a shell-specific error message for software installer scripts,
|
||||
// since they only support shell interpreters (not python).
|
||||
return ErrUnsupportedShellInterpreter
|
||||
}
|
||||
// Software installer scripts must use a shell interpreter (or no shebang,
|
||||
// which defaults to /bin/sh). Python shebangs are not supported here.
|
||||
if kind == ShebangPython {
|
||||
return ErrUnsupportedShellInterpreter
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ScriptPayload struct {
|
||||
Name string `json:"name"`
|
||||
ScriptContents []byte `json:"script_contents"`
|
||||
|
|
|
|||
|
|
@ -214,6 +214,161 @@ func TestValidateHostScriptContents(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateSoftwareInstallerScript(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
script string
|
||||
platform string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "empty script is valid (darwin)",
|
||||
script: "",
|
||||
platform: "darwin",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "empty script is valid (windows)",
|
||||
script: "",
|
||||
platform: "windows",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "empty script is valid (linux)",
|
||||
script: "",
|
||||
platform: "linux",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid shell script with bash shebang on darwin",
|
||||
script: "#!/bin/bash\necho hello",
|
||||
platform: "darwin",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid shell script with sh shebang on darwin",
|
||||
script: "#!/bin/sh\necho hello",
|
||||
platform: "darwin",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid shell script with zsh shebang on darwin",
|
||||
script: "#!/bin/zsh\necho hello",
|
||||
platform: "darwin",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid shell script with no shebang on darwin",
|
||||
script: "echo hello",
|
||||
platform: "darwin",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid shell script with bash shebang on linux",
|
||||
script: "#!/bin/bash\necho hello",
|
||||
platform: "linux",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid shell script with no shebang on linux",
|
||||
script: "echo hello",
|
||||
platform: "linux",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "unsupported interpreter on darwin",
|
||||
script: "#!/usr/bin/perl\nprint 'hello'",
|
||||
platform: "darwin",
|
||||
wantErr: ErrUnsupportedShellInterpreter,
|
||||
},
|
||||
{
|
||||
name: "unsupported interpreter on linux",
|
||||
script: "#!/usr/bin/perl\nprint 'hello'",
|
||||
platform: "linux",
|
||||
wantErr: ErrUnsupportedShellInterpreter,
|
||||
},
|
||||
{
|
||||
name: "unsupported interpreter on windows is OK (shebang not checked)",
|
||||
script: "#!/usr/bin/perl\nprint 'hello'",
|
||||
platform: "windows",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid powershell content on windows",
|
||||
script: "Write-Host 'hello'",
|
||||
platform: "windows",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "too large by byte count (darwin)",
|
||||
script: strings.Repeat("a", utf8.UTFMax*SavedScriptMaxRuneLen+1),
|
||||
platform: "darwin",
|
||||
wantErr: errors.New(RunScriptSavedMaxLenErrMsg),
|
||||
},
|
||||
{
|
||||
name: "too large by rune count (darwin)",
|
||||
script: strings.Repeat("🙂", SavedScriptMaxRuneLen+1),
|
||||
platform: "darwin",
|
||||
wantErr: errors.New(RunScriptSavedMaxLenErrMsg),
|
||||
},
|
||||
{
|
||||
name: "too large by byte count (windows)",
|
||||
script: strings.Repeat("a", utf8.UTFMax*SavedScriptMaxRuneLen+1),
|
||||
platform: "windows",
|
||||
wantErr: errors.New(RunScriptSavedMaxLenErrMsg),
|
||||
},
|
||||
{
|
||||
name: "too large by byte count (linux)",
|
||||
script: strings.Repeat("a", utf8.UTFMax*SavedScriptMaxRuneLen+1),
|
||||
platform: "linux",
|
||||
wantErr: errors.New(RunScriptSavedMaxLenErrMsg),
|
||||
},
|
||||
{
|
||||
name: "invalid utf8 encoding (darwin)",
|
||||
script: string([]byte{0xff, 0xfe, 0xfd}),
|
||||
platform: "darwin",
|
||||
wantErr: errors.New("Wrong data format. Only plain text allowed."),
|
||||
},
|
||||
{
|
||||
name: "invalid utf8 encoding (windows)",
|
||||
script: string([]byte{0xff, 0xfe, 0xfd}),
|
||||
platform: "windows",
|
||||
wantErr: errors.New("Wrong data format. Only plain text allowed."),
|
||||
},
|
||||
{
|
||||
name: "invalid utf8 encoding (linux)",
|
||||
script: string([]byte{0xff, 0xfe, 0xfd}),
|
||||
platform: "linux",
|
||||
wantErr: errors.New("Wrong data format. Only plain text allowed."),
|
||||
},
|
||||
{
|
||||
name: "at exactly the size limit is valid",
|
||||
script: strings.Repeat("a", SavedScriptMaxRuneLen),
|
||||
platform: "darwin",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "python shebang is rejected on darwin (software installer scripts are shell only)",
|
||||
script: "#!/usr/bin/env python3\nprint('hello')",
|
||||
platform: "darwin",
|
||||
wantErr: ErrUnsupportedShellInterpreter,
|
||||
},
|
||||
{
|
||||
name: "python shebang is rejected on linux (software installer scripts are shell only)",
|
||||
script: "#!/usr/bin/env python3\nprint('hello')",
|
||||
platform: "linux",
|
||||
wantErr: ErrUnsupportedShellInterpreter,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateSoftwareInstallerScript(tt.script, tt.platform)
|
||||
require.Equal(t, tt.wantErr, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostTimeout(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
|
|
|
|||
|
|
@ -12442,6 +12442,13 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
|
|||
})
|
||||
}
|
||||
|
||||
// upload with unsupported shebang in install script fails validation
|
||||
payloadBadShebang := &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "#!/usr/bin/perl\nprint 'hello'",
|
||||
Filename: "ruby.deb",
|
||||
}
|
||||
s.uploadSoftwareInstaller(t, payloadBadShebang, http.StatusBadRequest, "Interpreter not supported")
|
||||
|
||||
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
|
||||
|
||||
// check the software installer
|
||||
|
|
@ -12470,6 +12477,14 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
|
|||
"team_id": null, "fleet_name": null, "fleet_id": null, "self_service": true, "software_title_id": %d, "labels_include_any": [{"id": %d, "name": %q}], "software_display_name": ""}`,
|
||||
titleID, lblA.ID, lblA.Name)
|
||||
s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), activityData, 0)
|
||||
|
||||
// update with unsupported shebang in install script fails validation
|
||||
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
|
||||
InstallScript: ptr.String("#!/usr/bin/perl\nprint 'hello'"),
|
||||
TitleID: titleID,
|
||||
TeamID: nil,
|
||||
}, http.StatusBadRequest, "Interpreter not supported")
|
||||
|
||||
// patch the software installer to change the labels
|
||||
body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
|
||||
"team_id": {"0"},
|
||||
|
|
@ -13689,6 +13704,17 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
|||
errMsg = extractServerErrorText(resp.Body)
|
||||
require.Empty(t, errMsg)
|
||||
|
||||
// batch upload with unsupported shebang in install script fails validation
|
||||
softwareToInstallBadShebang := []*fleet.SoftwareInstallerPayload{
|
||||
{
|
||||
URL: rubyURL,
|
||||
InstallScript: "#!/usr/bin/perl\nprint 'hello'",
|
||||
},
|
||||
}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadShebang}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
||||
message = waitBatchSetSoftwareInstallersFailed(t, &s.withServer, tm.Name, batchResponse.RequestUUID)
|
||||
require.Contains(t, message, "Interpreter not supported")
|
||||
|
||||
// TODO(roberto): test with a variety of response codes
|
||||
|
||||
// check the application status
|
||||
|
|
@ -20070,6 +20096,16 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
|
|||
require.Len(t, errReasons, 1)
|
||||
assert.Contains(t, errReasons[0], "$FLEET_SECRET_INVALID3")
|
||||
|
||||
// Add with unsupported shebang in install script fails validation
|
||||
reqBadShebang := &addFleetMaintainedAppRequest{
|
||||
AppID: 1,
|
||||
TeamID: &team.ID,
|
||||
InstallScript: "#!/usr/bin/perl\nprint 'hello'",
|
||||
}
|
||||
respBadShebang := s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", reqBadShebang, http.StatusBadRequest)
|
||||
errMsg := extractServerErrorText(respBadShebang.Body)
|
||||
require.Contains(t, errMsg, "Interpreter not supported")
|
||||
|
||||
// Add an ingested app to the team
|
||||
var addMAResp addFleetMaintainedAppResponse
|
||||
req := &addFleetMaintainedAppRequest{
|
||||
|
|
|
|||
Loading…
Reference in a new issue