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