diff --git a/internal/config/config.go b/internal/config/config.go index f04df5196..c2343fc7b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ import ( "log/slog" "os" "path/filepath" + "regexp" "slices" "strconv" "strings" @@ -36,7 +37,11 @@ const ( EnvPrefix = "NGINX_AGENT" KeyDelimiter = "_" KeyValueNumber = 2 - AgentDirName = "/etc/nginx-agent/" + AgentDirName = "/etc/nginx-agent" + + // Regular expression to match invalid characters in paths. + // It matches whitespace, control characters, non-printable characters, and specific Unicode characters. + regexInvalidPath = "\\s|[[:cntrl:]]|[[:space:]]|[[^:print:]]|ㅤ|\\.\\.|\\*" ) var viperInstance = viper.NewWithOptions(viper.KeyDelimiter(KeyDelimiter)) @@ -79,27 +84,13 @@ func RegisterConfigFile() error { } func ResolveConfig() (*Config, error) { - // Collect allowed directories, so that paths in the config can be validated. - directories := viperInstance.GetStringSlice(AllowedDirectoriesKey) - allowedDirs := []string{AgentDirName} - log := resolveLog() slogger := logger.New(log.Path, log.Level) slog.SetDefault(slogger) - // Check directories in allowed_directories are valid - for _, dir := range directories { - if dir == "" || !filepath.IsAbs(dir) { - slog.Warn("Invalid directory: ", "dir", dir) - continue - } - - if !strings.HasSuffix(dir, "/") { - dir += "/" - } - allowedDirs = append(allowedDirs, dir) - } - + // Collect allowed directories, so that paths in the config can be validated. + directories := viperInstance.GetStringSlice(AllowedDirectoriesKey) + allowedDirs := resolveAllowedDirectories(directories) slog.Info("Configured allowed directories", "allowed_directories", allowedDirs) // Collect all parsing errors before returning the error, so the user sees all issues with config @@ -129,13 +120,40 @@ func ResolveConfig() (*Config, error) { checkCollectorConfiguration(collector, config) + slog.Info( + "Excluded files from being watched for file changes", + "exclude_files", + config.Watchers.FileWatcher.ExcludeFiles, + ) + slog.Debug("Agent config", "config", config) - slog.Info("Excluded files from being watched for file changes", "exclude_files", - config.Watchers.FileWatcher.ExcludeFiles) return config, nil } +// resolveAllowedDirectories checks if the provided directories are valid and returns a slice of cleaned absolute paths. +// It ignores empty paths, paths that are not absolute, and paths containing invalid characters. +// Invalid paths are logged as warnings. +func resolveAllowedDirectories(dirs []string) []string { + allowed := []string{AgentDirName} + for _, dir := range dirs { + re := regexp.MustCompile(regexInvalidPath) + invalidChars := re.MatchString(dir) + if dir == "" || dir == "/" || !filepath.IsAbs(dir) || invalidChars { + slog.Warn("Ignoring invalid directory", "dir", dir) + continue + } + dir = filepath.Clean(dir) + if dir == AgentDirName { + // If the directory is the default agent directory, we skip adding it again. + continue + } + allowed = append(allowed, dir) + } + + return allowed +} + func checkCollectorConfiguration(collector *Collector, config *Config) { if isOTelExporterConfigured(collector) && config.IsGrpcClientConfigured() && config.IsAuthConfigured() && config.IsTLSConfigured() { @@ -744,13 +762,14 @@ func resolveClient() *Client { } func resolveCollector(allowedDirs []string) (*Collector, error) { + // Collect receiver configurations var receivers Receivers - err := resolveMapStructure(CollectorReceiversKey, &receivers) if err != nil { return nil, fmt.Errorf("unmarshal collector receivers config: %w", err) } + // Collect exporter configurations exporters, err := resolveExporters() if err != nil { return nil, fmt.Errorf("unmarshal collector exporters config: %w", err) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 985e420cf..6e9490898 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -158,6 +158,84 @@ func TestNormalizeFunc(t *testing.T) { assert.Equal(t, expected, result) } +func TestResolveAllowedDirectories(t *testing.T) { + tests := []struct { + name string + configuredDirs []string + expected []string + }{ + { + name: "Empty path", + configuredDirs: []string{""}, + expected: []string{"/etc/nginx-agent"}, + }, + { + name: "Absolute path", + configuredDirs: []string{"/etc/agent/"}, + expected: []string{"/etc/nginx-agent", "/etc/agent"}, + }, + { + name: "Absolute paths", + configuredDirs: []string{"/etc/nginx/"}, + expected: []string{"/etc/nginx-agent", "/etc/nginx"}, + }, + { + name: "Absolute path with multiple slashes", + configuredDirs: []string{"/etc///////////nginx-agent/"}, + expected: []string{"/etc/nginx-agent"}, + }, + { + name: "Absolute path with directory traversal", + configuredDirs: []string{"/etc/nginx/../nginx-agent"}, + expected: []string{"/etc/nginx-agent"}, + }, + { + name: "Absolute path with repeat directory traversal", + configuredDirs: []string{"/etc/nginx-agent/../../../../../nginx-agent"}, + expected: []string{"/etc/nginx-agent"}, + }, + { + name: "Absolute path with control characters", + configuredDirs: []string{"/etc/nginx-agent/\\x08../tmp/"}, + expected: []string{"/etc/nginx-agent"}, + }, + { + name: "Absolute path with invisible characters", + configuredDirs: []string{"/etc/nginx-agent/ㅤㅤㅤ/tmp/"}, + expected: []string{"/etc/nginx-agent"}, + }, + { + name: "Absolute path with escaped invisible characters", + configuredDirs: []string{"/etc/nginx-agent/\\\\ㅤ/tmp/"}, + expected: []string{"/etc/nginx-agent"}, + }, + { + name: "Mixed paths", + configuredDirs: []string{ + "nginx-agent", + "", + "..", + "/", + "\\/", + ".", + "/etc/nginx/", + }, + expected: []string{"/etc/nginx-agent", "/etc/nginx"}, + }, + { + name: "Relative path", + configuredDirs: []string{"nginx-agent"}, + expected: []string{"/etc/nginx-agent"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + allowed := resolveAllowedDirectories(test.configuredDirs) + assert.Equal(t, test.expected, allowed) + }) + } +} + func TestResolveLog(t *testing.T) { viperInstance = viper.NewWithOptions(viper.KeyDelimiter(KeyDelimiter)) viperInstance.Set(LogLevelKey, "error") @@ -765,7 +843,7 @@ func agentConfig() *Config { return &Config{ UUID: "", Version: "", - Path: "", + Path: "testdata/agent.conf", Log: &Log{}, Client: &Client{ HTTP: &HTTP{ @@ -790,88 +868,14 @@ func agentConfig() *Config { }, }, AllowedDirectories: []string{ - "/etc/nginx/", "/etc/nginx-agent/", "/usr/local/etc/nginx/", "/var/run/nginx/", "/var/log/nginx/", - "/usr/share/nginx/modules/", - }, - Collector: &Collector{ - ConfigPath: "/etc/nginx-agent/nginx-agent-otelcol.yaml", - Exporters: Exporters{ - OtlpExporters: []OtlpExporter{ - { - Server: &ServerConfig{ - Host: "127.0.0.1", - Port: 1234, - Type: Grpc, - }, - TLS: &TLSConfig{ - Cert: "/path/to/server-cert.pem", - Key: "/path/to/server-cert.pem", - Ca: "/path/to/server-cert.pem", - SkipVerify: true, - ServerName: "remote-saas-server", - }, - }, - }, - }, - Processors: Processors{ - Batch: &Batch{ - SendBatchMaxSize: DefCollectorBatchProcessorSendBatchMaxSize, - SendBatchSize: DefCollectorBatchProcessorSendBatchSize, - Timeout: DefCollectorBatchProcessorTimeout, - }, - LogsGzip: &LogsGzip{}, - }, - Receivers: Receivers{ - OtlpReceivers: []OtlpReceiver{ - { - Server: &ServerConfig{ - Host: "localhost", - Port: 4317, - Type: Grpc, - }, - Auth: &AuthConfig{ - Token: "even-secreter-token", - }, - OtlpTLSConfig: &OtlpTLSConfig{ - GenerateSelfSignedCert: false, - Cert: "/path/to/server-cert.pem", - Key: "/path/to/server-cert.pem", - Ca: "/path/to/server-cert.pem", - SkipVerify: true, - ServerName: "local-data-plane-server", - }, - }, - }, - NginxReceivers: []NginxReceiver{ - { - InstanceID: "cd7b8911-c2c5-4daf-b311-dbead151d938", - StubStatus: APIDetails{ - URL: "http://localhost:4321/status", - Listen: "", - }, - AccessLogs: []AccessLog{ - { - LogFormat: accessLogFormat, - FilePath: "/var/log/nginx/access-custom.conf", - }, - }, - }, - }, - }, - Extensions: Extensions{ - Health: &Health{ - Server: &ServerConfig{ - Host: "localhost", - Port: 1337, - }, - Path: "/", - }, - }, - Log: &Log{ - Level: "INFO", - Path: "/var/log/nginx-agent/opentelemetry-collector-agent.log", - }, + "/etc/nginx", + "/etc/nginx-agent", + "/usr/local/etc/nginx", + "/var/run/nginx", + "/var/log/nginx", + "/usr/share/nginx/modules", }, + Collector: createDefaultCollectorConfig(), Command: &Command{ Server: &ServerConfig{ Host: "127.0.0.1", @@ -924,8 +928,12 @@ func createConfig() *Config { }, }, AllowedDirectories: []string{ - "/etc/nginx-agent/", "/etc/nginx/", "/usr/local/etc/nginx/", "/var/run/nginx/", - "/usr/share/nginx/modules/", "/var/log/nginx/", + "/etc/nginx-agent", + "/etc/nginx", + "/usr/local/etc/nginx", + "/var/run/nginx", + "/usr/share/nginx/modules", + "/var/log/nginx", }, DataPlaneConfig: &DataPlaneConfig{ Nginx: &NginxDataPlaneConfig{ @@ -1108,3 +1116,85 @@ func createConfig() *Config { }, } } + +func createDefaultCollectorConfig() *Collector { + return &Collector{ + ConfigPath: "/etc/nginx-agent/testdata/nginx-agent-otelcol.yaml", + Exporters: Exporters{ + OtlpExporters: []OtlpExporter{ + { + Server: &ServerConfig{ + Host: "127.0.0.1", + Port: 1234, + Type: Grpc, + }, + TLS: &TLSConfig{ + Cert: "/path/to/server-cert.pem", + Key: "/path/to/server-cert.pem", + Ca: "/path/to/server-cert.pem", + SkipVerify: true, + ServerName: "remote-saas-server", + }, + }, + }, + }, + Processors: Processors{ + Batch: &Batch{ + SendBatchMaxSize: DefCollectorBatchProcessorSendBatchMaxSize, + SendBatchSize: DefCollectorBatchProcessorSendBatchSize, + Timeout: DefCollectorBatchProcessorTimeout, + }, + LogsGzip: &LogsGzip{}, + }, + Receivers: Receivers{ + OtlpReceivers: []OtlpReceiver{ + { + Server: &ServerConfig{ + Host: "localhost", + Port: 4317, + Type: Grpc, + }, + Auth: &AuthConfig{ + Token: "even-secreter-token", + }, + OtlpTLSConfig: &OtlpTLSConfig{ + GenerateSelfSignedCert: false, + Cert: "/path/to/server-cert.pem", + Key: "/path/to/server-cert.pem", + Ca: "/path/to/server-cert.pem", + SkipVerify: true, + ServerName: "local-data-plane-server", + }, + }, + }, + NginxReceivers: []NginxReceiver{ + { + InstanceID: "cd7b8911-c2c5-4daf-b311-dbead151d938", + StubStatus: APIDetails{ + URL: "http://localhost:4321/status", + Listen: "", + }, + AccessLogs: []AccessLog{ + { + LogFormat: accessLogFormat, + FilePath: "/var/log/nginx/access-custom.conf", + }, + }, + }, + }, + }, + Extensions: Extensions{ + Health: &Health{ + Server: &ServerConfig{ + Host: "localhost", + Port: 1337, + }, + Path: "/", + }, + }, + Log: &Log{ + Level: "INFO", + Path: "/var/log/nginx-agent/opentelemetry-collector-agent.log", + }, + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 931d1c046..0466bdbd0 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -8,7 +8,10 @@ package config import ( "errors" "fmt" + "log/slog" + "os" "path/filepath" + "regexp" "strings" "time" @@ -414,16 +417,63 @@ func (c *Config) AreReceiversConfigured() bool { len(c.Collector.Receivers.TcplogReceivers) > 0 } -func isAllowedDir(dir string, allowedDirs []string) bool { - if !strings.HasSuffix(dir, "/") && filepath.Ext(dir) == "" { - dir += "/" +// isAllowedDir checks if the given path is in the list of allowed directories. +// It returns true if the path is allowed, false otherwise. +// If the path is allowed but does not exist, it also logs a warning. +// It also checks if the path is a file, in which case it checks the directory of the file. +func isAllowedDir(path string, allowedDirs []string) bool { + if len(allowedDirs) == 0 { + slog.Warn("No allowed directories configured") + return false + } + + directoryPath := path + isFilePath, err := regexp.MatchString(`\.(\w+)$`, directoryPath) + if err != nil { + slog.Error("Error matching path", "path", directoryPath, "error", err) + return false + } + + if isFilePath { + directoryPath = filepath.Dir(directoryPath) + slog.Debug("File path detected, checking parent directory is allowed", "path", directoryPath) + } + + fInfo, statErr := os.Stat(directoryPath) + if statErr != nil { + slog.Warn("Stat: Path error", "path", path, "error", statErr) + } + + if fInfo != nil { + if isSymlink(directoryPath) { + slog.Warn("Path is a symlink, skipping allowed directory check", "path", directoryPath) + return false + } } for _, allowedDirectory := range allowedDirs { - if strings.HasPrefix(dir, allowedDirectory) { + // Check if the directoryPath starts with the allowedDirectory + // This allows for subdirectories within the allowed directories. + if strings.HasPrefix(directoryPath, allowedDirectory) { return true } } return false } + +func isSymlink(path string) bool { + // check if it contains a symlink + lInfo, err := os.Lstat(path) + if err != nil { + slog.Warn("Lstat error", "path", path, "error", err) + return false + } + if lInfo != nil && lInfo.Mode()&os.ModeSymlink != 0 { + slog.Warn("Path is a symlink", "path", path) + + return true + } + + return false +} diff --git a/internal/config/types_test.go b/internal/config/types_test.go index f857a76d5..ae7212c66 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -6,88 +6,103 @@ package config import ( + "os" "testing" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" ) -func TestTypes_IsDirectoryAllowed(t *testing.T) { - config := agentConfig() - +func TestTypes_isAllowedDir(t *testing.T) { tests := []struct { name string - fileDir string + filePath string allowedDirs []string allowed bool }{ { - name: "Test 1: directory allowed", + name: "File is in allowed directory", allowed: true, allowedDirs: []string{ - AgentDirName, - "/etc/nginx/", - "/var/log/nginx/", + "/etc/nginx", }, - fileDir: "/etc/nginx", + filePath: "/etc/nginx/nginx.conf", }, { - name: "Test 2: directory not allowed", - allowed: false, + name: "File is in allowed directory with hyphen", + allowed: true, allowedDirs: []string{ - AgentDirName, - "/etc/nginx/", - "/var/log/nginx/", + "/etc/nginx-agent", }, - fileDir: "/etc/nginx-test/nginx-agent.conf", + filePath: "/etc/nginx-agent/nginx.conf", }, { - name: "Test 3: directory allowed", + name: "File exists and is in a subdirectory of allowed directory", allowed: true, allowedDirs: []string{ - AgentDirName, - "/etc/nginx/", - "/var/log/nginx/", + "/etc/nginx", }, - fileDir: "/etc/nginx/conf.d/nginx-agent.conf", + filePath: "/etc/nginx/conf.d/nginx.conf", }, { - name: "Test 4: directory not allowed", + name: "File exists and is outside allowed directory", allowed: false, allowedDirs: []string{ - AgentDirName, "/etc/nginx", - "/var/log/nginx", }, - fileDir: "~/test.conf", + filePath: "/etc/test/nginx.conf", }, { - name: "Test 5: directory not allowed", - allowed: false, + name: "File does not exist but is in allowed directory", + allowed: true, allowedDirs: []string{ - AgentDirName, - "/etc/nginx/", - "/var/log/nginx/", + "/etc/nginx", }, - fileDir: "//test.conf", + filePath: "/etc/nginx/idontexist.conf", }, { - name: "Test 6: directory allowed", - allowed: true, + name: "File does not exist and is outside allowed directory", + allowed: false, allowedDirs: []string{ - AgentDirName, - "/etc/nginx/", - "/var/log/nginx/", - "/", + "/etc/nginx", }, - fileDir: "/test.conf", + filePath: "/not-nginx-test/idontexist.conf", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - config.AllowedDirectories = test.allowedDirs - result := config.IsDirectoryAllowed(test.fileDir) + result := isAllowedDir(test.filePath, test.allowedDirs) assert.Equal(t, test.allowed, result) }) } + + t.Run("Symlink in allowed directory", func(t *testing.T) { + allowedDirs := []string{"/etc/nginx"} + filePath := "file.conf" + symlinkPath := "file_link" + + // Create a temp directory for the symlink + tempDir, err := os.MkdirTemp("", "symlink_test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) // Clean up the temp directory after the test + + // Ensure the temp directory is in the allowedDirs + allowedDirs = append(allowedDirs, tempDir) + + filePath = tempDir + "/" + filePath + defer os.RemoveAll(filePath) + err = os.WriteFile(filePath, []byte("test content"), 0o600) + require.NoError(t, err) + + // Create a symlink for testing + symlinkPath = tempDir + "/" + symlinkPath + defer os.Remove(symlinkPath) + err = os.Symlink(filePath, symlinkPath) + require.NoError(t, err) + + result := isAllowedDir(symlinkPath, allowedDirs) + require.False(t, result, "Symlink in allowed directory should return false") + }) }