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

@ -8,10 +8,10 @@ import (
"encoding/hex"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"sort"
"net/url"
"strings"
"sync"
"sync/atomic"
@ -874,6 +874,14 @@ func (c *Core) UploadAdapter(name, code string) error {
if name == "" {
return fmt.Errorf("adapter name required")
}
// Validate name SAFETY before touching the filesystem (R6). A path-traversal
// name ("../openai") must be rejected BEFORE WriteFile, or it would already
// have escaped the adapter dir by the time LoadAdapter re-checks. Built-in
// names are allowed here: an admin uploading openai.lua is the documented
// override, same as placing the file in adapter_dir by hand.
if err := lua.ValidateAdapterName(name, nil); err != nil {
return err
}
if err := os.MkdirAll(c.cfg.AdapterDir, 0755); err != nil {
return err
}
@ -885,6 +893,13 @@ func (c *Core) UploadAdapter(name, code string) error {
}
func (c *Core) RemoveAdapter(name string) error {
// Validate the name so a traversal name cannot os.Remove a file outside the
// adapter dir. A reserved (built-in) name is a legitimate delete target in
// the running VM, so reserved-name rejection is NOT applied here — only the
// path-safety character set is.
if err := lua.ValidateAdapterName(name, nil); err != nil {
return err
}
path := filepath.Join(c.cfg.AdapterDir, name+".lua")
_ = os.Remove(path)
c.vm.RemoveAdapter(name)

View File

@ -0,0 +1,133 @@
// 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

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)
}
}

View File

@ -729,19 +729,39 @@ func (v *VM) ListAdapters() []APIAdapter {
}
// LoadAdapter compiles and registers an adapter script from a file.
//
// The path is validated for SAFETY before reading (R6): it must sit inside the
// VM's adapter dir and the derived name must be a legal adapter name (no path
// traversal, absolute paths, or whitespace). It does NOT reject built-in names
// here: an operator placing openai.lua in adapter_dir to OVERRIDE the bundled
// openai is the documented hot-override mechanism. Only PLUGIN installs are
// barred from shadowing a built-in (see InstallPluginAdapter), because a
// third-party plugin clobbering the kernel's shipped adapter is tampering,
// whereas the operator editing their own deployment is not.
func (v *VM) LoadAdapter(path string) error {
name, err := ValidateAdapterPath(path, v.dir, nil)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read adapter: %w", err)
}
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
return v.LoadAdapterSource(name, string(data))
}
// LoadAdapterSource registers an adapter from source code. Replacing an
// existing adapter shuts the old pool down and re-boots lazily, carrying the
// configured ceiling over so a hot-reload does not reset elastic sizing.
//
// The name is validated for path SAFETY only (no traversal/absolute/space).
// Built-in names are allowed: an operator override is legitimate. A plugin
// that must NOT shadow a built-in uses InstallPluginAdapter, which adds the
// reserved-name gate.
func (v *VM) LoadAdapterSource(name, code string) error {
if err := ValidateAdapterName(name, nil); err != nil {
return err
}
static, err := inspectScript(name, code)
if err != nil {
return err
@ -801,6 +821,31 @@ func (v *VM) pool(name string) *adapterPool {
return v.pools[name]
}
// bundledNames returns the set of adapter names that ship inside the kernel.
// It is derived from the embedded FS (same source writeBundledAdapters uses),
// so a new built-in adapter is automatically reserved and an operator cannot
// clobber it by importing a same-named external adapter.
// BundledAdapterNames is the exported counterpart of bundledNames for callers
// outside the lua package (core.UploadAdapter validates against it). It returns
// the set of adapter names shipped inside the kernel so an external import that
// shadows one is rejected up front (R6).
func (v *VM) BundledAdapterNames() []string { return v.bundledNames() }
func (v *VM) bundledNames() []string {
entries, err := bundledAdapters.ReadDir("adapters")
if err != nil {
return nil
}
out := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
out = append(out, strings.TrimSuffix(e.Name(), ".lua"))
}
return out
}
func (v *VM) writeBundledAdapters() error {
// Derive the set from the embedded FS instead of a hardcoded name list so
// adding an adapter never requires maintaining a second list.
@ -1380,3 +1425,22 @@ func (v *VM) AdapterRevision(name string) uint64 {
defer p.mu.Unlock()
return p.revision
}
// InstallPluginAdapter registers an adapter ON BEHALF OF A PLUGIN, with the
// one extra rule plugins (and only plugins) are held to: the name must not
// shadow a built-in adapter.
//
// Why plugins are stricter than operators: an operator dropping openai.lua in
// adapter_dir is overriding their own deployment (documented, intended). A
// third-party plugin registering an adapter named "openai" would silently
// swap out the kernel's shipped protocol translation for code the operator
// never reviewed — tampering with the shipped artifact. R6 requires that to
// fail loudly with the conflict named, not succeed quietly.
//
// This is the entry point Phase 3's plugin adapter-install bridge calls.
func (v *VM) InstallPluginAdapter(name, code string) error {
if err := ValidateAdapterName(name, v.bundledNames()); err != nil {
return err
}
return v.LoadAdapterSource(name, code)
}