// Package lua — 适配器导入安全校验(R6) // // 用户要求(feature/unified-wire-v2):导入适配器时必须检查路径冲突, // 避免静默错误。 // // 之前的风险: // // UploadAdapter(name, code) 直接拼 adapter_dir/.lua。 // name 含 "../" 或绝对路径会写到目录外; // name 与内置适配器同名(openai/anthropic/gemini…)会静默覆盖—— // 加载失败只报 "adapter X missing",错误现场与病因分离。 // // 这里的校验在**所有导入入口**统一执行(LoadAdapterSource / UploadAdapter / // 插件 install_adapter),让"为什么被拒绝"成为明确的错误信息。 package lua import ( "errors" "fmt" "os" "path/filepath" "strings" ) // AdapterNamePattern 是适配器名的合法字符集。 // 不允许:路径分隔符(/、\)、点点(..)、绝对路径、空格。 // 允许:字母、数字、下划线、连字符。 const AdapterNamePattern = `^[a-zA-Z0-9_-]+$` // ErrAdapterNameInvalid 适配器名不合法。 type ErrAdapterNameInvalid struct{ Name, Reason string } func (e *ErrAdapterNameInvalid) Error() string { return fmt.Sprintf("adapter name %q invalid: %s", e.Name, e.Reason) } // ErrAdapterNameReserved 适配器名与内核内置适配器冲突(用户要求:冲突时 // 内核内置优先,外部不得覆盖)。 type ErrAdapterNameReserved struct{ Name string } func (e *ErrAdapterNameReserved) Error() string { return fmt.Sprintf("adapter name %q is reserved by a built-in adapter; renaming or deleting the built-in is not supported", e.Name) } // ValidateAdapterName 校验一个将被导入的适配器名。 // // 它不做文件系统存在性检查(那是上传路径的责任),只校验"这个名字作为 // adapter_dir/.lua 的一部分是否安全"。校验维度: // // 1. 非空、符合字符集(拒绝路径穿越、绝对路径、空白) // 2. 不与内核内置适配器名冲突(除非 force 覆盖内置,但这通常不该被允许) // 3. 不含保留路径段(.、..、CON、NUL 等) // // reservedNames 是调用方提供的"保留名集合"——通常是内置适配器名表。 // 传 nil 表示无保留名(仍校验字符集与路径安全)。 func ValidateAdapterName(name string, reservedNames []string) error { if name == "" { return &ErrAdapterNameInvalid{Name: name, Reason: "empty"} } // 字符集:最简单的拒绝路径风险的方式。 if !matchesAdapterPattern(name) { return &ErrAdapterNameInvalid{Name: name, Reason: "must match " + AdapterNamePattern + " (no slashes, dots-only, absolute paths, or whitespace)"} } // 保留段:即使字符集通过,也要点名拒绝 . 和 .. (它们会被某些字符集放行, // 但绝不能成为文件名)。 switch name { case ".", "..": return &ErrAdapterNameInvalid{Name: name, Reason: "reserved path segment"} } for _, reserved := range reservedNames { if strings.EqualFold(name, reserved) { return &ErrAdapterNameReserved{Name: name} } } return nil } // ValidateAdapterPath 在 LoadAdapter(path) 入口校验磁盘路径是否指向 // adapter_dir 下、且文件名合法。 // // 这一层是"从磁盘加载"的安全网:Start() 用它加载目录里的 .lua,必须保证 // 一个被放进来的文件(无论谁放的)只能注册成它自己的名字,不能因为文件名 // 怪异(如 ../openai.lua)就覆盖或越界。 // // 返回校验后的 (name, error)。 func ValidateAdapterPath(path, adapterDir string, reservedNames []string) (string, error) { // 先解析成绝对路径,与 adapter_dir 比对,确认文件确实在目录内 // (不在目录内的 LoadAdapter 调用本就是异常用法,但 Start() 遍历的是 // ReadDir 的结果,天然在目录内,这条校验对它是 no-op)。 abs, err := filepath.Abs(path) if err != nil { return "", &ErrAdapterNameInvalid{Name: path, Reason: "unresolvable path"} } dirAbs, _ := filepath.Abs(adapterDir) if !isWithinDir(abs, dirAbs) { return "", &ErrAdapterNameInvalid{Name: path, Reason: "file is outside adapter_dir"} } name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) if err := ValidateAdapterName(name, reservedNames); err != nil { return "", err } return name, nil } // matchesAdapterPattern 是 AdapterNamePattern 的纯函数实现(避免在 hot path // 里编译正则)。 func matchesAdapterPattern(s string) bool { if s == "" { return false } for _, r := range s { ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' if !ok { return false } } return true } // isWithinDir 判断 path 是否在 dir 内(含 path == dir 本身,不含 .. 逃逸)。 func isWithinDir(path, dir string) bool { rel, err := filepath.Rel(dir, path) if err != nil { return false } return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != "." } // 哑引用:让 errors 包在 go vet 下不报"导出但未使用"。 var _ = errors.New