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 ./... 全绿。
This commit is contained in:
JianFeeeee
2026-10-11 09:23:34 +08:00
parent 9d6653a573
commit 958f1f48b5
4 changed files with 411 additions and 2 deletions

View File

@ -0,0 +1,197 @@
package lua
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// 本文件锁定 R6:导入适配器时检查路径冲突,避免静默错误。
//
// 覆盖三类注入:
// 1. 名字字符集(路径穿越 / 绝对路径 / 空白)→ 拒绝
// 2. 保留名冲突(内置适配器)→ 拒绝
// 3. 合法名 → 通过(且确实可加载)
func TestValidateAdapterNameRejectsPathTraversal(t *testing.T) {
for _, name := range []string{
"../openai", // 目录逃逸
"..", // 保留段
".", // 保留段
"/etc/passwd", // 绝对路径
"a/b/c", // 斜杠
"a\\b", // 反斜杠
"has space", // 空白
"a b c", // 空格
"", // 空
} {
if err := ValidateAdapterName(name, nil); err == nil {
t.Errorf("名字 %q 应被拒绝(路径不安全),但通过了", name)
}
}
}
func TestValidateAdapterNameRejectsReserved(t *testing.T) {
reserved := []string{"openai", "anthropic", "gemini", "Ollama"}
for _, name := range []string{"openai", "anthropic", "gemini", "ollama"} {
if err := ValidateAdapterName(name, reserved); err == nil {
t.Errorf("名字 %q 与内置适配器冲突,应被拒绝", name)
}
}
}
func TestValidateAdapterNameAcceptsLegal(t *testing.T) {
for _, name := range []string{
"my_adapter",
"my-adapter",
"custom123",
"a",
"opencode", // 不是保留名(本次已从内置移除)
} {
if err := ValidateAdapterName(name, []string{"openai", "anthropic"}); err != nil {
t.Errorf("合法名字 %q 被误拒: %v", name, err)
}
}
}
// TestValidateAdapterPathRejectsOutsideDir 验证从磁盘加载路径时,
// 目录外的文件被拒绝。
func TestValidateAdapterPathRejectsOutsideDir(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(dir, "..", "evil.lua")
if _, err := ValidateAdapterPath(outside, dir, nil); err == nil {
t.Errorf("目录外的路径 %q 应被拒绝", outside)
}
}
// TestLoadAdapterSourceAllowsBuiltinOverride 验证运维覆盖内置适配器是允许的
// (文档化的 adapter_dir 热覆盖机制)——仅插件被禁止影子内置。
func TestLoadAdapterSourceAllowsBuiltinOverride(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
code := `local a={} a.name="openai" a.version="override" a.endpoint="/x" return a`
if err := vm.LoadAdapterSource("openai", code); err != nil {
t.Errorf("运维覆盖内置 openai 应被允许(热覆盖机制),却被拒: %v", err)
}
}
// TestInstallPluginAdapterRejectsBuiltin 验证**插件**装内置名被拒(防篡改)。
func TestInstallPluginAdapterRejectsBuiltin(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
code := `local a={} a.name="openai" a.version="evil" a.endpoint="/x" return a`
if err := vm.InstallPluginAdapter("openai", code); err == nil {
t.Error("插件装与内置同名的适配器应被拒(防篡改发行物)")
}
// 但插件装非内置名应成功
ok := `local a={} a.name="myplugin_adapter" a.version="1" a.endpoint="/x" return a`
if err := vm.InstallPluginAdapter("myplugin_adapter", ok); err != nil {
t.Errorf("插件装非保留名应成功: %v", err)
}
}
func TestLoadAdapterSourceRejectsReserved(t *testing.T) {
t.Skip("已改:LoadAdapterSource 允许运维覆盖内置;保留名禁止已移至 InstallPluginAdapter")
}
// TestLoadAdapterSourceRejectsTraversal 验证加载入口拒绝路径穿越名。
func TestLoadAdapterSourceRejectsTraversal(t *testing.T) {
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
code := `local a={} a.name="x" a.version="1" a.endpoint="/x" return a`
if err := vm.LoadAdapterSource("../evil", code); err == nil {
t.Error("路径穿越名 ../evil 应被拒绝")
}
}
// TestUploadAdapterPathTraversalDoesNotEscape 是端到端安全测试:
// 模拟管理员通过 API 上传一个恶意名,验证它**不会**写到 adapter_dir 之外。
func TestUploadAdapterPathTraversalDoesNotEscape(t *testing.T) {
// 用一个真实 Core 来测 UploadAdapter,但 core 需要完整配置——这里直接
// 测 UploadAdapter 的校验层(名字在写文件之前就被拒)。
// 由于 UploadAdapter 在 core 包,我们在 lua 层能测的等价面是
// ValidateAdapterName + LoadAdapterSource,上面已覆盖。
// 这里补一个"确无副作用"断言:遍历名不得创建任何文件。
dir := filepath.Join(t.TempDir(), "adapters")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := ValidateAdapterName("../openai", nil); err == nil {
t.Fatal("前提不成立")
}
// 证明没有 ../openai.lua 被创建
if _, err := os.Stat(filepath.Join(dir, "..", "openai.lua")); !os.IsNotExist(err) {
t.Error("校验前就写出了逃逸文件 —— 写入顺序错误")
}
}
// TestLoadAdapterValidatesTheDiskPath 验证从磁盘加载(Start 的路径)对路径
// 做安全校验:目录外的文件被拒。(目录内同名内置的文件是合法覆盖,
// 不在此拒绝——见 TestLoadAdapterSourceAllowsBuiltinOverride。)
func TestLoadAdapterValidatesTheDiskPath(t *testing.T) {
dir := filepath.Join(t.TempDir(), "adapters")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
// 目录外的文件:应被拒
outside := filepath.Join(dir, "..", "evil.lua")
if err := os.WriteFile(outside, []byte(`local a={} a.name="evil" a.version="1" a.endpoint="/x" return a`), 0o644); err != nil {
t.Fatal(err)
}
vm := NewVM(dir)
if err := vm.LoadAdapter(outside); err == nil {
t.Error("目录外的磁盘文件应被拒绝")
}
}
// TestValidateAdapterPathInDirAccepted 验证目录内的合法文件通过。
func TestValidateAdapterPathInDirAccepted(t *testing.T) {
dir := filepath.Join(t.TempDir(), "adapters")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
ok := filepath.Join(dir, "my_new.lua")
if err := os.WriteFile(ok, []byte(`local a={} a.name="my_new" a.version="1" a.endpoint="/x" return a`), 0o644); err != nil {
t.Fatal(err)
}
name, err := ValidateAdapterPath(ok, dir, []string{"openai"})
if err != nil {
t.Fatalf("合法文件被误拒: %v", err)
}
if name != "my_new" {
t.Errorf("导出名 = %q, want my_new", name)
}
}
// TestErrorNamesTheConflict 验证错误信息点名冲突对象(用户要求"避免静默
// 错误"——错误必须可定位)。
func TestErrorNamesTheConflict(t *testing.T) {
err := ValidateAdapterName("openai", []string{"openai"})
if err == nil {
t.Fatal("应拒绝")
}
var reserved *ErrAdapterNameReserved
if !errors.As(err, &reserved) {
t.Fatalf("错误类型应为 ErrAdapterNameReserved, got %T", err)
}
if !strings.Contains(err.Error(), "openai") {
t.Errorf("错误信息应点名冲突对象: %v", err)
}
if !strings.Contains(err.Error(), "built-in") {
t.Errorf("错误信息应说明冲突性质: %v", err)
}
}