Files
larksuite-cli/internal/binding/audit.go
evandance 62ff3d66a6 fix(bind): accept ~/ paths in OpenClaw secret references (#839)
OpenClaw stores secret file paths in user-authored ~/-relative form so
the configuration stays portable across machines. lark-cli config bind
previously rejected these as non-absolute, blocking users until they
rewrote the OpenClaw config with literal absolute paths.

bind now resolves ~ to the OpenClaw home directory (OPENCLAW_HOME if
set, otherwise the OS home) before the path audit runs, mirroring how
OpenClaw itself reads the same field. Cwd-relative paths and other
unsafe locations are still rejected as before.
2026-05-13 12:34:43 +08:00

162 lines
5.2 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
// AuditParams holds parameters for AssertSecurePath.
type AuditParams struct {
TargetPath string
Label string // e.g. "secrets.providers.vault.command"
TrustedDirs []string
AllowInsecurePath bool
AllowReadableByOthers bool
AllowSymlinkPath bool
}
// AssertSecurePath verifies that a file/command path is safe for use with
// OpenClaw SecretRef resolution. On success it returns the effective path
// (the symlink target, if the input was a symlink and allowed).
//
// The check is a short, ordered pipeline — each step below is both a read of
// the contract and a pointer to the helper that enforces it.
func AssertSecurePath(params AuditParams) (string, error) {
target := params.TargetPath
label := params.Label
if err := requireAbsolutePath(target, label); err != nil {
return "", err
}
linfo, err := lstatNonDir(target, label)
if err != nil {
return "", err
}
effectivePath, err := resolveSymlinkIfAllowed(target, linfo, params)
if err != nil {
return "", err
}
if err := requireInTrustedDirs(effectivePath, params.TrustedDirs, label); err != nil {
return "", err
}
if params.AllowInsecurePath {
return effectivePath, nil
}
if err := auditFilePermissions(effectivePath, params.AllowReadableByOthers, label); err != nil {
return "", err
}
if err := checkOwnerUID(effectivePath, label); err != nil {
return "", err
}
return effectivePath, nil
}
// requireAbsolutePath rejects relative paths; relative paths would depend on
// the process cwd and defeat the point of a static audit. Shell-style
// shortcuts like `~` are home-relative, not cwd-relative — they are an
// orthogonal concern and the audit is intentionally Go-stdlib strict here.
// Callers that accept user-authored config (e.g. resolveFileRef) must
// pre-resolve any such shortcuts before passing the path in.
func requireAbsolutePath(target, label string) error {
if !filepath.IsAbs(target) {
return fmt.Errorf("%s: path must be absolute, got %q", label, target)
}
return nil
}
// lstatNonDir stats the path without following symlinks, rejecting
// directories. Returns the stat info for downstream steps to reuse.
func lstatNonDir(target, label string) (fs.FileInfo, error) {
info, err := vfs.Lstat(target)
if err != nil {
return nil, fmt.Errorf("%s: cannot stat %q: %w", label, target, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%s: path %q is a directory, not a file", label, target)
}
return info, nil
}
// resolveSymlinkIfAllowed resolves a symlink to its target when
// params.AllowSymlinkPath is true, or rejects it otherwise. When the input
// is not a symlink, target is returned unchanged. A symlink that points to
// another symlink is rejected so callers only deal with a single hop.
func resolveSymlinkIfAllowed(target string, linfo fs.FileInfo, params AuditParams) (string, error) {
if linfo.Mode()&os.ModeSymlink == 0 {
return target, nil
}
if !params.AllowSymlinkPath {
return "", fmt.Errorf("%s: path %q is a symlink (not allowed)", params.Label, target)
}
resolved, err := vfs.EvalSymlinks(target)
if err != nil {
return "", fmt.Errorf("%s: cannot resolve symlink %q: %w", params.Label, target, err)
}
rinfo, err := vfs.Lstat(resolved)
if err != nil {
return "", fmt.Errorf("%s: cannot stat resolved path %q: %w", params.Label, resolved, err)
}
if rinfo.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("%s: resolved path %q is still a symlink", params.Label, resolved)
}
return resolved, nil
}
// requireInTrustedDirs enforces that effectivePath lives under one of the
// caller-declared trusted directories, if any were declared. An empty
// trustedDirs list disables the check.
func requireInTrustedDirs(effectivePath string, trustedDirs []string, label string) error {
if len(trustedDirs) == 0 {
return nil
}
cleaned := filepath.Clean(effectivePath)
for _, dir := range trustedDirs {
cleanDir := filepath.Clean(dir)
if cleaned == cleanDir || strings.HasPrefix(cleaned, cleanDir+"/") {
return nil
}
}
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}