feat(handler,config): 支持 try_files 动态后缀 ($uri.<ext>)

- 实现 $uri.<ext> 模式解析(如 $uri.html, $uri.json)
- 添加扩展名安全验证:白名单字符、危险后缀黑名单
- 根路径边界处理:避免生成 "/.html" 隐藏文件名
- 增强配置文档注释,说明 nginx 兼容性和安全限制
- 完整单元测试覆盖

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-10 18:29:42 +08:00
parent ada7357f7d
commit 76c2a6028c
5 changed files with 514 additions and 4 deletions

View File

@ -317,8 +317,30 @@ type StaticConfig struct {
Index []string `yaml:"index"`
// TryFiles 按顺序尝试查找的文件列表
// 支持 $uri 和 $uri/ 占位符,用于 SPA 部署
// 示例: ["$uri", "$uri/", "/index.html"]
// 支持以下模式:
// - $uri: 请求路径
// - $uri/: 请求路径加斜杠(目录)
// - $uri.<ext>: 请求路径加扩展名(如 $uri.html, $uri.json
// - /path: 绝对路径回退(如 /index.html
// - filename: 相对路径回退(如 fallback.html
//
// nginx 兼容性:
// - $uri 变量语义与 nginx try_files 指令一致
// - 配置语法可从 nginx 直接迁移
//
// 安全限制(附加于 nginx 基础):
// - 扩展名仅允许字母、数字、点、下划线、连字符
// - 禁止危险后缀(.php, .exe, .bat 等)
// - 禁止 null byte 和路径分隔符
//
// 根路径边界情况:
// - 当 relPath="/" 且模式为 "$uri.<ext>" 时,返回空字符串
// - 此设计避免生成 "/.html" 这样的隐藏文件名
// - 建议使用绝对路径回退(如 /index.html处理根路径
//
// 示例:
// try_files: ["$uri", "$uri.html", "/index.html"]
// try_files: ["$uri", "$uri/", "/app.html"]
TryFiles []string `yaml:"try_files"`
// TryFilesPass 内部重定向是否触发中间件

View File

@ -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.<ext> - 请求路径加扩展名(如 $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.<ext> 动态后缀
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 前缀。

View File

@ -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.<script>", wantErr: true, errMsg: "非法字符"},
{name: "拒绝扩展名中百分号", pattern: "$uri.%20", wantErr: true, errMsg: "非法字符"},
{name: "拒绝扩展名中中文", pattern: "$uri.测试", wantErr: true, errMsg: "非法字符"},
// 空模式
{name: "拒绝空模式", pattern: "", wantErr: true, errMsg: "不能为空"},
{name: "拒绝空扩展名", pattern: "$uri.", wantErr: true, errMsg: "扩展名不能为空"},
// 路径遍历
{name: "拒绝绝对路径遍历", pattern: "/../../../etc/passwd", wantErr: true, errMsg: "路径遍历"},
{name: "拒绝文件名路径遍历", pattern: "../etc/passwd", wantErr: true, errMsg: "路径遍历"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTryFilesPattern(tt.pattern)
if tt.wantErr {
if err == nil {
t.Errorf("validateTryFilesPattern(%q) 期望返回错误,但返回 nil", tt.pattern)
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("错误消息不匹配,期望包含 %q实际 %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("validateTryFilesPattern(%q) 期望返回 nil但返回错误: %v", tt.pattern, err)
}
}
})
}
}
func TestValidateStaticsWithTryFiles(t *testing.T) {
tests := []struct {
name string
statics []StaticConfig
wantErr bool
errMsg string
}{
{
name: "有效 try_files 配置",
statics: []StaticConfig{
{
Path: "/",
Root: "/var/www",
TryFiles: []string{"$uri", "$uri.html", "/index.html"},
},
},
wantErr: false,
},
{
name: "多静态目录有效配置",
statics: []StaticConfig{
{
Path: "/",
Root: "/var/www",
TryFiles: []string{"$uri", "/index.html"},
},
{
Path: "/api",
Root: "/var/api",
TryFiles: []string{"$uri.json", "/api.json"},
},
},
wantErr: false,
},
{
name: "无效 try_files 模式",
statics: []StaticConfig{
{
Path: "/",
Root: "/var/www",
TryFiles: []string{"$uri", "$uri.php"},
},
},
wantErr: true,
errMsg: "try_files[1]",
},
{
name: "空 try_files 配置",
statics: []StaticConfig{
{
Path: "/",
Root: "/var/www",
TryFiles: []string{},
},
},
wantErr: false,
},
{
name: "无 try_files 配置",
statics: []StaticConfig{
{
Path: "/",
Root: "/var/www",
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateStatics(tt.statics)
if tt.wantErr {
if err == nil {
t.Errorf("期望返回错误,但返回 nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("错误消息不匹配,期望包含 %q实际 %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("期望返回 nil但返回错误: %v", err)
}
}
})
}
}

View File

@ -317,18 +317,41 @@ func (h *StaticHandler) handleTryFiles(ctx *fasthttp.RequestCtx, reqPath string)
// resolveTryFilePath 解析 try_files 中的占位符。
//
// 支持的占位符:
// - $uri: 请求路径
// - $uri/: 请求路径加斜杠
// - $uri.<ext>: 请求路径加扩展名(如 $uri.html
//
// nginx 兼容性说明:
// - $uri 变量语义与 nginx try_files 一致
// - 附加安全验证在 validateStatics 时执行
//
// 参数:
// - tryFile: try_files 配置项
// - tryFile: try_files 配置项(已在 validateStatics 时验证)
// - relPath: 相对请求路径
//
// 返回值:
// - string: 解析后的文件路径
// - string: 解析后的文件路径,根路径边界返回空字符串触发回退
func (h *StaticHandler) resolveTryFilePath(tryFile, relPath string) string {
switch {
// ====== 保留:现有逻辑 ======
case tryFile == "$uri":
return relPath
case tryFile == "$uri/":
return relPath + "/"
// ====== 新增:动态后缀支持 ======
case strings.HasPrefix(tryFile, "$uri."):
// 提取后缀部分(包含点,如 ".html"
suffix := tryFile[4:] // "$uri" 是4个字符后面是 ".html" 等后缀
// 根路径边界处理:返回空字符串让 try_files 继续下一个条目
// 避免 "/.html" 这样的隐藏文件名
if relPath == "/" {
return "" // 触发回退到下一个 try_files 条目
}
return relPath + suffix
// ====== 保留:现有逻辑 ======
case strings.HasPrefix(tryFile, "/"):
// 绝对路径,直接返回(去掉开头的 /
return tryFile[1:]

View File

@ -1448,3 +1448,139 @@ func TestStaticHandler_LargeFileContentType(t *testing.T) {
})
}
}
// TestResolveTryFilePathWithDynamicSuffix 测试动态后缀解析
func TestResolveTryFilePathWithDynamicSuffix(t *testing.T) {
handler := NewStaticHandler("/var/www", "/", []string{"index.html"}, false)
tests := []struct {
name string
tryFile string
relPath string
wantResult string
}{
// 基本占位符
{name: "$uri", tryFile: "$uri", relPath: "/api/user", wantResult: "/api/user"},
{name: "$uri/", tryFile: "$uri/", relPath: "/api/user", wantResult: "/api/user/"},
// 动态后缀 - 正常路径
{name: "$uri.html 正常", tryFile: "$uri.html", relPath: "/about", wantResult: "/about.html"},
{name: "$uri.json 正常", tryFile: "$uri.json", relPath: "/api/data", wantResult: "/api/data.json"},
{name: "$uri.css 正常", tryFile: "$uri.css", relPath: "/styles/main", wantResult: "/styles/main.css"},
// 动态后缀 - 根路径边界(返回空字符串)
{name: "$uri.html 根路径", tryFile: "$uri.html", relPath: "/", wantResult: ""},
{name: "$uri.json 根路径", tryFile: "$uri.json", relPath: "/", wantResult: ""},
// 动态后缀 - 子目录路径(正常处理)
{name: "$uri.html 子目录", tryFile: "$uri.html", relPath: "/api/", wantResult: "/api/.html"},
{name: "$uri.json 子目录", tryFile: "$uri.json", relPath: "/v1/", wantResult: "/v1/.json"},
// 绝对路径
{name: "绝对路径", tryFile: "/index.html", relPath: "/api/user", wantResult: "index.html"},
{name: "绝对路径嵌套", tryFile: "/fallback/app.html", relPath: "/any", wantResult: "fallback/app.html"},
// 相对路径
{name: "相对路径", tryFile: "fallback.html", relPath: "/api/user", wantResult: "fallback.html"},
{name: "相对路径带连字符", tryFile: "app-shell.html", relPath: "/any", wantResult: "app-shell.html"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := handler.resolveTryFilePath(tt.tryFile, tt.relPath)
if got != tt.wantResult {
t.Errorf("resolveTryFilePath(%q, %q) = %q, want %q",
tt.tryFile, tt.relPath, got, tt.wantResult)
}
})
}
}
// TestStaticHandler_TryFilesWithDynamicSuffix 测试动态后缀集成
func TestStaticHandler_TryFilesWithDynamicSuffix(t *testing.T) {
tmpDir := t.TempDir()
// 创建测试文件
if err := os.WriteFile(filepath.Join(tmpDir, "about.html"), []byte("about page"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
if err := os.MkdirAll(filepath.Join(tmpDir, "api"), 0755); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "api", "data.json"), []byte("{\"data\":true}"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("fallback"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
handler.SetTryFiles([]string{"$uri", "$uri.html", "/index.html"}, false, nil)
tests := []struct {
name string
path string
wantStatus int
wantContent string
}{
{
name: "找到 $uri.html",
path: "/about",
wantStatus: fasthttp.StatusOK,
wantContent: "about page",
},
{
name: "回退到 /index.html",
path: "/nonexistent",
wantStatus: fasthttp.StatusOK,
wantContent: "fallback",
},
{
name: "根路径回退到 /index.html",
path: "/",
wantStatus: fasthttp.StatusOK,
wantContent: "fallback",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newTestContext(t, tt.path)
handler.Handle(ctx)
if got := ctx.Response.StatusCode(); got != tt.wantStatus {
t.Errorf("状态码 = %d, want %d", got, tt.wantStatus)
}
if got := string(ctx.Response.Body()); got != tt.wantContent {
t.Errorf("内容 = %q, want %q", got, tt.wantContent)
}
})
}
}
// TestStaticHandler_TryFilesRootPathFallback 测试根路径回退
func TestStaticHandler_TryFilesRootPathFallback(t *testing.T) {
tmpDir := t.TempDir()
// 创建 index.html 作为根路径回退
if err := os.WriteFile(filepath.Join(tmpDir, "index.html"), []byte("root fallback"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
// 注意:不创建 /.html 文件,测试根路径边界情况
handler := NewStaticHandler(tmpDir, "/", []string{"index.html"}, false)
handler.SetTryFiles([]string{"$uri", "$uri.html", "/index.html"}, false, nil)
ctx := newTestContext(t, "/")
handler.Handle(ctx)
// 验证根路径请求正确回退到 /index.html
if got := ctx.Response.StatusCode(); got != fasthttp.StatusOK {
t.Errorf("状态码 = %d, want %d", got, fasthttp.StatusOK)
}
if got := string(ctx.Response.Body()); got != "root fallback" {
t.Errorf("内容 = %q, want %q", got, "root fallback")
}
}