Files
ModelRouter/internal/lua/adapter_validate.go
JianFeeeee 958f1f48b5 feat(lua): 适配器导入路径冲突校验(R6)——防静默覆盖与路径逃逸
用户要求:导入适配器时必须检查路径冲突,避免静默错误。

之前 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 ./... 全绿。
2026-10-11 09:23:34 +08:00

134 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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