first commit
This commit is contained in:
99
backend/internal/util/responseheaders/responseheaders.go
Normal file
99
backend/internal/util/responseheaders/responseheaders.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package responseheaders
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
// defaultAllowed 定义允许透传的响应头白名单
|
||||
// 注意:以下头部由 Go HTTP 包自动处理,不应手动设置:
|
||||
// - content-length: 由 ResponseWriter 根据实际写入数据自动设置
|
||||
// - transfer-encoding: 由 HTTP 库根据需要自动添加/移除
|
||||
// - connection: 由 HTTP 库管理连接复用
|
||||
var defaultAllowed = map[string]struct{}{
|
||||
"content-type": {},
|
||||
"content-encoding": {},
|
||||
"content-language": {},
|
||||
"cache-control": {},
|
||||
"etag": {},
|
||||
"last-modified": {},
|
||||
"expires": {},
|
||||
"vary": {},
|
||||
"date": {},
|
||||
"x-request-id": {},
|
||||
"x-ratelimit-limit-requests": {},
|
||||
"x-ratelimit-limit-tokens": {},
|
||||
"x-ratelimit-remaining-requests": {},
|
||||
"x-ratelimit-remaining-tokens": {},
|
||||
"x-ratelimit-reset-requests": {},
|
||||
"x-ratelimit-reset-tokens": {},
|
||||
"retry-after": {},
|
||||
"location": {},
|
||||
"www-authenticate": {},
|
||||
}
|
||||
|
||||
// hopByHopHeaders 是跳过的 hop-by-hop 头部,这些头部由 HTTP 库自动处理
|
||||
var hopByHopHeaders = map[string]struct{}{
|
||||
"content-length": {},
|
||||
"transfer-encoding": {},
|
||||
"connection": {},
|
||||
}
|
||||
|
||||
func FilterHeaders(src http.Header, cfg config.ResponseHeaderConfig) http.Header {
|
||||
allowed := make(map[string]struct{}, len(defaultAllowed)+len(cfg.AdditionalAllowed))
|
||||
for key := range defaultAllowed {
|
||||
allowed[key] = struct{}{}
|
||||
}
|
||||
// 关闭时只使用默认白名单,additional/force_remove 不生效
|
||||
if cfg.Enabled {
|
||||
for _, key := range cfg.AdditionalAllowed {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
allowed[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
forceRemove := map[string]struct{}{}
|
||||
if cfg.Enabled {
|
||||
forceRemove = make(map[string]struct{}, len(cfg.ForceRemove))
|
||||
for _, key := range cfg.ForceRemove {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
forceRemove[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make(http.Header, len(src))
|
||||
for key, values := range src {
|
||||
lower := strings.ToLower(key)
|
||||
if _, blocked := forceRemove[lower]; blocked {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[lower]; !ok {
|
||||
continue
|
||||
}
|
||||
// 跳过 hop-by-hop 头部,这些由 HTTP 库自动处理
|
||||
if _, isHopByHop := hopByHopHeaders[lower]; isHopByHop {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
filtered.Add(key, value)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func WriteFilteredHeaders(dst http.Header, src http.Header, cfg config.ResponseHeaderConfig) {
|
||||
filtered := FilterHeaders(src, cfg)
|
||||
for key, values := range filtered {
|
||||
for _, value := range values {
|
||||
dst.Add(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package responseheaders
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
func TestFilterHeadersDisabledUsesDefaultAllowlist(t *testing.T) {
|
||||
src := http.Header{}
|
||||
src.Add("Content-Type", "application/json")
|
||||
src.Add("X-Request-Id", "req-123")
|
||||
src.Add("X-Test", "ok")
|
||||
src.Add("Connection", "keep-alive")
|
||||
src.Add("Content-Length", "123")
|
||||
|
||||
cfg := config.ResponseHeaderConfig{
|
||||
Enabled: false,
|
||||
ForceRemove: []string{"x-request-id"},
|
||||
}
|
||||
|
||||
filtered := FilterHeaders(src, cfg)
|
||||
if filtered.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("expected Content-Type passthrough, got %q", filtered.Get("Content-Type"))
|
||||
}
|
||||
if filtered.Get("X-Request-Id") != "req-123" {
|
||||
t.Fatalf("expected X-Request-Id allowed, got %q", filtered.Get("X-Request-Id"))
|
||||
}
|
||||
if filtered.Get("X-Test") != "" {
|
||||
t.Fatalf("expected X-Test removed, got %q", filtered.Get("X-Test"))
|
||||
}
|
||||
if filtered.Get("Connection") != "" {
|
||||
t.Fatalf("expected Connection to be removed, got %q", filtered.Get("Connection"))
|
||||
}
|
||||
if filtered.Get("Content-Length") != "" {
|
||||
t.Fatalf("expected Content-Length to be removed, got %q", filtered.Get("Content-Length"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterHeadersEnabledUsesAllowlist(t *testing.T) {
|
||||
src := http.Header{}
|
||||
src.Add("Content-Type", "application/json")
|
||||
src.Add("X-Extra", "ok")
|
||||
src.Add("X-Remove", "nope")
|
||||
src.Add("X-Blocked", "nope")
|
||||
|
||||
cfg := config.ResponseHeaderConfig{
|
||||
Enabled: true,
|
||||
AdditionalAllowed: []string{"x-extra"},
|
||||
ForceRemove: []string{"x-remove"},
|
||||
}
|
||||
|
||||
filtered := FilterHeaders(src, cfg)
|
||||
if filtered.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("expected Content-Type allowed, got %q", filtered.Get("Content-Type"))
|
||||
}
|
||||
if filtered.Get("X-Extra") != "ok" {
|
||||
t.Fatalf("expected X-Extra allowed, got %q", filtered.Get("X-Extra"))
|
||||
}
|
||||
if filtered.Get("X-Remove") != "" {
|
||||
t.Fatalf("expected X-Remove removed, got %q", filtered.Get("X-Remove"))
|
||||
}
|
||||
if filtered.Get("X-Blocked") != "" {
|
||||
t.Fatalf("expected X-Blocked removed, got %q", filtered.Get("X-Blocked"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user