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 }