Files
nomad/helper/escapingfs/escapes_test.go
Tim Gross 0d3cd1427f migration: check symlink sources during archive unpack
During allocation directory migration, the client was not checking that any
symlinks in the archive aren't pointing to somewhere outside the allocation
directory. While task driver sandboxing will protect against processes inside
the task from reading/writing thru the symlink, this doesn't protect against the
client itself from performing unintended operations outside the sandbox.

This changeset includes two changes:

* Update the archive unpacking to check the source of symlinks and require that
  they fall within the sandbox.
* Fix a bug in the symlink check where it was using `filepath.Rel` which doesn't
  work for paths in the sibling directories of the sandbox directory. This bug
  doesn't appear to be exploitable but caused errors in testing.

Fixes: https://github.com/hashicorp/nomad/issues/19887
2024-02-08 10:40:24 -05:00

285 lines
6.6 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package escapingfs
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/shoenig/test/must"
"github.com/stretchr/testify/require"
)
func write(t *testing.T, file, data string) {
err := os.WriteFile(file, []byte(data), 0600)
require.NoError(t, err)
}
func Test_PathEscapesAllocViaRelative(t *testing.T) {
for _, test := range []struct {
prefix string
path string
exp bool
}{
// directly under alloc-dir/alloc-id/
{prefix: "", path: "", exp: false},
{prefix: "", path: "/foo", exp: false},
{prefix: "", path: "./", exp: false},
{prefix: "", path: "../", exp: true}, // at alloc-id/
// under alloc-dir/alloc-id/<foo>/
{prefix: "foo", path: "", exp: false},
{prefix: "foo", path: "/foo", exp: false},
{prefix: "foo", path: "../", exp: false}, // at foo/
{prefix: "foo", path: "../../", exp: true}, // at alloc-id/
// under alloc-dir/alloc-id/foo/bar/
{prefix: "foo/bar", path: "", exp: false},
{prefix: "foo/bar", path: "/foo", exp: false},
{prefix: "foo/bar", path: "../", exp: false}, // at bar/
{prefix: "foo/bar", path: "../../", exp: false}, // at foo/
{prefix: "foo/bar", path: "../../../", exp: true}, // at alloc-id/
} {
result, err := PathEscapesAllocViaRelative(test.prefix, test.path)
require.NoError(t, err)
require.Equal(t, test.exp, result)
}
}
func Test_pathEscapesBaseViaSymlink(t *testing.T) {
t.Run("symlink-escape", func(t *testing.T) {
dir := t.TempDir()
// link from dir/link
link := filepath.Join(dir, "link")
// link to /tmp
target := filepath.Clean("/tmp")
err := os.Symlink(target, link)
require.NoError(t, err)
escape, err := pathEscapesBaseViaSymlink(dir, link)
require.NoError(t, err)
require.True(t, escape)
})
t.Run("symlink-noescape", func(t *testing.T) {
dir := t.TempDir()
// create a file within dir
target := filepath.Join(dir, "foo")
write(t, target, "hi")
// link to file within dir
link := filepath.Join(dir, "link")
err := os.Symlink(target, link)
require.NoError(t, err)
// link to file within dir does not escape dir
escape, err := pathEscapesBaseViaSymlink(dir, link)
require.NoError(t, err)
require.False(t, escape)
})
}
func Test_PathEscapesAllocDir(t *testing.T) {
t.Run("no-escape-root", func(t *testing.T) {
dir := t.TempDir()
escape, err := PathEscapesAllocDir(dir, "", "/")
require.NoError(t, err)
require.False(t, escape)
})
t.Run("no-escape", func(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "foo"), "hi")
escape, err := PathEscapesAllocDir(dir, "", "/foo")
require.NoError(t, err)
require.False(t, escape)
})
t.Run("no-escape-no-exist", func(t *testing.T) {
dir := t.TempDir()
escape, err := PathEscapesAllocDir(dir, "", "/no-exist")
require.NoError(t, err)
require.False(t, escape)
})
t.Run("symlink-escape", func(t *testing.T) {
dir := t.TempDir()
// link from dir/link
link := filepath.Join(dir, "link")
// link to /tmp
target := filepath.Clean("/tmp")
err := os.Symlink(target, link)
require.NoError(t, err)
escape, err := PathEscapesAllocDir(dir, "", "/link")
require.NoError(t, err)
require.True(t, escape)
})
t.Run("relative-escape", func(t *testing.T) {
dir := t.TempDir()
escape, err := PathEscapesAllocDir(dir, "", "../../foo")
require.NoError(t, err)
require.True(t, escape)
})
t.Run("relative-escape-prefix", func(t *testing.T) {
dir := t.TempDir()
escape, err := PathEscapesAllocDir(dir, "/foo/bar", "../../../foo")
require.NoError(t, err)
require.True(t, escape)
})
}
func TestPathEscapesSandbox(t *testing.T) {
cases := []struct {
name string
path string
dir string
expected bool
}{
{
// this is the ${NOMAD_SECRETS_DIR} case
name: "ok joined absolute path inside sandbox",
path: filepath.Join("/alloc", "/secrets"),
dir: "/alloc",
expected: false,
},
{
name: "fail unjoined absolute path outside sandbox",
path: "/secrets",
dir: "/alloc",
expected: true,
},
{
name: "ok joined relative path inside sandbox",
path: filepath.Join("/alloc", "./safe"),
dir: "/alloc",
expected: false,
},
{
name: "fail unjoined relative path outside sandbox",
path: "./safe",
dir: "/alloc",
expected: true,
},
{
name: "ok relative path traversal constrained to sandbox",
path: filepath.Join("/alloc", "../../alloc/safe"),
dir: "/alloc",
expected: false,
},
{
name: "ok unjoined absolute path traversal constrained to sandbox",
path: filepath.Join("/alloc", "/../alloc/safe"),
dir: "/alloc",
expected: false,
},
{
name: "ok unjoined absolute path traversal constrained to sandbox",
path: "/../alloc/safe",
dir: "/alloc",
expected: false,
},
{
name: "fail joined relative path traverses outside sandbox",
path: filepath.Join("/alloc", "../../../unsafe"),
dir: "/alloc",
expected: true,
},
{
name: "fail unjoined relative path traverses outside sandbox",
path: "../../../unsafe",
dir: "/alloc",
expected: true,
},
{
name: "fail joined absolute path tries to transverse outside sandbox",
path: filepath.Join("/alloc", "/alloc/../../unsafe"),
dir: "/alloc",
expected: true,
},
{
name: "fail unjoined absolute path tries to transverse outside sandbox",
path: "/alloc/../../unsafe",
dir: "/alloc",
expected: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caseMsg := fmt.Sprintf("path: %v\ndir: %v", tc.path, tc.dir)
escapes := PathEscapesSandbox(tc.dir, tc.path)
require.Equal(t, tc.expected, escapes, caseMsg)
})
}
}
func TestHasPrefixCaseInsensitive(t *testing.T) {
cases := []struct {
name string
path string
prefix string
expected bool
}{
{
name: "has prefix",
path: "/foo/bar",
prefix: "/foo",
expected: true,
},
{
name: "has prefix different case",
path: "/FOO/bar",
prefix: "/foo",
expected: true,
},
{
name: "short path",
path: "/foo",
prefix: "/foo/bar",
expected: false,
},
{
name: "exact match",
path: "/foo",
prefix: "/foo",
expected: true,
},
{
name: "no prefix",
path: "/baz/bar",
prefix: "/foo",
expected: false,
},
{
name: "no prefix different case",
path: "/BAZ/bar",
prefix: "/foo",
expected: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := hasPrefixCaseInsensitive(tc.path, tc.prefix)
must.Eq(t, tc.expected, got)
})
}
}