WIP
This commit is contained in:
@@ -12,11 +12,13 @@
|
|||||||
//
|
//
|
||||||
// When invoked as zfs or zpool, the binary validates that all commands
|
// When invoked as zfs or zpool, the binary validates that all commands
|
||||||
// reference the test pool "zreplplatformtest" before delegating through
|
// reference the test pool "zreplplatformtest" before delegating through
|
||||||
// sudo. Safety checks include:
|
// sudo. The validation rule is simple: the pool name must appear in the
|
||||||
// - Pool name validation on all non-flag arguments
|
// command args. Special cases:
|
||||||
// - Blocking -a (all) flag without explicit pool reference
|
// - Resume tokens ("zfs send -t <token>"): decoded via the real binary
|
||||||
// - Command-aware flag parsing (-t is arg-taking for zfs, boolean for zpool)
|
// to extract and verify the toname field
|
||||||
// - Mountpoint path traversal protection on post-create chmod
|
// - The -a (all) flag is blocked without an explicit pool reference
|
||||||
|
// - Bare subcommands (feature detection) are allowed
|
||||||
|
// - Mountpoint paths are validated against traversal on post-create chmod
|
||||||
//
|
//
|
||||||
// With -no-interposer, zfs/zpool symlink directly to the real binaries
|
// With -no-interposer, zfs/zpool symlink directly to the real binaries
|
||||||
// (no sudo, no validation). Use this when running as root.
|
// (no sudo, no validation). Use this when running as root.
|
||||||
@@ -33,18 +35,15 @@ import (
|
|||||||
// SetupInterposerPath creates a temp directory with zfs/zpool symlinks
|
// SetupInterposerPath creates a temp directory with zfs/zpool symlinks
|
||||||
// and replaces PATH so that all zfs/zpool invocations go through it.
|
// and replaces PATH so that all zfs/zpool invocations go through it.
|
||||||
//
|
//
|
||||||
// When direct is false (normal mode), zfs/zpool symlink to the current
|
// If `explicitDir` is non-empty, the PATH is constructed in that directory
|
||||||
// executable (interposer mode: validates commands and delegates via sudo).
|
// instead of a temp directory, and the caller is responsible for cleanup.
|
||||||
// Additional .real symlinks point to the actual binaries.
|
|
||||||
//
|
//
|
||||||
// When direct is true, zfs/zpool symlink directly to the real binaries
|
// `direct` controls whether to set up the interposer (false) or to symlink
|
||||||
// (no interposer, no sudo). Use this when running as root.
|
// zfs/zpool directly to the real binaries (true, no interposer, no sudo).
|
||||||
//
|
//
|
||||||
// Returns the directory path and a cleanup function.
|
// Returns the directory path and a cleanup function.
|
||||||
func SetupInterposerPath(explicitDir string, direct bool) (string, func(), error) {
|
func SetupInterposerPath(explicitDir string, direct bool) (string, func(), error) {
|
||||||
// Resolve real binary paths while original PATH is still intact.
|
// Resolve real binary paths while original PATH is still intact.
|
||||||
// In direct mode we only need zfs/zpool and bash; in interposer
|
|
||||||
// mode we also need sudo.
|
|
||||||
lookupNames := []string{"zfs", "zpool", "bash"}
|
lookupNames := []string{"zfs", "zpool", "bash"}
|
||||||
if !direct {
|
if !direct {
|
||||||
lookupNames = append(lookupNames, "sudo")
|
lookupNames = append(lookupNames, "sudo")
|
||||||
@@ -166,18 +165,10 @@ func RunInterposer(command string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validatePoolName(command string, args []string, poolName string) error {
|
func validatePoolName(command string, args []string, poolName string) error {
|
||||||
// Allow subcommands that don't reference a specific dataset,
|
|
||||||
// used for feature detection (e.g., "zfs send" with no dataset,
|
|
||||||
// "zfs receive" with no dataset, "zpool get").
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some subcommands are inherently safe / don't target a pool.
|
|
||||||
// Allow them unconditionally. The real safety net is that we
|
|
||||||
// only have sudo for zfs/zpool, not for anything destructive
|
|
||||||
// outside the test pool.
|
|
||||||
//
|
|
||||||
// Check if any non-flag argument references the pool name.
|
// Check if any non-flag argument references the pool name.
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if strings.HasPrefix(arg, "-") {
|
if strings.HasPrefix(arg, "-") {
|
||||||
@@ -188,57 +179,35 @@ func validatePoolName(command string, args []string, poolName string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block commands with -a (all) flag when no pool name was found.
|
// Special case: "zfs send -t <token>" (or -nvt, etc.) — the pool
|
||||||
// These operate on ALL pools/filesystems (e.g., zfs unmount -a,
|
// name is encoded in the resume token, not visible in the args.
|
||||||
// zpool export -a) and must not be allowed without a pool reference.
|
// Decode the token via the real binary and verify toname.
|
||||||
|
if command == "zfs" {
|
||||||
|
if token := extractResumeToken(args); token != "" {
|
||||||
|
return validateResumeTokenPool(token, poolName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block -a (all) flag without pool reference. Commands like
|
||||||
|
// "zfs unmount -a" or "zpool export -a" affect all pools.
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if !strings.HasPrefix(arg, "-") {
|
if !strings.HasPrefix(arg, "-") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// -a standalone or in combined flags (e.g., -fa, -ra)
|
|
||||||
if arg == "-a" || (len(arg) > 2 && arg[0] == '-' && arg[1] != '-' &&
|
if arg == "-a" || (len(arg) > 2 && arg[0] == '-' && arg[1] != '-' &&
|
||||||
strings.ContainsRune(arg[1:], 'a')) {
|
strings.ContainsRune(arg[1:], 'a')) {
|
||||||
return fmt.Errorf("%s command rejected: -a flag not allowed without explicit pool reference", command)
|
return fmt.Errorf("%s command rejected: -a flag not allowed without explicit pool reference", command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case: bare subcommand with only flags (feature detection).
|
// If only one non-flag arg is present (the subcommand itself),
|
||||||
// Count non-flag args. If there's only one (the subcommand itself),
|
// this is feature detection (e.g., bare "zfs send", "zfs receive").
|
||||||
// allow it.
|
|
||||||
//
|
|
||||||
// Note: -s is intentionally NOT in the skip list. It is boolean in
|
|
||||||
// some subcommands (e.g., zfs recv -s) and argument-taking in others
|
|
||||||
// (e.g., zfs list -s property). Not skipping its "value" is safe:
|
|
||||||
// commands with the pool name are already handled by the loop above,
|
|
||||||
// and for commands without a pool name, over-counting non-flag args
|
|
||||||
// causes a safe rejection rather than a dangerous acceptance.
|
|
||||||
nonFlagArgs := 0
|
nonFlagArgs := 0
|
||||||
skipNext := false
|
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if skipNext {
|
if !strings.HasPrefix(arg, "-") {
|
||||||
skipNext = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Flags that take a value argument: -o, -O always; -t for zfs only.
|
|
||||||
// -t is arg-taking in zfs (list -t type, send -t token) but
|
|
||||||
// boolean in zpool (import -t = temporary, events -t = timestamps).
|
|
||||||
argTakingChars := "oO"
|
|
||||||
if command == "zfs" {
|
|
||||||
argTakingChars = "oOt"
|
|
||||||
}
|
|
||||||
if arg == "-o" || arg == "-O" || (command == "zfs" && arg == "-t") ||
|
|
||||||
(len(arg) > 2 && arg[0] == '-' && arg[1] != '-' &&
|
|
||||||
strings.ContainsAny(arg, argTakingChars)) {
|
|
||||||
skipNext = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(arg, "-") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nonFlagArgs++
|
nonFlagArgs++
|
||||||
}
|
}
|
||||||
// If only the subcommand is present (e.g., "send", "receive", "get"),
|
}
|
||||||
// this is likely feature detection.
|
|
||||||
if nonFlagArgs <= 1 {
|
if nonFlagArgs <= 1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -246,6 +215,56 @@ func validatePoolName(command string, args []string, poolName string) error {
|
|||||||
return fmt.Errorf("%s command rejected: args %v do not reference pool %q", command, args, poolName)
|
return fmt.Errorf("%s command rejected: args %v do not reference pool %q", command, args, poolName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractResumeToken returns the resume token from "zfs send -t <token>"
|
||||||
|
// or combined forms like "zfs send -nvt <token>". Returns "" if not found.
|
||||||
|
func extractResumeToken(args []string) string {
|
||||||
|
if len(args) == 0 || args[0] != "send" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i, arg := range args {
|
||||||
|
if !strings.HasPrefix(arg, "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// -t standalone, or combined flag containing t (e.g., -nvt)
|
||||||
|
if arg == "-t" || (len(arg) > 2 && arg[0] == '-' && arg[1] != '-' &&
|
||||||
|
strings.ContainsRune(arg[1:], 't')) {
|
||||||
|
if i+1 < len(args) {
|
||||||
|
return args[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateResumeTokenPool decodes a resume token by calling the real zfs
|
||||||
|
// binary directly (bypassing the interposer) and checks that the toname
|
||||||
|
// field references the expected pool.
|
||||||
|
func validateResumeTokenPool(token, poolName string) error {
|
||||||
|
realBinary := filepath.Join(os.Getenv("PATH"), "zfs.real")
|
||||||
|
cmd := exec.Command("sudo", realBinary, "send", "-nvt", token)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// zfs send -nvt may exit non-zero if the dataset no longer exists,
|
||||||
|
// but it still prints the token contents. Only fail on exec errors.
|
||||||
|
if _, ok := err.(*exec.ExitError); !ok {
|
||||||
|
return fmt.Errorf("decode resume token: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "toname = ") {
|
||||||
|
toname := strings.TrimPrefix(line, "toname = ")
|
||||||
|
if toname == poolName || strings.HasPrefix(toname, poolName+"/") || strings.HasPrefix(toname, poolName+"@") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("resume token rejected: toname %q does not reference pool %q", toname, poolName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("resume token rejected: could not extract toname from token")
|
||||||
|
}
|
||||||
|
|
||||||
func isCreateOperation(args []string) bool {
|
func isCreateOperation(args []string) bool {
|
||||||
return len(args) > 0 && args[0] == "create"
|
return len(args) > 0 && args[0] == "create"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user