From 76c2a6028c522e00c9bf9111b2eb051b206a546a Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 10 Apr 2026 18:29:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(handler,config):=20=E6=94=AF=E6=8C=81=20tr?= =?UTF-8?q?y=5Ffiles=20=E5=8A=A8=E6=80=81=E5=90=8E=E7=BC=80=20($uri.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 $uri. 模式解析(如 $uri.html, $uri.json) - 添加扩展名安全验证:白名单字符、危险后缀黑名单 - 根路径边界处理:避免生成 "/.html" 隐藏文件名 - 增强配置文档注释,说明 nginx 兼容性和安全限制 - 完整单元测试覆盖 Co-Authored-By: Claude Opus 4.6 --- internal/config/config.go | 26 ++++- internal/config/validate.go | 157 ++++++++++++++++++++++++++++ internal/config/validate_test.go | 172 +++++++++++++++++++++++++++++++ internal/handler/static.go | 27 ++++- internal/handler/static_test.go | 136 ++++++++++++++++++++++++ 5 files changed, 514 insertions(+), 4 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5262a01..460cffb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -317,8 +317,30 @@ type StaticConfig struct { Index []string `yaml:"index"` // TryFiles 按顺序尝试查找的文件列表 - // 支持 $uri 和 $uri/ 占位符,用于 SPA 部署 - // 示例: ["$uri", "$uri/", "/index.html"] + // 支持以下模式: + // - $uri: 请求路径 + // - $uri/: 请求路径加斜杠(目录) + // - $uri.: 请求路径加扩展名(如 $uri.html, $uri.json) + // - /path: 绝对路径回退(如 /index.html) + // - filename: 相对路径回退(如 fallback.html) + // + // nginx 兼容性: + // - $uri 变量语义与 nginx try_files 指令一致 + // - 配置语法可从 nginx 直接迁移 + // + // 安全限制(附加于 nginx 基础): + // - 扩展名仅允许字母、数字、点、下划线、连字符 + // - 禁止危险后缀(.php, .exe, .bat 等) + // - 禁止 null byte 和路径分隔符 + // + // 根路径边界情况: + // - 当 relPath="/" 且模式为 "$uri." 时,返回空字符串 + // - 此设计避免生成 "/.html" 这样的隐藏文件名 + // - 建议使用绝对路径回退(如 /index.html)处理根路径 + // + // 示例: + // try_files: ["$uri", "$uri.html", "/index.html"] + // try_files: ["$uri", "$uri/", "/app.html"] TryFiles []string `yaml:"try_files"` // TryFilesPass 内部重定向是否触发中间件 diff --git a/internal/config/validate.go b/internal/config/validate.go index 30253d5..6a3b454 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -129,10 +129,167 @@ func validateStatics(statics []StaticConfig) error { if s.Root != "" && strings.Contains(s.Root, "..") { return fmt.Errorf("static[%d]: 根目录路径不能包含 '..'", i) } + + // 验证 try_files 模式 + for j, pattern := range s.TryFiles { + if err := validateTryFilesPattern(pattern); err != nil { + return fmt.Errorf("static[%d].try_files[%d]: %w", i, j, err) + } + } } return nil } +// validateTryFilesPattern 验证 try_files 模式的安全性。 +// +// 检查 try_files 配置项是否包含安全风险的模式。 +// 支持的模式格式: +// - $uri - 请求路径 +// - $uri/ - 请求路径加斜杠 +// - $uri. - 请求路径加扩展名(如 $uri.html) +// - /path - 绝对路径回退 +// - filename - 相对路径回退 +// +// 验证规则: +// - 拒绝 null byte(\x00) +// - 拒绝路径分隔符(/ 和 \)在扩展名中 +// - 扩展名仅允许字母、数字、点、下划线、连字符 +// - 拒绝危险后缀(.php, .exe, .bat, .sh, .cgi 等) +// +// 参数: +// - pattern: try_files 配置项 +// +// 返回值: +// - error: 验证失败时返回具体错误信息 +func validateTryFilesPattern(pattern string) error { + if pattern == "" { + return errors.New("try_files 模式不能为空") + } + + // 检查 null byte + if strings.Contains(pattern, "\x00") { + return errors.New("try_files 模式不能包含 null byte") + } + + // 定义支持的模式类型 + // 1. $uri 占位符 + if pattern == "$uri" || pattern == "$uri/" { + return nil + } + + // 2. $uri. 动态后缀 + if strings.HasPrefix(pattern, "$uri.") { + ext := pattern[5:] // 提取扩展名部分 + + // 检查扩展名安全性 + if err := validateTryFilesExtension(ext); err != nil { + return fmt.Errorf("try_files 模式 %q: %w", pattern, err) + } + return nil + } + + // 3. 绝对路径回退(以 / 开头) + if strings.HasPrefix(pattern, "/") { + // 验证路径不包含危险字符 + if strings.Contains(pattern, "..") { + return fmt.Errorf("try_files 模式 %q 不能包含路径遍历", pattern) + } + return nil + } + + // 4. 相对路径回退(文件名) + // 检查是否为安全文件名 + if err := validateTryFilesFilename(pattern); err != nil { + return fmt.Errorf("try_files 模式 %q: %w", pattern, err) + } + + return nil +} + +// validateTryFilesExtension 验证动态后缀扩展名的安全性。 +// +// 检查扩展名是否包含危险字符或属于危险后缀列表。 +// +// 参数: +// - ext: 扩展名字符串(不包含前导点) +// +// 返回值: +// - error: 验证失败时返回错误信息 +func validateTryFilesExtension(ext string) error { + if ext == "" { + return errors.New("扩展名不能为空") + } + + // 检查路径分隔符 + if strings.ContainsAny(ext, "/\\") { + return errors.New("扩展名不能包含路径分隔符") + } + + // 检查 null byte + if strings.Contains(ext, "\x00") { + return errors.New("扩展名不能包含 null byte") + } + + // 白名单字符检查:仅允许字母、数字、点、下划线、连字符 + for i, c := range ext { + isLetter := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + isDigit := c >= '0' && c <= '9' + isAllowed := c == '.' || c == '_' || c == '-' + if !isLetter && !isDigit && !isAllowed { + return fmt.Errorf("扩展名包含非法字符 %q (位置 %d)", c, i) + } + } + + // 危险后缀黑名单(不含前导点,与 ext 格式一致) + dangerousExtensions := []string{ + "php", "php3", "php4", "php5", "phtml", + "exe", "bat", "cmd", "sh", "bash", + "cgi", "pl", "py", "rb", + "asp", "aspx", "jsp", + } + + extLower := strings.ToLower(ext) + for _, dangerous := range dangerousExtensions { + if extLower == dangerous || strings.HasSuffix(extLower, "."+dangerous) { + return fmt.Errorf("扩展名 %q 被禁止(潜在安全风险)", ext) + } + } + + return nil +} + +// validateTryFilesFilename 验证回退文件名的安全性。 +// +// 检查文件名是否包含路径遍历或危险字符。 +// +// 参数: +// - filename: 文件名字符串 +// +// 返回值: +// - error: 验证失败时返回错误信息 +func validateTryFilesFilename(filename string) error { + if filename == "" { + return errors.New("文件名不能为空") + } + + // 检查路径遍历 + if strings.Contains(filename, "..") { + return errors.New("文件名不能包含路径遍历") + } + + // 检查路径分隔符 + if strings.ContainsAny(filename, "/\\") { + return errors.New("文件名不能包含路径分隔符") + } + + // 检查 null byte + if strings.Contains(filename, "\x00") { + return errors.New("文件名不能包含 null byte") + } + + return nil +} + // validatePathConflicts 检查 static 和 proxy 路径冲突。 // // 确保 static 和 proxy 没有相同的 path 前缀。 diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index c41b527..a396cd8 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -1222,3 +1222,175 @@ func TestValidateVariables(t *testing.T) { }) } } + +func TestValidateTryFilesPattern(t *testing.T) { + tests := []struct { + name string + pattern string + wantErr bool + errMsg string + }{ + // 基本占位符 + {name: "有效 $uri", pattern: "$uri", wantErr: false}, + {name: "有效 $uri/", pattern: "$uri/", wantErr: false}, + + // 动态后缀 + {name: "有效 $uri.html", pattern: "$uri.html", wantErr: false}, + {name: "有效 $uri.json", pattern: "$uri.json", wantErr: false}, + {name: "有效 $uri.css", pattern: "$uri.css", wantErr: false}, + {name: "有效 $uri.js", pattern: "$uri.js", wantErr: false}, + {name: "有效 $uri.xml", pattern: "$uri.xml", wantErr: false}, + {name: "有效 $uri.webmanifest", pattern: "$uri.webmanifest", wantErr: false}, + {name: "有效 $uri.txt", pattern: "$uri.txt", wantErr: false}, + {name: "有效 $uri.svg", pattern: "$uri.svg", wantErr: false}, + {name: "有效 $uri.woff2", pattern: "$uri.woff2", wantErr: false}, + + // 绝对路径回退 + {name: "有效绝对路径", pattern: "/index.html", wantErr: false}, + {name: "有效嵌套路径", pattern: "/fallback/index.html", wantErr: false}, + + // 相对路径回退 + {name: "有效文件名", pattern: "fallback.html", wantErr: false}, + {name: "有效嵌套文件名", pattern: "app-shell.html", wantErr: false}, + + // 安全检查 - null byte + {name: "拒绝 null byte", pattern: "$uri\x00.html", wantErr: true, errMsg: "null byte"}, + {name: "拒绝扩展名 null byte", pattern: "$uri.ht\x00ml", wantErr: true, errMsg: "null byte"}, + + // 安全检查 - 路径分隔符 + {name: "拒绝扩展名中斜杠", pattern: "$uri./../etc/passwd", wantErr: true, errMsg: "路径分隔符"}, + {name: "拒绝扩展名中反斜杠", pattern: "$uri.\\..\\passwd", wantErr: true, errMsg: "路径分隔符"}, + {name: "拒绝扩展名中单个斜杠", pattern: "$uri.dir/file", wantErr: true, errMsg: "路径分隔符"}, + + // 安全检查 - 危险后缀 + {name: "拒绝 .php 后缀", pattern: "$uri.php", wantErr: true, errMsg: "被禁止"}, + {name: "拒绝 .exe 后缀", pattern: "$uri.exe", wantErr: true, errMsg: "被禁止"}, + {name: "拒绝 .bat 后缀", pattern: "$uri.bat", wantErr: true, errMsg: "被禁止"}, + {name: "拒绝 .sh 后缀", pattern: "$uri.sh", wantErr: true, errMsg: "被禁止"}, + {name: "拒绝 .cgi 后缀", pattern: "$uri.cgi", wantErr: true, errMsg: "被禁止"}, + {name: "拒绝 .phtml 后缀", pattern: "$uri.phtml", wantErr: true, errMsg: "被禁止"}, + {name: "拒绝 .PHP 大写后缀", pattern: "$uri.PHP", wantErr: true, errMsg: "被禁止"}, + + // 安全检查 - 非法字符 + {name: "拒绝扩展名中空格", pattern: "$uri. html", wantErr: true, errMsg: "非法字符"}, + {name: "拒绝扩展名中特殊字符", pattern: "$uri.