chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
// serviceTransport 复用的共享 Transport,避免每条请求新建导致连接无法复用、
|
||||
// 且无超时的资源泄漏。带响应头/空闲连接超时,防止后端挂死时请求无限期阻塞。
|
||||
var (
|
||||
serviceTransportOnce sync.Once
|
||||
serviceTransport http.RoundTripper
|
||||
)
|
||||
|
||||
func getServiceTransport() http.RoundTripper {
|
||||
serviceTransportOnce.Do(func() {
|
||||
serviceTransport = &http.Transport{
|
||||
// 默认校验证书;仅当配置 tls_insecure_skip_verify=true(内网自签名证书后端)时跳过。
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: conf.Service.TLSInsecureSkipVerify},
|
||||
ResponseHeaderTimeout: 15 * time.Second,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
ExpectContinueTimeout: 2 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
}
|
||||
})
|
||||
return serviceTransport
|
||||
}
|
||||
|
||||
// singleJoiningSlash 将基础路径与附加路径安全拼接,避免双斜杠或丢失分隔符。
|
||||
func singleJoiningSlash(a, b string) string {
|
||||
aslash := strings.HasSuffix(a, "/")
|
||||
bslash := strings.HasPrefix(b, "/")
|
||||
switch {
|
||||
case aslash && bslash:
|
||||
return a + b[1:]
|
||||
case !aslash && !bslash:
|
||||
return a + "/" + b
|
||||
}
|
||||
return a + b
|
||||
}
|
||||
|
||||
func Proxy(ctx context.Context) error {
|
||||
span := trace.SpanFromContext(ctx.Request().Context())
|
||||
|
||||
var userID uint
|
||||
if ctx.IsLogged {
|
||||
userID = ctx.User.ID
|
||||
}
|
||||
|
||||
if span.SpanContext().IsValid() {
|
||||
span.SetAttributes(
|
||||
attribute.Int("nekobox.service.user-id", int(userID)),
|
||||
)
|
||||
}
|
||||
|
||||
uri := ctx.Param("**")
|
||||
basePath := strings.Split(uri, "/")[0]
|
||||
forwardPath := strings.TrimPrefix(uri, basePath)
|
||||
|
||||
var forwardURLStr string
|
||||
for _, backend := range conf.Service.Backends {
|
||||
if backend.Prefix == basePath {
|
||||
forwardURLStr = backend.ForwardURL
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(forwardURLStr) == 0 {
|
||||
return ctx.JSONError(http.StatusNotFound, i18n.T(ctx.Lang(), "proxy.errNotFound"))
|
||||
}
|
||||
|
||||
forwardURL, err := url.Parse(forwardURLStr)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to parse forward URL")
|
||||
return ctx.JSONError(http.StatusInternalServerError, i18n.T(ctx.Lang(), "proxy.errInternal"))
|
||||
}
|
||||
|
||||
// 路径穿越防护:先用 path.Clean 归一化,剥离 ../ 等相对段,
|
||||
// 确保转发路径始终相对于后端基址,不会被 ../ 越出白名单前缀。
|
||||
forwardPath = path.Clean(forwardPath)
|
||||
if !strings.HasPrefix(forwardPath, "/") {
|
||||
forwardPath = "/" + forwardPath
|
||||
}
|
||||
|
||||
reverseProxy := httputil.ReverseProxy{
|
||||
Director: func(req *http.Request) {
|
||||
req.URL = forwardURL
|
||||
req.URL.Path = singleJoiningSlash(forwardURL.Path, forwardPath)
|
||||
// 透传原始查询串(此前静默丢弃,导致带参请求后端收不到 query)。
|
||||
req.URL.RawQuery = ctx.Request().URL.RawQuery
|
||||
req.Host = forwardURL.Host
|
||||
|
||||
// 安全加固:不透传用户浏览器带来的敏感请求头给后端。
|
||||
// 后端通过 X-NekoBox-User-ID 识别用户身份,不应接收用户的会话
|
||||
// Cookie / 鉴权头,否则可能被用于横向越权或凭证泄露。
|
||||
// 同时清除客户端伪造的 X-Forwarded-*,避免请求头注入。
|
||||
req.Header.Del("Cookie")
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del("Proxy-Authorization")
|
||||
req.Header.Del("X-Forwarded-For")
|
||||
req.Header.Del("X-Forwarded-Host")
|
||||
req.Header.Del("X-Forwarded-Proto")
|
||||
|
||||
traceHeaders := http.Header{}
|
||||
otel.GetTextMapPropagator().Inject(ctx.Request().Context(), propagation.HeaderCarrier(traceHeaders))
|
||||
for key := range traceHeaders {
|
||||
req.Header.Set(key, traceHeaders.Get(key))
|
||||
}
|
||||
|
||||
req.Header.Set("X-NekoBox-From", "nekobox-gateway")
|
||||
req.Header.Set("X-NekoBox-User-ID", strconv.Itoa(int(userID)))
|
||||
},
|
||||
Transport: getServiceTransport(),
|
||||
ErrorHandler: func(writer http.ResponseWriter, request *http.Request, err error) {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to handle reverse proxy request")
|
||||
_ = ctx.JSONError(http.StatusInternalServerError, i18n.T(ctx.Lang(), "proxy.errInternal"))
|
||||
},
|
||||
}
|
||||
|
||||
// 审计日志:记录每一次反代转发(谁、哪个前缀、哪条路径、转发到哪个后端),
|
||||
// 便于事后排查异常访问与越权探测。
|
||||
logrus.WithContext(ctx.Request().Context()).WithFields(logrus.Fields{
|
||||
"user_id": userID,
|
||||
"prefix": basePath,
|
||||
"path": forwardPath,
|
||||
"backend": forwardURLStr,
|
||||
}).Info("Service proxy forwarding request")
|
||||
|
||||
reverseProxy.ServeHTTP(ctx.ResponseWriter(), ctx.Request().Request)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user