Skip to content

Commit 55121c5

Browse files
authored
fix(file): rotate active file if it points to a symbolic link (#274)
* fix: rotate active file if it points to a symlink * use lowercase naming for assert func * close logger in test * explicitly cleanup files to avoid windows issue * add missing file close
1 parent 977d131 commit 55121c5

File tree

3 files changed

+127
-3
lines changed

3 files changed

+127
-3
lines changed

file/rotator.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package file
2020
import (
2121
"errors"
2222
"fmt"
23+
"io/fs"
2324
"os"
2425
"path/filepath"
2526
"sort"
@@ -245,13 +246,27 @@ func (r *Rotator) openNew() error {
245246
return fmt.Errorf("failed to make directories for new file: %w", err)
246247
}
247248

248-
_, err = os.Stat(r.rot.ActiveFile())
249+
stat, err := os.Lstat(r.rot.ActiveFile())
249250
if err == nil {
251+
isSymlink := stat.Mode()&fs.ModeSymlink != 0
252+
250253
// check if the file has to be rotated before writing to it
251254
reason, t := r.isRotationTriggered(0)
252255
if reason == rotateReasonNoRotate {
253-
return r.appendToFile()
256+
// To avoid symlink following attacks, if the active file is a symlink
257+
// we need to rotate it to avoid writing to the symlink target, which
258+
// could be a sensitive or protected file not owned by us.
259+
if isSymlink {
260+
if r.log != nil {
261+
r.log.Debugw("Active file is a symlink, forcing rotation", "filename", r.rot.ActiveFile())
262+
}
263+
reason = rotateReasonInitializing
264+
t = r.clock.Now()
265+
} else {
266+
return r.appendToFile()
267+
}
254268
}
269+
255270
if err = r.rot.Rotate(reason, t); err != nil {
256271
return fmt.Errorf("failed to rotate backups: %w", err)
257272
}
@@ -284,7 +299,7 @@ func (r *Rotator) openFile() error {
284299
return fmt.Errorf("failed to make directories for new file: %w", err)
285300
}
286301

287-
r.file, err = os.OpenFile(r.rot.ActiveFile(), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, r.permissions)
302+
r.file, err = os.OpenFile(r.rot.ActiveFile(), os.O_EXCL|os.O_CREATE|os.O_WRONLY|os.O_TRUNC, r.permissions)
288303
if err != nil {
289304
return fmt.Errorf("failed to open new file '%s': %w", r.rot.ActiveFile(), err)
290305
}

file/rotator_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,49 @@ func TestRotate(t *testing.T) {
255255
AssertDirContents(t, dir, secondFile, thirdFile)
256256
}
257257

258+
func TestRotateSymlink(t *testing.T) {
259+
dir := t.TempDir()
260+
261+
logname := "beatname"
262+
filename := filepath.Join(dir, logname)
263+
264+
c := &testClock{time.Date(2021, 11, 11, 0, 0, 0, 0, time.Local)}
265+
266+
privateFileContents := []byte("original contents")
267+
privateFile := filepath.Join(dir, "private")
268+
err := os.WriteFile(privateFile, privateFileContents, 0644)
269+
require.NoError(t, err)
270+
271+
// Plant a symlink to the private file by guessing the log filename.
272+
guessedFilename := filepath.Join(dir, fmt.Sprintf("%s-%s.ndjson", logname, c.Now().Format(file.DateFormat)))
273+
err = os.Symlink(privateFile, guessedFilename)
274+
require.NoError(t, err)
275+
276+
logger, buf := logp.NewInMemory("rotator", logp.ConsoleEncoderConfig())
277+
278+
r, err := file.NewFileRotator(filename,
279+
file.MaxBackups(1),
280+
file.WithClock(c),
281+
file.RotateOnStartup(false),
282+
file.WithLogger(logger),
283+
)
284+
if err != nil {
285+
t.Fatal(err)
286+
}
287+
defer r.Close()
288+
289+
WriteMsg(t, r)
290+
291+
// The file rotation should have detected the destination is a symlink and rotated before writing.
292+
rotatedFilename := filepath.Join(dir, fmt.Sprintf("%s-%s-1.ndjson", logname, c.Now().Format(file.DateFormat)))
293+
AssertDirContents(t, dir, filepath.Base(privateFile), filepath.Base(guessedFilename), filepath.Base(rotatedFilename))
294+
require.Contains(t, buf.String(), "Active file is a symlink, forcing rotation")
295+
296+
got, err := os.ReadFile(privateFile)
297+
require.NoError(t, err)
298+
assert.Equal(t, privateFileContents, got, "The symlink target should not have been modified")
299+
}
300+
258301
func TestRotateExtension(t *testing.T) {
259302
dir := t.TempDir()
260303

@@ -312,6 +355,7 @@ func AssertDirContents(t *testing.T, dir string, files ...string) {
312355
if err != nil {
313356
t.Fatal(err)
314357
}
358+
defer f.Close()
315359

316360
names, err := f.Readdirnames(-1)
317361
if err != nil {

logp/defaults_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ import (
3232
"time"
3333

3434
"github.com/stretchr/testify/assert"
35+
"github.com/stretchr/testify/require"
3536
"go.uber.org/zap/zapcore"
3637

38+
"github.com/elastic/elastic-agent-libs/file"
3739
"github.com/elastic/elastic-agent-libs/logp"
3840
)
3941

@@ -437,3 +439,66 @@ func takeAllLogsFromPath(t *testing.T, path string) []map[string]any {
437439

438440
return entries
439441
}
442+
443+
func TestLoggerRotateSymlink(t *testing.T) {
444+
dir := t.TempDir()
445+
446+
cfg := logp.DefaultConfig(logp.DefaultEnvironment)
447+
cfg.Beat = "logger"
448+
cfg.ToFiles = true
449+
cfg.Files.Path = dir
450+
cfg.Files.MaxBackups = 1
451+
cfg.Files.RotateOnStartup = false
452+
453+
logname := cfg.Beat
454+
455+
privateFileContents := []byte("original contents")
456+
privateFile := filepath.Join(dir, "private")
457+
err := os.WriteFile(privateFile, privateFileContents, 0644)
458+
require.NoError(t, err)
459+
460+
// Plant a symlink to the private file by guessing the log filename.
461+
guessedFilename := filepath.Join(dir, fmt.Sprintf("%s-%s.ndjson", logname, time.Now().Format(file.DateFormat)))
462+
err = os.Symlink(privateFile, guessedFilename)
463+
require.NoError(t, err)
464+
465+
err = logp.Configure(cfg)
466+
require.NoError(t, err)
467+
468+
logLine := "a info message"
469+
logp.L().Info(logLine)
470+
471+
// The file rotation should have detected the destination is a symlink and rotated before writing.
472+
rotatedFilename := filepath.Join(dir, fmt.Sprintf("%s-%s-1.ndjson", logname, time.Now().Format(file.DateFormat)))
473+
assertDirContents(t, dir, filepath.Base(privateFile), filepath.Base(guessedFilename), filepath.Base(rotatedFilename))
474+
475+
got, err := os.ReadFile(privateFile)
476+
require.NoError(t, err)
477+
assert.Equal(t, privateFileContents, got, "The symlink target should not have been modified")
478+
479+
got, err = os.ReadFile(rotatedFilename)
480+
require.NoError(t, err)
481+
assert.Contains(t, string(got), logLine, "The rotated file should contain the log message")
482+
483+
assert.NoError(t, logp.L().Close())
484+
485+
// Error: TempDir RemoveAll cleanup: remove t.TempDir() The process cannot access the file because it is being used by another process.
486+
require.NoError(t, os.RemoveAll(dir))
487+
}
488+
489+
func assertDirContents(t *testing.T, dir string, files ...string) {
490+
t.Helper()
491+
492+
f, err := os.Open(dir)
493+
if err != nil {
494+
t.Fatal(err)
495+
}
496+
defer f.Close()
497+
498+
names, err := f.Readdirnames(-1)
499+
if err != nil {
500+
t.Fatal(err)
501+
}
502+
503+
assert.ElementsMatch(t, files, names)
504+
}

0 commit comments

Comments
 (0)