Cherry-pick MM-66789: Restrict log downloads to a root path for support packets (#35164)
Automatic Merge
Этот коммит содержится в:
@@ -6,11 +6,13 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/utils"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
)
|
||||
|
||||
@@ -127,6 +129,123 @@ func GetLogSettingsFromNotificationsLogSettings(notificationLogSettings *model.N
|
||||
return settings
|
||||
}
|
||||
|
||||
// GetLogRootPath returns the root directory for all log files.
|
||||
// This is used for security validation to prevent arbitrary file reads via advanced logging.
|
||||
// The logging root is determined by:
|
||||
// 1. MM_LOG_PATH environment variable (if set and non-empty)
|
||||
// 2. The default "logs" directory (found relative to the binary)
|
||||
func GetLogRootPath() string {
|
||||
// Check environment variable first
|
||||
if envPath := os.Getenv("MM_LOG_PATH"); envPath != "" {
|
||||
absPath, err := filepath.Abs(envPath)
|
||||
if err == nil {
|
||||
return absPath
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default logs directory
|
||||
logsDir, _ := fileutils.FindDir("logs")
|
||||
absPath, err := filepath.Abs(logsDir)
|
||||
if err != nil {
|
||||
return logsDir
|
||||
}
|
||||
return absPath
|
||||
}
|
||||
|
||||
// ValidateLogFilePath validates that a log file path is within the logging root directory.
|
||||
// This prevents arbitrary file read/write vulnerabilities in logging configuration.
|
||||
// The logging root is determined by MM_LOG_PATH environment variable or the configured log directory.
|
||||
func ValidateLogFilePath(filePath string, loggingRoot string) error {
|
||||
// Resolve file path to absolute
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve path %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
// Resolve symlinks to prevent bypass via symlink attacks
|
||||
realPath, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
// If file doesn't exist, still validate the intended path
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("cannot resolve symlinks for %s: %w", absPath, err)
|
||||
}
|
||||
} else {
|
||||
absPath = realPath
|
||||
}
|
||||
|
||||
// Resolve logging root to absolute
|
||||
absRoot, err := filepath.Abs(loggingRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve logging root %s: %w", loggingRoot, err)
|
||||
}
|
||||
|
||||
// Ensure root has trailing separator for proper prefix matching
|
||||
// This prevents /tmp/log matching /tmp/logger
|
||||
rootWithSep := absRoot
|
||||
if !strings.HasSuffix(rootWithSep, string(filepath.Separator)) {
|
||||
rootWithSep += string(filepath.Separator)
|
||||
}
|
||||
|
||||
// Check if file is within the logging root
|
||||
// Allow exact match (absPath == absRoot) or proper prefix match
|
||||
if absPath != absRoot && !strings.HasPrefix(absPath, rootWithSep) {
|
||||
return fmt.Errorf("path %s is outside logging root %s", filePath, absRoot)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WarnIfLogPathsOutsideRoot validates log file paths in the config and logs errors for paths outside the logging root.
|
||||
// This is called during config save to identify configurations that will cause server startup to fail in a future version.
|
||||
// Currently only logs errors; in a future version this will block server startup.
|
||||
func WarnIfLogPathsOutsideRoot(cfg *model.Config) {
|
||||
loggingRoot := GetLogRootPath()
|
||||
|
||||
// Check LogSettings.AdvancedLoggingJSON
|
||||
if !utils.IsEmptyJSON(cfg.LogSettings.AdvancedLoggingJSON) {
|
||||
validateAdvancedLoggingConfig(cfg.LogSettings.AdvancedLoggingJSON, "LogSettings.AdvancedLoggingJSON", loggingRoot)
|
||||
}
|
||||
|
||||
// Check NotificationLogSettings.AdvancedLoggingJSON
|
||||
if !utils.IsEmptyJSON(cfg.NotificationLogSettings.AdvancedLoggingJSON) {
|
||||
validateAdvancedLoggingConfig(cfg.NotificationLogSettings.AdvancedLoggingJSON, "NotificationLogSettings.AdvancedLoggingJSON", loggingRoot)
|
||||
}
|
||||
|
||||
// Check ExperimentalAuditSettings.AdvancedLoggingJSON
|
||||
if !utils.IsEmptyJSON(cfg.ExperimentalAuditSettings.AdvancedLoggingJSON) {
|
||||
validateAdvancedLoggingConfig(cfg.ExperimentalAuditSettings.AdvancedLoggingJSON, "ExperimentalAuditSettings.AdvancedLoggingJSON", loggingRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func validateAdvancedLoggingConfig(loggingJSON json.RawMessage, configName string, loggingRoot string) {
|
||||
logCfg := make(mlog.LoggerConfiguration)
|
||||
if err := json.Unmarshal(loggingJSON, &logCfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for targetName, target := range logCfg {
|
||||
if target.Type != "file" {
|
||||
continue
|
||||
}
|
||||
|
||||
var fileOption struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal(target.Options, &fileOption); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := ValidateLogFilePath(fileOption.Filename, loggingRoot); err != nil {
|
||||
mlog.Error("Log file path in logging config is outside logging root directory. This configuration will cause server startup to fail in a future version. To fix, set MM_LOG_PATH environment variable to a parent directory containing all log paths, or move log files to the configured logging root.",
|
||||
mlog.String("config_section", configName),
|
||||
mlog.String("target", targetName),
|
||||
mlog.String("path", fileOption.Filename),
|
||||
mlog.String("logging_root", loggingRoot),
|
||||
mlog.Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeSimpleConsoleTarget(level string, outputJSON bool, color bool) (mlog.TargetCfg, error) {
|
||||
levels, err := stdLevels(level)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,8 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -50,3 +52,169 @@ func TestMloggerConfigFromAuditConfig(t *testing.T) {
|
||||
assert.Equal(t, optionsExpected, optionsReceived)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLogRootPath(t *testing.T) {
|
||||
t.Run("returns MM_LOG_PATH when set", func(t *testing.T) {
|
||||
// Create a temp directory to use as MM_LOG_PATH
|
||||
dir, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(dir)
|
||||
})
|
||||
|
||||
t.Setenv("MM_LOG_PATH", dir)
|
||||
|
||||
result := GetLogRootPath()
|
||||
absDir, _ := filepath.Abs(dir)
|
||||
assert.Equal(t, absDir, result)
|
||||
})
|
||||
|
||||
t.Run("finds logs directory relative to binary when MM_LOG_PATH not set", func(t *testing.T) {
|
||||
// When MM_LOG_PATH is not set, GetLogRootPath falls back to FindDir("logs"),
|
||||
// which searches for a "logs" directory relative to the working directory
|
||||
// and the binary location. Create a logs directory relative to the test
|
||||
// binary to verify this behavior.
|
||||
t.Setenv("MM_LOG_PATH", "")
|
||||
|
||||
// Get the test binary location
|
||||
exe, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
require.NoError(t, err)
|
||||
binaryDir := filepath.Dir(exe)
|
||||
|
||||
// Create a "logs" directory next to the binary
|
||||
logsDir := filepath.Join(binaryDir, "logs")
|
||||
err = os.MkdirAll(logsDir, 0755)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(logsDir)
|
||||
})
|
||||
|
||||
result := GetLogRootPath()
|
||||
|
||||
// Result should be an absolute path
|
||||
assert.True(t, filepath.IsAbs(result), "GetLogRootPath should return an absolute path, got: %s", result)
|
||||
|
||||
// FindDir searches working directory first, then binary directory.
|
||||
// The result should be either the logs directory we created or another
|
||||
// logs directory found earlier in the search path. Either way, it should
|
||||
// be a valid directory path ending in "logs".
|
||||
assert.True(t, filepath.Base(result) == "logs" || result == "./",
|
||||
"GetLogRootPath should return a logs directory path, got: %s", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLogFilePath(t *testing.T) {
|
||||
t.Run("valid path within root", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
validFile := filepath.Join(root, "app.log")
|
||||
err = os.WriteFile(validFile, []byte("test"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateLogFilePath(validFile, root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid path in subdirectory", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
subdir := filepath.Join(root, "subdir")
|
||||
err = os.MkdirAll(subdir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
validFile := filepath.Join(subdir, "app.log")
|
||||
err = os.WriteFile(validFile, []byte("test"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateLogFilePath(validFile, root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("rejects absolute path outside root", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
outsideDir, err := os.MkdirTemp("", "outside")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(outsideDir)
|
||||
})
|
||||
|
||||
outsideFile := filepath.Join(outsideDir, "secret.txt")
|
||||
err = os.WriteFile(outsideFile, []byte("secret"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateLogFilePath(outsideFile, root)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "outside logging root")
|
||||
})
|
||||
|
||||
t.Run("rejects path traversal attack", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
traversalPath := filepath.Join(root, "..", "..", "etc", "passwd")
|
||||
|
||||
err = ValidateLogFilePath(traversalPath, root)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "outside logging root")
|
||||
})
|
||||
|
||||
t.Run("rejects symlink pointing outside root", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
outsideDir, err := os.MkdirTemp("", "outside")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(outsideDir)
|
||||
})
|
||||
|
||||
// Create a file outside the root
|
||||
outsideFile := filepath.Join(outsideDir, "secret.txt")
|
||||
err = os.WriteFile(outsideFile, []byte("secret"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a symlink inside root pointing to the outside file
|
||||
symlinkPath := filepath.Join(root, "sneaky.log")
|
||||
err = os.Symlink(outsideFile, symlinkPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateLogFilePath(symlinkPath, root)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "outside logging root")
|
||||
})
|
||||
|
||||
t.Run("allows non-existent file path within root", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
// File doesn't exist but path is within root
|
||||
nonExistentFile := filepath.Join(root, "future.log")
|
||||
|
||||
err = ValidateLogFilePath(nonExistentFile, root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user