mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 02:01:44 +08:00
156 lines
5.8 KiB
Go
156 lines
5.8 KiB
Go
package utils
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"SamWaf/global"
|
|
)
|
|
|
|
// 批量任务「本地路径」来源的路径收口。
|
|
|
|
// batchImportDefaultSubDir 内置默认导入目录(相对程序目录),恒允许。
|
|
const batchImportDefaultSubDir = "data/import"
|
|
|
|
// batchImportBaseDir 取 SamWaf 程序所在目录,单测里可替换。
|
|
var batchImportBaseDir = GetCurrentDir
|
|
|
|
// BatchImportDefaultDir 返回内置默认导入目录的绝对路径,供报错文案与前端提示使用。
|
|
func BatchImportDefaultDir() string {
|
|
if base := batchImportBaseDir(); base != "" {
|
|
if absBase, err := filepath.Abs(base); err == nil {
|
|
return filepath.Clean(filepath.Join(absBase, batchImportDefaultSubDir))
|
|
}
|
|
}
|
|
return batchImportDefaultSubDir
|
|
}
|
|
|
|
// batchImportAllowedRoots 返回允许的导入根目录(已 Clean 的绝对路径):
|
|
// 内置默认 <程序目录>/data/import(恒允许)+ config.yml security.batch_import_allowed_dirs。
|
|
func batchImportAllowedRoots() []string {
|
|
roots := make([]string, 0, 4)
|
|
if d := BatchImportDefaultDir(); d != "" && filepath.IsAbs(d) {
|
|
roots = append(roots, d)
|
|
}
|
|
for _, d := range strings.Split(global.GCONFIG_BATCH_IMPORT_ALLOWED_DIRS, ",") {
|
|
d = strings.TrimSpace(d)
|
|
if d == "" {
|
|
continue
|
|
}
|
|
cleaned := filepath.Clean(d)
|
|
if !filepath.IsAbs(cleaned) {
|
|
continue // 只接受绝对目录,相对目录基准不可控,忽略
|
|
}
|
|
roots = append(roots, cleaned)
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// underAnyRoot 判断路径是否落在任一根目录之下(等于根目录本身不算,来源必须是文件)。
|
|
func underAnyRoot(roots []string, p string) bool {
|
|
for _, root := range roots {
|
|
if pathUnder(root, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveBatchLocalPath 只做「与文件系统状态无关」的路径判定:规范化 + 允许目录前缀锚定。
|
|
// 相对路径按内置默认导入目录解析(用户只填文件名即可),随后与绝对路径走同一套判定。
|
|
// `..` 在 filepath.Clean 阶段就已被折叠,所以 data/import/../../etc/passwd 会落在根目录之外被拒。
|
|
func resolveBatchLocalPath(raw string) (string, error) {
|
|
p := strings.TrimSpace(raw)
|
|
if p == "" {
|
|
return "", fmt.Errorf("来源路径为空")
|
|
}
|
|
if strings.ContainsAny(p, "\r\n\x00") {
|
|
return "", fmt.Errorf("来源路径包含非法字符(换行或空字符)")
|
|
}
|
|
// 相对路径统一挂到内置默认目录下再判定,避免基准目录取决于进程工作目录
|
|
if !filepath.IsAbs(filepath.Clean(p)) {
|
|
base := BatchImportDefaultDir()
|
|
if base == "" || !filepath.IsAbs(base) {
|
|
return "", fmt.Errorf("无法确定内置导入目录,请填写绝对路径")
|
|
}
|
|
p = filepath.Join(base, p)
|
|
}
|
|
|
|
cleaned, err := cleanAbsFilePath(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !underAnyRoot(batchImportAllowedRoots(), cleaned) {
|
|
return "", fmt.Errorf("来源路径不在允许目录内(请把文件放进内置导入目录 %s,该目录不存在时请手工创建;或在 config.yml 的 security.batch_import_allowed_dirs 声明目标目录): %s",
|
|
BatchImportDefaultDir(), cleaned)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
// checkBatchLocalSymlink 软链接逃逸判定:字面路径在允许目录内,不代表真正读到的文件也在。
|
|
// EvalSymlinks 会一路解开中间目录与终点的软链接,拿到真实落点后再判一次。
|
|
// 返回解析后的真实路径。
|
|
func checkBatchLocalSymlink(cleaned string) (string, error) {
|
|
realPath, err := filepath.EvalSymlinks(cleaned)
|
|
if err != nil {
|
|
return "", fmt.Errorf("来源文件不存在或不可访问: %s, %v", cleaned, err)
|
|
}
|
|
realPath = filepath.Clean(realPath)
|
|
roots := batchImportAllowedRoots()
|
|
realRoots := make([]string, 0, len(roots))
|
|
for _, root := range roots {
|
|
if rr, e := filepath.EvalSymlinks(root); e == nil {
|
|
realRoots = append(realRoots, filepath.Clean(rr))
|
|
} else {
|
|
realRoots = append(realRoots, root) // 目录尚未创建时按字面根比对
|
|
}
|
|
}
|
|
if !underAnyRoot(realRoots, realPath) {
|
|
return "", fmt.Errorf("来源路径经软链接指向了允许目录之外,已拒绝: %s", cleaned)
|
|
}
|
|
return realPath, nil
|
|
}
|
|
|
|
// PrecheckBatchLocalPath 保存批量任务时的路径预检:只判路径策略,不要求文件已存在
|
|
// (用户完全可能先建任务、再把文件放进去)。文件已存在时顺带查一次软链接逃逸,
|
|
// 让明显配错的情况在保存阶段就报出来。真正的读取前判定见 ValidateBatchLocalPath。
|
|
func PrecheckBatchLocalPath(raw string) error {
|
|
cleaned, err := resolveBatchLocalPath(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, statErr := os.Lstat(cleaned); statErr != nil {
|
|
return nil // 文件还没放上来,保存阶段不拦
|
|
}
|
|
_, err = checkBatchLocalSymlink(cleaned)
|
|
return err
|
|
}
|
|
|
|
// ValidateBatchLocalPath 校验并规范化批量任务的本地来源路径,返回可直接打开的绝对路径。
|
|
// 执行(读取)前调用,是真正的安全边界:
|
|
// 1. 基本合法性(非空、无控制字符、不以分隔符结尾、指向具体文件);
|
|
// 2. Clean 后必须落在允许根目录之下(前缀锚定);
|
|
// 3. 解析软链接后的**真实**路径同样必须落在允许根目录之下——只判字面路径的话,
|
|
// 在允许目录里放一个指向 /etc/shadow 的软链接就能整个绕过;
|
|
// 4. 目标必须是普通文件:目录读不出内容,FIFO/设备会把批量任务永久挂住。
|
|
func ValidateBatchLocalPath(raw string) (string, error) {
|
|
cleaned, err := resolveBatchLocalPath(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
realPath, err := checkBatchLocalSymlink(cleaned)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
fi, err := os.Stat(realPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("来源文件不可访问: %s, %v", cleaned, err)
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
return "", fmt.Errorf("来源必须是普通文件(目录/管道/设备不允许): %s", cleaned)
|
|
}
|
|
return realPath, nil
|
|
}
|