Skip to content

Commit b55d417

Browse files
authored
fix: Schema failure fixes (#51)
* fix: Allow schemas to live anywhere in the agent workspace * fix: Remove invalid schema or content before calling service * fix: Make logs less noisy
1 parent 83b98f9 commit b55d417

3 files changed

Lines changed: 169 additions & 54 deletions

File tree

cmd/agent-metadata-action/main.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ func initNewRelic(ctx context.Context) *newrelic.Application {
5757
app, err := newrelic.NewApplication(
5858
newrelic.ConfigAppName("agent-metadata-action"),
5959
newrelic.ConfigLicense(licenseKey),
60-
newrelic.ConfigDebugLogger(os.Stdout),
6160
newrelic.ConfigDistributedTracerEnabled(true),
6261
newrelic.ConfigAppLogForwardingEnabled(true),
6362
newrelic.ConfigFromEnvironment(), // This reads NEW_RELIC_HOST

internal/loader/definitions.go

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,19 @@ func ReadConfigurationDefinitions(ctx context.Context, workspacePath string) ([]
3131
}
3232
schemaPath, ok := definitions[i]["schema"].(string)
3333
if !ok {
34-
logging.Warn(ctx, "schema field is not a string - skipping")
34+
// Drop the field so the server doesn't reject the whole request over a malformed type.
35+
logging.Warn(ctx, "schema field is not a string - dropping it")
36+
delete(definitions[i], "schema")
3537
continue
3638
}
3739

3840
// @todo at some point, we may want to do this concurrently if there are any agents with a large number of files
3941
encoded, err := loadAndEncodeFile(workspacePath, schemaPath, "schema")
4042
if err != nil {
41-
logging.Warnf(ctx, "failed to load schema at schema path %s: %v -- continuing without it", schemaPath, err)
43+
// Drop the field rather than leaving the path string in place — the server would
44+
// otherwise try to base64-decode the path and reject the whole bundled request.
45+
logging.Warnf(ctx, "failed to load schema at schema path %s: %v -- dropping schema field", schemaPath, err)
46+
delete(definitions[i], "schema")
4247
continue
4348
}
4449
definitions[i]["schema"] = encoded
@@ -71,14 +76,19 @@ func ReadAgentControlDefinitions(ctx context.Context, workspacePath string) ([]m
7176
}
7277
contentPath, ok := definitions[i]["content"].(string)
7378
if !ok {
74-
logging.Warn(ctx, "content field is not a string - skipping")
79+
// Drop the field so the server doesn't reject the whole request over a malformed type.
80+
logging.Warn(ctx, "content field is not a string - dropping it")
81+
delete(definitions[i], "content")
7582
continue
7683
}
7784

7885
// @todo at some point, we may want to do this concurrently if there are any agents with a large number of files
7986
encoded, err := loadAndEncodeFile(workspacePath, contentPath, "content")
8087
if err != nil {
81-
logging.Warnf(ctx, "failed to load content at path %s: %v -- continuing without it", contentPath, err)
88+
// Drop the field rather than leaving the path string in place — the server would
89+
// otherwise try to base64-decode the path and reject the whole bundled request.
90+
logging.Warnf(ctx, "failed to load content at path %s: %v -- dropping content field", contentPath, err)
91+
delete(definitions[i], "content")
8292
continue
8393
}
8494
definitions[i]["content"] = encoded
@@ -139,28 +149,22 @@ func loadAndEncodeFile(workspacePath string, contentPath string, filePathField s
139149
return "", nil
140150
}
141151

142-
// Validate content path to prevent directory traversal attacks
143-
if strings.Contains(contentPath, "..") {
144-
return "", fmt.Errorf("invalid %s path: contains directory traversal", filePathField)
145-
}
146-
147-
// Content paths are relative to the expected root directory
152+
// Content paths are relative to the .fleetControl directory; the resolved path
153+
// must stay within the workspace so we can't read arbitrary files on the runner.
148154
fullPath := filepath.Join(workspacePath, config.GetRootFolderForAgentRepo(), contentPath)
149155

150-
// Additional security check: ensure the resolved path is within the expected root directory
151-
expectedRootDir := filepath.Join(workspacePath, config.GetRootFolderForAgentRepo())
152156
resolvedPath, err := filepath.Abs(fullPath)
153157
if err != nil {
154158
return "", fmt.Errorf("failed to resolve %s path: %w", filePathField, err)
155159
}
156160

157-
resolvedRootDirectory, err := filepath.Abs(expectedRootDir)
161+
resolvedWorkspace, err := filepath.Abs(workspacePath)
158162
if err != nil {
159-
return "", fmt.Errorf("failed to resolve expected root directory:%s %w", expectedRootDir, err)
163+
return "", fmt.Errorf("failed to resolve workspace path %s: %w", workspacePath, err)
160164
}
161165

162-
if !strings.HasPrefix(resolvedPath, resolvedRootDirectory+string(filepath.Separator)) && resolvedPath != resolvedRootDirectory {
163-
return "", fmt.Errorf("invalid %s path: must be within expected root directory: %s\n", filePathField, expectedRootDir)
166+
if !strings.HasPrefix(resolvedPath, resolvedWorkspace+string(filepath.Separator)) && resolvedPath != resolvedWorkspace {
167+
return "", fmt.Errorf("invalid %s path: must be within workspace: %s", filePathField, resolvedWorkspace)
164168
}
165169

166170
data, err := os.ReadFile(fullPath)

internal/loader/definitions_test.go

Lines changed: 149 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -385,10 +385,6 @@ func TestReadConfigurationDefinitions_DirectoryTraversal(t *testing.T) {
385385
name: "parent directory traversal with ../",
386386
schemaPath: "../../../etc/passwd",
387387
},
388-
{
389-
name: "relative parent traversal",
390-
schemaPath: "schemas/../../secrets.json",
391-
},
392388
{
393389
name: "multiple parent traversals",
394390
schemaPath: "./../.././../../../sensitive.json",
@@ -428,7 +424,7 @@ func TestReadConfigurationDefinitions_DirectoryTraversal(t *testing.T) {
428424

429425
require.NoError(t, err)
430426
assert.NotNil(t, configs)
431-
assert.Contains(t, outputStr, "directory traversal")
427+
assert.Contains(t, outputStr, "must be within workspace")
432428
})
433429
}
434430
}
@@ -530,11 +526,11 @@ func TestReadAgentControlDefinitions_ContentLoadingWarnings(t *testing.T) {
530526
expectedWarning: "failed to load content",
531527
},
532528
{
533-
name: "directory traversal in content path",
529+
name: "content path escapes workspace",
534530
setupFunc: func(t *testing.T, tmpDir string) string {
535531
return "../../../etc/passwd"
536532
},
537-
expectedWarning: "directory traversal",
533+
expectedWarning: "must be within workspace",
538534
},
539535
}
540536

@@ -820,43 +816,15 @@ func TestLoadAndEncodeFile_PathValidation(t *testing.T) {
820816
expectedErrMsg string
821817
}{
822818
{
823-
name: "path with directory traversal using ../",
819+
name: "path escapes workspace via parent traversal",
824820
setupFunc: func(t *testing.T) (string, string) {
825821
tmpDir := t.TempDir()
826822
workspace := filepath.Join(tmpDir, "workspace", config.GetRootFolderForAgentRepo())
827823
require.NoError(t, os.MkdirAll(workspace, 0755))
828824

829-
// Path that would escape using ../
830825
return filepath.Join(tmpDir, "workspace"), "../../../etc/passwd"
831826
},
832-
expectedErrMsg: "directory traversal",
833-
},
834-
{
835-
name: "path with multiple directory traversals",
836-
setupFunc: func(t *testing.T) (string, string) {
837-
tmpDir := t.TempDir()
838-
workspace := filepath.Join(tmpDir, "workspace", config.GetRootFolderForAgentRepo())
839-
require.NoError(t, os.MkdirAll(workspace, 0755))
840-
841-
return filepath.Join(tmpDir, "workspace"), "schemas/../../sensitive.json"
842-
},
843-
expectedErrMsg: "directory traversal",
844-
},
845-
{
846-
name: "path attempting to read outside .fleetControl via symlink-like path",
847-
setupFunc: func(t *testing.T) (string, string) {
848-
tmpDir := t.TempDir()
849-
workspace := filepath.Join(tmpDir, "workspace", config.GetRootFolderForAgentRepo())
850-
require.NoError(t, os.MkdirAll(workspace, 0755))
851-
852-
// Create a file at workspace level (outside .fleetControl)
853-
outsideFile := filepath.Join(tmpDir, "workspace", "outside.json")
854-
require.NoError(t, os.WriteFile(outsideFile, []byte(`{"test": "data"}`), 0644))
855-
856-
// Try to access it from .fleetControl
857-
return filepath.Join(tmpDir, "workspace"), "../outside.json"
858-
},
859-
expectedErrMsg: "directory traversal",
827+
expectedErrMsg: "must be within workspace",
860828
},
861829
}
862830

@@ -890,3 +858,147 @@ func TestLoadAndEncodeFile_PathValidation(t *testing.T) {
890858
})
891859
}
892860
}
861+
862+
// TestLoadAndEncodeFile_OutsideFleetControl verifies that schema files referenced
863+
// from outside .fleetControl (but still within the workspace) load successfully.
864+
// This is the dotnet agent shape: schema lives at ../src/.../Configuration.xsd.
865+
func TestLoadAndEncodeFile_OutsideFleetControl(t *testing.T) {
866+
tmpDir := t.TempDir()
867+
workspace := filepath.Join(tmpDir, "workspace")
868+
configDir := filepath.Join(workspace, config.GetRootFolderForAgentRepo())
869+
require.NoError(t, os.MkdirAll(configDir, 0755))
870+
871+
// Schema lives outside .fleetControl, in a sibling directory inside the workspace.
872+
srcDir := filepath.Join(workspace, "src")
873+
require.NoError(t, os.MkdirAll(srcDir, 0755))
874+
schemaContent := `<?xml version="1.0"?><xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"/>`
875+
schemaFile := filepath.Join(srcDir, "Configuration.xsd")
876+
require.NoError(t, os.WriteFile(schemaFile, []byte(schemaContent), 0644))
877+
878+
configFile := filepath.Join(configDir, config.GetConfigurationDefinitionsFilename())
879+
testYAML := `configurationDefinitions:
880+
- platform: linux
881+
description: Test config
882+
type: test-config
883+
version: 1.0.0
884+
format: xml
885+
schema: ../src/Configuration.xsd`
886+
require.NoError(t, os.WriteFile(configFile, []byte(testYAML), 0644))
887+
888+
configs, err := ReadConfigurationDefinitions(context.Background(), workspace)
889+
require.NoError(t, err)
890+
require.Len(t, configs, 1)
891+
892+
encoded, ok := configs[0]["schema"].(string)
893+
require.True(t, ok, "schema should be base64-encoded string, got %T", configs[0]["schema"])
894+
expected := base64.StdEncoding.EncodeToString([]byte(schemaContent))
895+
assert.Equal(t, expected, encoded)
896+
}
897+
898+
// TestReadConfigurationDefinitions_DropsBrokenSchemaField verifies that when a
899+
// schema can't be loaded or has the wrong type, the schema field is removed from
900+
// the entry rather than left in place. This prevents the server from rejecting the
901+
// entire bundled request when one entry has a broken schema reference.
902+
func TestReadConfigurationDefinitions_DropsBrokenSchemaField(t *testing.T) {
903+
tests := []struct {
904+
name string
905+
yamlContent string
906+
}{
907+
{
908+
name: "schema path points to missing file",
909+
yamlContent: `configurationDefinitions:
910+
- platform: linux
911+
description: good entry
912+
type: test-config
913+
version: 1.0.0
914+
format: yaml
915+
schema: ./schemas/does-not-exist.json`,
916+
},
917+
{
918+
name: "schema field is not a string",
919+
yamlContent: `configurationDefinitions:
920+
- platform: linux
921+
description: good entry
922+
type: test-config
923+
version: 1.0.0
924+
format: yaml
925+
schema: 12345`,
926+
},
927+
{
928+
name: "schema path escapes workspace",
929+
yamlContent: `configurationDefinitions:
930+
- platform: linux
931+
description: good entry
932+
type: test-config
933+
version: 1.0.0
934+
format: yaml
935+
schema: ../../../etc/passwd`,
936+
},
937+
}
938+
939+
for _, tt := range tests {
940+
t.Run(tt.name, func(t *testing.T) {
941+
tmpDir := t.TempDir()
942+
configDir := filepath.Join(tmpDir, config.GetRootFolderForAgentRepo())
943+
require.NoError(t, os.MkdirAll(configDir, 0755))
944+
945+
configFile := filepath.Join(configDir, config.GetConfigurationDefinitionsFilename())
946+
require.NoError(t, os.WriteFile(configFile, []byte(tt.yamlContent), 0644))
947+
948+
configs, err := ReadConfigurationDefinitions(context.Background(), tmpDir)
949+
require.NoError(t, err)
950+
require.Len(t, configs, 1)
951+
952+
_, hasSchema := configs[0]["schema"]
953+
assert.False(t, hasSchema, "schema field should be removed when load fails, but got: %v", configs[0]["schema"])
954+
// Sibling fields stay so the rest of the entry can still ship.
955+
assert.Equal(t, "good entry", configs[0]["description"])
956+
})
957+
}
958+
}
959+
960+
// TestReadAgentControlDefinitions_DropsBrokenContentField is the agent-control
961+
// counterpart to TestReadConfigurationDefinitions_DropsBrokenSchemaField.
962+
func TestReadAgentControlDefinitions_DropsBrokenContentField(t *testing.T) {
963+
tests := []struct {
964+
name string
965+
yamlContent string
966+
}{
967+
{
968+
name: "content path points to missing file",
969+
yamlContent: `agentControlDefinitions:
970+
- platform: KUBERNETES
971+
supportFromAgent: 1.0.0
972+
supportFromAgentControl: 1.0.0
973+
content: ./agentControl/does-not-exist.yml`,
974+
},
975+
{
976+
name: "content field is not a string",
977+
yamlContent: `agentControlDefinitions:
978+
- platform: KUBERNETES
979+
supportFromAgent: 1.0.0
980+
supportFromAgentControl: 1.0.0
981+
content: 42`,
982+
},
983+
}
984+
985+
for _, tt := range tests {
986+
t.Run(tt.name, func(t *testing.T) {
987+
tmpDir := t.TempDir()
988+
configDir := filepath.Join(tmpDir, config.GetRootFolderForAgentRepo())
989+
require.NoError(t, os.MkdirAll(configDir, 0755))
990+
991+
agentControlFile := filepath.Join(configDir, config.GetAgentControlDefinitionsFilename())
992+
require.NoError(t, os.WriteFile(agentControlFile, []byte(tt.yamlContent), 0644))
993+
994+
defs, err := ReadAgentControlDefinitions(context.Background(), tmpDir)
995+
require.NoError(t, err)
996+
require.Len(t, defs, 1)
997+
998+
_, hasContent := defs[0]["content"]
999+
assert.False(t, hasContent, "content field should be removed when load fails, but got: %v", defs[0]["content"])
1000+
// Sibling fields stay so the rest of the entry can still ship.
1001+
assert.Equal(t, "KUBERNETES", defs[0]["platform"])
1002+
})
1003+
}
1004+
}

0 commit comments

Comments
 (0)