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:
Jahziel Villasana-Espinoza 2026-03-30 10:13:03 -04:00 committed by GitHub
parent d84beaa43f
commit 028ff2adf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 314 additions and 4 deletions

View file

@ -0,0 +1 @@
- Added validation for software install, uninstall, and post-install scripts.

View file

@ -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) {

View file

@ -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 == "" {

View file

@ -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."

View file

@ -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"`

View file

@ -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 {

View file

@ -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{