我这个项目叫 OpenLink。它干的事很土:让浏览器里的 Gemini / AI Studio 能读写你本机的文件、跑命令。
网页里的 AI 没有手。它只能吐出文本。所以中间必须有一个本地进程去接活。这个进程写成 Go,一共两千多行,其中真正决定"安不安全"的只有三个文件:cmd/server/main.go、internal/security/auth.go、internal/security/sandbox.go。这篇讲这三个文件里我实际怎么写的,包括写错的地方。
一、先别监听 0.0.0.0
很多本地工具的第一行是 http.ListenAndServe(":8080", mux)。冒号前面空着,等于监听所有网卡。如果你的笔记本连了咖啡店 WiFi、公司内网、宿舍路由器,这个服务对同网段所有人都是开着的。
我一开始就避开了这个坑,因为我要接的是浏览器扩展,扩展只可能从本机发起请求。所以:
go
func (s *Server) Run() error {
return s.router.Run(fmt.Sprintf("127.0.0.1:%d", s.config.Port))
}
硬编码 127.0.0.1,没有开关。issue 列表里有一条(#4)要求支持 open-webui,那个是自建服务端的玩法,我一直没做。不是我懒,是这个功能的收益(多一个接入端)跟风险(把"能在你电脑上跑命令的服务"往外面挪)不对等。
端口默认 39527。选它是因为够怪,不容易撞上别人。
二、token 怎么发、怎么存
每次启动,服务需要确认"对面这个请求来自我自己的扩展",而不是某个网页里偷偷 fetch('http://127.0.0.1:39527/exec') 的脚本。
做法是启动时生成一个长期 token:
go
b := make([]byte, 32)
rand.Read(b)
token := hex.EncodeToString(b) // 64 个字符
写到 ~/.openlink/settings.json,文件权限 0600,目录 0700。第二次启动直接读回来,不重新生成------不然每次重启都要重新在扩展里贴一遍,没人受得了。
生成完,终端会打印一行:
ini
认证 URL: http://127.0.0.1:39527/auth?token=xxxxx...
把这一整串粘进扩展的「API 地址」输入框。扩展会自己在后面拼 /exec、/prompt。
这里我偷了个懒,得说清楚:token 放在 URL 查询串里,会被浏览器历史、终端 scrollback、可能的代理日志记下来。更干净的做法是扩展里单独有个 token 输入框。我当时图省事,把两个输入框合成了一个。这属于"能用但不体面",知道就行,别学。
三、鉴权中间件:长度先比,再 ConstantTimeCompare
go
if c.Request.URL.Path == "/health" || c.Request.URL.Path == "/auth" {
c.Next()
return
}
auth := c.GetHeader("Authorization")
expected := "Bearer " + token
if len(auth) != len(expected) || subtle.ConstantTimeCompare([]byte(auth), []byte(expected)) != 1 {
c.JSON(401, gin.H{"error": "unauthorized"})
c.Abort()
return
}
c.Next()
只有 /health 和 /auth 放行。前者给扩展做连通性探测,后者用来校验 token 本身------它也得公开,因为扩展第一次连上来时手里只有一个 token,还不知道对不对。
其余全要 Authorization: Bearer <token>。
为什么先比长度?subtle.ConstantTimeCompare 遇到长度不同的输入会直接返回 0,但我不想让"长度不同"和"内容不同"在耗时上有区别。这一步实际意义不大(差几十纳秒,隔着网络根本测不出来),属于心理安慰。真正有用的是那个 len() 检查顺手挡掉了空 header 的情况。
== 换掉是因为我某天翻代码时想到:token 比较用普通字符串比较,看着就不专业。于是改了。
四、CORS 这里我确实留了个口子
中间件里我写的是:
go
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
通配。而且我没有校验 Origin 头。
理论上,任何网页都能向 http://127.0.0.1:39527 发一个跨域请求。实际能不能造成伤害,取决于它能不能拿到 token。拿不到 token,/exec 一律 401。所以安全边界整个压在"64 字符随机串不泄露"这一件事上。
这不算一个合格的答案。正确做法是两道闸:Origin 白名单(只允许 gemini.google.com、aistudio.google.com 加扩展自己的 origin),加上 token。两道都要过。
我没改的原因很实:扩展的请求是从 background service worker 发的,fetch 出去时 Origin 是扩展的 chrome-extension://...,改完之后我得先确认这个 origin 在 manifest 里的 host_permissions 生效后长什么样,再测一遍。这个测试我还没做,就不先动代码了。写在这里当待办。
顺带说一句,manifest.json 里我把 host 权限收得很紧:
json
"host_permissions": ["http://127.0.0.1:*/*"]
扩展只能碰本机,碰不到任何真实网站。这条比 CORS 那个通配更实在。
五、沙箱:为什么必须 EvalSymlinks
所有文件工具的路径都要过一遍 SafePath:
go
absRoot, _ := filepath.EvalSymlinks(rootDir)
joined := filepath.Join(absRoot, targetPath)
absTarget, err := filepath.EvalSymlinks(joined)
if err != nil {
absTarget, _ = filepath.Abs(joined) // 文件还不存在,新建场景
}
if !strings.HasPrefix(absTarget, absRoot+string(filepath.Separator)) && absTarget != absRoot {
return "", errors.New("path outside sandbox")
}
关键在 EvalSymlinks。如果只做 filepath.Join + 前缀比较,工作目录下有个指向 / 的软链(ln -s / root),AI 写 root/etc/hosts 就能出去。加入软链解析后,.. 和软链一起被拍平,再比前缀。
targetPath 在文档里要求是相对路径。filepath.Join 会漏一道:如果 AI 传了绝对路径 /etc/passwd,Join(workdir, "/etc/passwd") 得到的是 workdir/etc/passwd,会被当成工作目录里的相对文件------不会越界,但会读到奇怪的路径并返回"文件不存在"。这个行为我认了,比报错回去让模型反复试要好。
那个 filepath.Abs fallback 有个时间窗:检查的时候文件不存在,检查和写入之间 AI 那边可能有个软链被创建。要在真机上制造这个竞态,得让模型精确地在两个工具调用之间塞一个 ln -s,概率低。但它是存在的。我记着,没修。
六、危险命令拦截:两张表,和它拦不住的东西
exec_cmd 执行前先过 IsDangerousCommand。里面是两张表:
go
var dangerousPatterns = []string{
"rm -rf", "rm -fr", "> /dev/", "chmod 777", "kill -9",
}
var dangerousCommands = []string{
"mkfs", "format", "nc", "netcat",
"sudo", "reboot", "shutdown",
}
分两张的原因是匹配方式不同。
第一张走子串匹配。rm -rf 这种带空格和连字符的组合,正常路径里几乎不会出现,子串匹配误伤概率极低。
第二张走单词边界。nc 只有两个字母,子串匹配会把 sync、fnc 这类词一起拦下来。git log 里那个 915de04 fix: 修复 skills 加载和危险命令误判两个 bug,改的就是这件事:从子串匹配换成单词边界(前后必须是空格、;、|、&、引号、尖括号或字符串边界)。
curl 和 wget 一开始也在这张表里。后来专门发了一个 commit fix(security): 从危险命令列表移除 curl/wget 把它们删了。原因很朴素:这两个是正常网络工具,AI 帮你 curl 一个接口文档是合理需求,拦掉之后它只会绕路(go run 一个临时程序去下载),拦了个寂寞。
它拦不住什么
我不打算把这段写成"安全"的样子。IsDangerousCommand 是个黑名单,黑名单天生可以被绕过。随便举几个现在就能过:
rm -r -f dir:两个参数分开写,不含rm -rf子串find . -name '*.log' -deletepython -c "import shutil; shutil.rmtree('.')"s""udo reboot:引号在lower之后才被切分,sudo前后不满足边界
所以真正的边界不是这张表,是另外两件事:执行时的 proc.Dir 固定在工作目录 ,以及有人在旁边看。工具调用会在页面里渲染成一张卡片,参数摊开写着,你看着它跑。
我下一步想改的方向是把黑名单换成"命令解析 + 允许列表":解析出 argv0,只放过 go、git、npm、ls、cat 这类明确认识的,其余的默认拒绝并要求授权。这个改动会明显降低可用性(很多人喜欢拿它跑 ffmpeg、pandoc),所以在纠结。
七、顺手做掉的三件小事
输出截断。 exec_cmd 的 CombinedOutput 可能是几万行。上限设成 2000 行 / 50KB,超了就整份写进 ~/.openlink/tool-output/<UnixNano>,返回前 2000 行加一句"完整内容保存至 xxx,用 read_file 加 offset 分段读取"。模型看到这句话会自己去翻页,比直接把上下文顶爆强。
web_fetch 的 SSRF 防护。 这一条是后来补的:
go
private := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"127.0.0.0/8", "169.254.0.0/16", "::1/128", "fc00::/7"}
先 net.LookupHost 把域名解成 IP,命中内网段直接拒绝。不加的话,AI 一句 web_fetch http://192.168.1.1/ 就能把你家路由器后台拉回来。
超时。 默认 60 秒,exec.CommandContext 到点杀进程,返回 execution timeout。这个数字对 Go 项目偏紧------第一次跑 go test ./... 要现编译依赖,还没到单测就已经四十多秒。可以在启动时 -timeout 180 改。我现在觉得更好的默认值是一百二,改默认值要动所有人已有的行为,先放着。
八、还剩什么没做
三件,按顺序:
- 加 Origin 校验,把 CORS 的通配收掉(要先测清扩展的 origin 到底是啥)
- 把黑名单往命令解析方向改,至少拦住
rm -r -f这种换写法的绕过 - 修一个路径不一致的小 bug:
/prompt读prompts/init_prompt.txt,而每 20 次调用重注入提示词时读的是RootDir/init_prompt.txt。后者在多数目录结构下根本不存在,等于重注入静默失效了
代码在这里:github.com/afumu/openl...
项目定位我写在 README 第一行:学习研究用,不适合生产。上面这些缺口就是"不适合"的一部分。