mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户要求:导入适配器时必须检查路径冲突,避免静默错误。 之前 UploadAdapter(name, code) 直接拼 adapter_dir/<name>.lua: - name 含 "../" 或绝对路径会写到目录外 - name 与内置同名会静默覆盖,加载失败只报 "adapter X missing", 现场与病因分离 实现(internal/lua/adapter_validate.go): - ValidateAdapterName:字符集 ^[a-zA-Z0-9_-]+$,拒绝 /、\、..、绝对路径、空白 - ValidateAdapterPath:文件必须在 adapter_dir 内 - VM.InstallPluginAdapter:插件装内置名 → ErrAdapterNameReserved 拒 - Core.UploadAdapter:在 WriteFile 之前校验(防路径逃逸) - Core.RemoveAdapter:也校验名字(防 os.Remove 越界) 设计取舍:保留名禁止只对插件生效,不对运维生效。 - operator 放 openai.lua 覆盖内置 = 文档化的热覆盖机制,放行 - 第三方插件静默换掉内核自带的协议翻译 = 篡改发行物,拒绝 - 先误做成了全拒,Start() 加载内置适配器直接崩,测试抓住后改对 (TestLoadBundledAdapters 等锁住回归) 测试 12 个:路径穿越/绝对路径/保留段/空白/内置覆盖(允许 vs 插件拒绝)/ 目录外加载/错误点名/热重载不回归。 验证:go test -tags luajit ./... 全绿。
134 lines
5.0 KiB
Go
134 lines
5.0 KiB
Go
// Package lua — 适配器导入安全校验(R6)
|
||
//
|
||
// 用户要求(feature/unified-wire-v2):导入适配器时必须检查路径冲突,
|
||
// 避免静默错误。
|
||
//
|
||
// 之前的风险:
|
||
//
|
||
// UploadAdapter(name, code) 直接拼 adapter_dir/<name>.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/<name>.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
|