优化小程序部分

This commit is contained in:
hero920103 committed 2026-10-31 19:17:37 +08:00
1 parent 727319a6eb
commit d4e89788c5
46 files changed
+4906 -63

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
// Package verifyfiles 解析「根路径站点验证文件」的请求。
//
// 背景:nginx 对所有域名都是「全量反代给 Go」,Go 不认识的路径一律回 beego 404,
// 于是备案 / 搜索引擎 / 第三方平台的域名所有权验证文件
// (如 1XXl8fPHr9.txt、baidu_verify_xxx.html)根本访问不到。
// 而这类文件名每次都不同(随机串),不可能逐个注册路由。
//
// 这里只做纯逻辑:URL 路径 → 磁盘文件 的安全映射。
// beego 过滤器胶水放在 routers 包(见 routers/verifyfiles.go)。
//
// 安全性:
// - 只认「根路径下的单段文件名」,不做目录拼接,杜绝 ../ 穿越;
// - 文件名走白名单正则,扩展名走允许列表(不放行 .go/.conf/.env 等敏感后缀);
// - 目标文件必须真实存在于验证目录内,否则一律放行给正常路由。
package verifyfiles
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
const (
// DefaultDir 默认验证文件目录(相对进程工作目录,即服务目录)。
DefaultDir = "verify"
// DefaultExts 默认允许的扩展名。
DefaultExts = ".txt,.html,.htm"
)
// fileNameRe 文件名白名单:字母或数字开头,只允许字母数字与 . _ -
var fileNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// Config 验证文件托管配置。
type Config struct {
// Dir 验证文件目录。留空用 DefaultDir;填 "-" 表示关闭该功能。
Dir string
// Exts 允许的扩展名,逗号分隔(如 ".txt,.html")。留空用 DefaultExts。
Exts string
// Hosts 限定生效的 Host(不含端口),逗号分隔。留空表示不限制。
Hosts string
}
// Resolver 把 URL 路径解析成验证文件的磁盘路径。
type Resolver struct {
dir string
exts map[string]bool
hosts map[string]bool
}
// New 按配置创建 Resolver。
// Dir 为 "-" 时表示关闭该功能,返回 (nil, nil)。
func New(cfg Config) (*Resolver, error) {
dir := strings.TrimSpace(cfg.Dir)
if dir == "-" {
return nil, nil
}
if dir == "" {
dir = DefaultDir
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("验证文件目录 %q 解析失败: %w", dir, err)
}
r := &Resolver{
dir: abs,
exts: map[string]bool{},
hosts: map[string]bool{},
}
for _, e := range strings.Split(strings.TrimSpace(cfg.Exts), ",") {
e = strings.ToLower(strings.TrimSpace(e))
if e == "" {
continue
}
if !strings.HasPrefix(e, ".") {
e = "." + e
}
r.exts[e] = true
}
if len(r.exts) == 0 {
for _, e := range strings.Split(DefaultExts, ",") {
r.exts[strings.ToLower(strings.TrimSpace(e))] = true
}
}
for _, h := range strings.Split(strings.TrimSpace(cfg.Hosts), ",") {
h = normalizeHost(h)
if h == "" {
continue
}
r.hosts[h] = true
}
return r, nil
}
// Dir 返回验证文件目录的绝对路径。
func (r *Resolver) Dir() string {
if r == nil {
return ""
}
return r.dir
}
// Exts 返回允许的扩展名列表(用于启动日志,便于确认配置是否生效)。
func (r *Resolver) Exts() []string {
if r == nil {
return nil
}
out := make([]string, 0, len(r.exts))
for e := range r.exts {
out = append(out, e)
}
return out
}
// AllowHost 判断该 Host 是否允许取验证文件(未配置 hosts 时一律允许)。
func (r *Resolver) AllowHost(host string) bool {
if r == nil {
return false
}
if len(r.hosts) == 0 {
return true
}
_, ok := r.hosts[normalizeHost(host)]
return ok
}
// Resolve 把请求路径解析成磁盘文件路径。
// 只接受根路径下的单段文件名(如 /1XXl8fPHr9.txt);文件必须真实存在。
func (r *Resolver) Resolve(urlPath string) (string, bool) {
if r == nil {
return "", false
}
name, ok := r.fileName(urlPath)
if !ok {
return "", false
}
full := filepath.Join(r.dir, name)
// 兜底:拼出来的路径必须仍在验证目录内
if !strings.HasPrefix(full, r.dir+string(os.PathSeparator)) {
return "", false
}
f, err := os.Stat(full)
if err != nil || f.IsDir() {
return "", false
}
return full, true
}
// fileName 从 URL 路径里取出合法的验证文件名。
func (r *Resolver) fileName(urlPath string) (string, bool) {
p := strings.TrimSpace(urlPath)
if p == "" || p == "/" {
return "", false
}
name := strings.TrimPrefix(p, "/")
if name == "" {
return "", false
}
// 只允许根目录下的单段文件名,禁止子目录
if strings.ContainsAny(name, "/\\") {
return "", false
}
// 禁止路径穿越
if strings.Contains(name, "..") {
return "", false
}
if !fileNameRe.MatchString(name) {
return "", false
}
// index.html 会被 http.ServeFile 重定向到 "./",排除
if strings.EqualFold(name, "index.html") {
return "", false
}
ext := strings.ToLower(filepath.Ext(name))
if !r.exts[ext] {
return "", false
}
return name, true
}
// normalizeHost 去掉端口并转小写。
func normalizeHost(h string) string {
h = strings.ToLower(strings.TrimSpace(h))
if i := strings.IndexByte(h, ':'); i > 0 {
h = h[:i]
}
return h
}
+124
View File
@@ -0,0 +1,124 @@
package verifyfiles
import (
"os"
"path/filepath"
"testing"
)
// newTestResolver 造一个临时验证目录,返回 resolver。
func newTestResolver(t *testing.T, cfg Config) *Resolver {
t.Helper()
r, err := New(cfg)
if err != nil {
t.Fatalf("创建 Resolver 失败: %v", err)
}
if r == nil {
t.Fatal("Resolver 不应为 nil")
}
return r
}
// TestResolveAllowed 确认合法验证文件能被解析到磁盘路径。
func TestResolveAllowed(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "1XXl8fPHr9.txt"), []byte("1XXl8fPHr9"), 0o644); err != nil {
t.Fatalf("准备测试文件失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "baidu_verify_codexxx.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("准备测试文件失败: %v", err)
}
r := newTestResolver(t, Config{Dir: dir})
for _, name := range []string{"1XXl8fPHr9.txt", "baidu_verify_codexxx.html"} {
got, ok := r.Resolve("/" + name)
if !ok {
t.Fatalf("合法验证文件 /%s 未被解析出来", name)
}
want := filepath.Join(dir, name)
if got != want {
t.Fatalf("路径不对,期望 %s,实际 %s", want, got)
}
}
}
// TestResolveRejected 确认非法/敏感/不存在的路径一律不放行。
func TestResolveRejected(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"secret.env", "notes.md", "index.html", "app.conf"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatalf("准备测试文件失败: %v", err)
}
}
if err := os.Mkdir(filepath.Join(dir, "sub.txt"), 0o755); err != nil {
t.Fatalf("准备测试目录失败: %v", err)
}
r := newTestResolver(t, Config{Dir: dir})
cases := []string{
"/",
"",
"/secret.env", // 敏感后缀不在白名单
"/notes.md", // 后缀不在白名单
"/app.conf", // 配置文件不得外泄
"/index.html", // 会被 ServeFile 重定向
"/sub/dir.txt", // 只允许根路径单段文件名
"/../secret.env", // 路径穿越
"/1XXl8fPHr9.txt/../..", // 路径穿越
"/missing.txt", // 文件不存在
"/sub.txt", // 目录不是文件
}
for _, c := range cases {
if got, ok := r.Resolve(c); ok {
t.Fatalf("路径 %q 不应被放行,实际解析为 %s", c, got)
}
}
}
// TestAllowHost 确认域名白名单按「忽略端口 + 忽略大小写」匹配。
func TestAllowHost(t *testing.T) {
r := newTestResolver(t, Config{Dir: t.TempDir(), Hosts: "api.yunzer.cn"})
if !r.AllowHost("api.yunzer.cn") {
t.Fatal("api.yunzer.cn 应被允许")
}
if !r.AllowHost("API.Yunzer.cn:443") {
t.Fatal("带端口与大写的 api.yunzer.cn 应被允许")
}
if r.AllowHost("other.com") {
t.Fatal("未在白名单里的域名不应被允许")
}
// 未配置 hosts 时,所有走本服务的域名都生效
all := newTestResolver(t, Config{Dir: t.TempDir()})
if !all.AllowHost("api.yunzer.cn") || !all.AllowHost("dh2.fun") {
t.Fatal("未配置 verify_hosts 时应允许所有域名")
}
}
// TestNewDisabled 确认 dir = "-" 表示关闭该功能。
func TestNewDisabled(t *testing.T) {
r, err := New(Config{Dir: "-"})
if err != nil {
t.Fatalf("不应报错: %v", err)
}
if r != nil {
t.Fatal("dir = \"-\" 时 Resolver 应为 nil")
}
}
// TestDefaultExts 确认默认扩展名包含 .txt。
func TestDefaultExts(t *testing.T) {
r := newTestResolver(t, Config{Dir: t.TempDir()})
found := false
for _, e := range r.Exts() {
if e == ".txt" {
found = true
}
}
if !found {
t.Fatalf("默认扩展名应包含 .txt,实际 %v", r.Exts())
}
}