From ceacd99a745b9180af2987417429b80c384a9230 Mon Sep 17 00:00:00 2001 From: smail Date: Wed, 30 Sep 2026 22:33:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BA=E9=9A=94=E7=A6=BB=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=8C=89=E6=9D=83=E9=99=90=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E7=9A=84=E6=9C=AC=E5=9C=B0=E5=87=BA=E7=AB=99=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/hooks/base.go | 19 +- backend/hooks/plugin_egress_linux.go | 86 +++++++++ backend/hooks/plugin_isolation.go | 4 + backend/hooks/plugin_isolation_linux.go | 9 +- backend/hooks/plugin_isolation_linux_test.go | 32 ++++ backend/utils/credential/credential.go | 24 ++- backend/utils/credential/credential_test.go | 130 +++++++++++++ backend/utils/credential/egress.go | 191 +++++++++++++++++++ 8 files changed, 488 insertions(+), 7 deletions(-) create mode 100644 backend/hooks/plugin_egress_linux.go create mode 100644 backend/utils/credential/egress.go diff --git a/backend/hooks/base.go b/backend/hooks/base.go index 0a2c9e5..5959e7a 100644 --- a/backend/hooks/base.go +++ b/backend/hooks/base.go @@ -269,10 +269,10 @@ func (h *HookSender) SettingsHtml(ctx *context.Context, url string, requestData func NewHookSender(socketPath string, name string, serverVersion string, authToken ...string) *HookSender { httpc := http.Client{ - Timeout: time.Second * 10, + Timeout: time.Second * 35, Transport: &http.Transport{ DialContext: func(ctx oContext.Context, network, addr string) (net.Conn, error) { - return net.Dial("unix", socketPath) + return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) }, }, } @@ -349,6 +349,7 @@ func Init(serverVersion string) { } env := pluginEnvironment(root, logicalID) env = appendPluginEnv(env, "SMAIL_PLUGIN_RPC_TOKEN", authToken) + env = appendPluginEnv(env, "SMAIL_PLUGIN_ID", logicalID) launchSpec, launchErr := newPluginLaunchSpec(pluginPath, root, executableName, socketName, env) if launchErr != nil { if !config.DevelopmentMode { @@ -411,8 +412,18 @@ func Init(serverVersion string) { } if instance, generation, ok := pluginInstanceForStartup(logicalID); ok { payload, payloadErr := pluginRuntimeConfig(logicalID, generation, instance) - if payloadErr != nil || hook.ValidateConfig(payload) != nil || hook.Configure(payload) != nil { - log.Warnf("[%s] Plugin runtime configuration failed", logicalID) + if payloadErr != nil { + log.Warnf("[%s] 插件运行配置构建失败", logicalID) + _ = process.Kill() + continue + } + if hook.ValidateConfig(payload) != nil { + log.Warnf("[%s] 插件运行配置校验失败", logicalID) + _ = process.Kill() + continue + } + if hook.Configure(payload) != nil { + log.Warnf("[%s] 插件运行配置应用失败", logicalID) _ = process.Kill() continue } diff --git a/backend/hooks/plugin_egress_linux.go b/backend/hooks/plugin_egress_linux.go new file mode 100644 index 0000000..fd3892f --- /dev/null +++ b/backend/hooks/plugin_egress_linux.go @@ -0,0 +1,86 @@ +//go:build linux + +package hooks + +import ( + "context" + "errors" + "io" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "git.pcux.cn/xuans/Smail/utils/credential" +) + +func startPluginEgress(spec *pluginLaunchSpec, uid, gid int) error { + logicalID := "" + for _, item := range spec.procAttr.Env { + if strings.HasPrefix(item, "SMAIL_PLUGIN_ID=") { + logicalID = strings.TrimPrefix(item, "SMAIL_PLUGIN_ID=") + } + } + if logicalID == "" { + return errors.New("plugin egress identity is unavailable") + } + token, err := newPluginAuthToken() + if err != nil { + return errors.New("plugin egress authorization generation failed") + } + socketPath := filepath.Join(spec.runtimeDir, filepath.Base(credential.PluginEgressSocket)) + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) + if err != nil { + return errors.New("plugin egress listener creation failed") + } + if err := os.Chmod(socketPath, 0600); err != nil { + _ = listener.Close() + return errors.New("plugin egress socket permissions failed") + } + if err := os.Chown(socketPath, uid, gid); err != nil { + _ = listener.Close() + return errors.New("plugin egress socket identity failed") + } + ctx, cancel := context.WithCancel(context.Background()) + server := &http.Server{ + Handler: credential.NewPluginEgressHandler(token, func() []credential.NetworkTarget { + instance, _, ok := pluginInstanceForStartup(logicalID) + if !ok || validatePluginRuntimeBinding(logicalID, instance) != nil { + return nil + } + canNetwork := false + for _, capability := range instance.Permissions.Capabilities { + canNetwork = canNetwork || capability == "network" + } + if !canNetwork { + return nil + } + targets := make([]credential.NetworkTarget, 0, len(instance.Permissions.Network)) + for _, target := range instance.Permissions.Network { + if target.Scheme == "https" { + targets = append(targets, credential.NetworkTarget{Scheme: target.Scheme, Host: target.Host, Port: target.Port}) + } + } + return targets + }), + BaseContext: func(net.Listener) context.Context { return ctx }, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 65 * time.Second, + IdleTimeout: 5 * time.Second, + MaxHeaderBytes: 32 << 10, + ErrorLog: log.New(io.Discard, "", 0), + } + server.SetKeepAlivesEnabled(false) + spec.egressStop = func() { + cancel() + _ = listener.Close() + _ = server.Close() + } + spec.procAttr.Env = appendPluginEnv(spec.procAttr.Env, "SMAIL_PLUGIN_EGRESS_TOKEN", token) + go func() { _ = server.Serve(listener) }() + return nil +} diff --git a/backend/hooks/plugin_isolation.go b/backend/hooks/plugin_isolation.go index e354c5c..5d2e004 100644 --- a/backend/hooks/plugin_isolation.go +++ b/backend/hooks/plugin_isolation.go @@ -15,6 +15,7 @@ type pluginLaunchSpec struct { socketPath string runtimeDir string parentFiles []*os.File + egressStop func() } func compatibilityPluginLaunchSpec(pluginPath, root, name, socketName string, env []string) *pluginLaunchSpec { @@ -43,6 +44,9 @@ func (s *pluginLaunchSpec) cleanup() { return } s.closeParentFiles() + if s.egressStop != nil { + s.egressStop() + } if s.runtimeDir != "" { _ = os.RemoveAll(s.runtimeDir) } diff --git a/backend/hooks/plugin_isolation_linux.go b/backend/hooks/plugin_isolation_linux.go index c086d72..6dbcc07 100644 --- a/backend/hooks/plugin_isolation_linux.go +++ b/backend/hooks/plugin_isolation_linux.go @@ -77,7 +77,7 @@ func newPluginLaunchSpec(pluginPath, root, name, socketName string, env []string return nil, fmt.Errorf("prepare plugin listener: %w", err) } cloneFlags := uintptr(unix.CLONE_NEWNET) - return &pluginLaunchSpec{ + spec := &pluginLaunchSpec{ path: "/" + name, args: []string{name, "fd:3"}, socketPath: abstractSocket, @@ -104,7 +104,12 @@ func newPluginLaunchSpec(pluginPath, root, name, socketName string, env []string Setsid: true, }, }, - }, nil + } + if err := startPluginEgress(spec, uid, gid); err != nil { + spec.cleanup() + return nil, err + } + return spec, nil } func copyPluginConfig(root, runtimeDir, name string, uid, gid int) error { diff --git a/backend/hooks/plugin_isolation_linux_test.go b/backend/hooks/plugin_isolation_linux_test.go index d05c332..b47ca87 100644 --- a/backend/hooks/plugin_isolation_linux_test.go +++ b/backend/hooks/plugin_isolation_linux_test.go @@ -100,3 +100,35 @@ func TestInheritedAbstractSocketRemainsReachableAcrossListenerCopy(t *testing.T) t.Fatal(err) } } + +func TestPluginEgressSocketLifecycle(t *testing.T) { + spec := &pluginLaunchSpec{ + runtimeDir: t.TempDir(), + procAttr: &os.ProcAttr{Env: []string{ + "SMAIL_PLUGIN_ID=wechat_push", + "SMAIL_PLUGIN_RPC_TOKEN=" + strings.Repeat("b", 64), + }}, + } + if err := startPluginEgress(spec, os.Getuid(), os.Getgid()); err != nil { + t.Fatal(err) + } + defer spec.cleanup() + socketPath := filepath.Join(spec.runtimeDir, "egress.socket") + info, err := os.Stat(socketPath) + if err != nil || info.Mode()&os.ModeSocket == 0 || info.Mode().Perm() != 0600 { + t.Fatal("egress socket is missing or permissions are unsafe") + } + token := "" + for _, value := range spec.procAttr.Env { + if strings.HasPrefix(value, "SMAIL_PLUGIN_EGRESS_TOKEN=") { + token = strings.TrimPrefix(value, "SMAIL_PLUGIN_EGRESS_TOKEN=") + } + } + if len(token) != 64 || token == strings.Repeat("b", 64) { + t.Fatal("egress token is missing or reuses the RPC token") + } + spec.egressStop() + if _, err := os.Stat(socketPath); !os.IsNotExist(err) { + t.Fatal("egress socket survived shutdown") + } +} diff --git a/backend/utils/credential/credential.go b/backend/utils/credential/credential.go index f924532..c30830c 100644 --- a/backend/utils/credential/credential.go +++ b/backend/utils/credential/credential.go @@ -8,6 +8,7 @@ import ( "net/http" "net/netip" "net/url" + "os" "strconv" "strings" "time" @@ -101,6 +102,10 @@ type NetworkTarget struct { // NewRestrictedHTTPClientForTargets 创建仅允许访问授权目标的 HTTP 客户端。 func NewRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget) *http.Client { + return newRestrictedHTTPClientForTargets(timeoutMS, targets, os.Getenv("SMAIL_PLUGIN_SANDBOX") == "1") +} + +func newRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget, sandbox bool) *http.Client { timeout := time.Duration(NormalizeTimeoutMS(timeoutMS)) * time.Millisecond allowedTargets := normalizeNetworkTargets(targets) transport := &http.Transport{ @@ -113,9 +118,13 @@ func NewRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget) * ResponseHeaderTimeout: timeout, IdleConnTimeout: 30 * time.Second, } + var outbound http.RoundTripper = transport + if sandbox { + outbound = newPluginEgressTransport(timeout) + } return &http.Client{ Timeout: timeout, - Transport: restrictedRoundTripper{transport: transport, targets: allowedTargets}, + Transport: restrictedRoundTripper{transport: outbound, targets: allowedTargets}, CheckRedirect: func(request *http.Request, via []*http.Request) error { if len(via) >= 10 { return errors.New("too many redirects") @@ -132,11 +141,24 @@ type restrictedRoundTripper struct { func (r restrictedRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { if err := validateRestrictedRequest(request, r.targets, "remote target is not authorized"); err != nil { + closeRequestBody(request) return nil, err } return r.transport.RoundTrip(request) } +func (r restrictedRoundTripper) CloseIdleConnections() { + if transport, ok := r.transport.(interface{ CloseIdleConnections() }); ok { + transport.CloseIdleConnections() + } +} + +func closeRequestBody(request *http.Request) { + if request != nil && request.Body != nil { + _ = request.Body.Close() + } +} + func validateRestrictedRequest(request *http.Request, targets []NetworkTarget, targetError string) error { if request == nil || request.URL == nil { return errors.New("invalid endpoint") diff --git a/backend/utils/credential/credential_test.go b/backend/utils/credential/credential_test.go index 44958ce..7fabfb1 100644 --- a/backend/utils/credential/credential_test.go +++ b/backend/utils/credential/credential_test.go @@ -1,9 +1,11 @@ package credential import ( + "io" "net/http" "net/http/httptest" "net/netip" + "strings" "testing" ) @@ -130,3 +132,131 @@ func TestNormalizeTimeoutMS(t *testing.T) { t.Fatal("timeout limit was not normalized") } } + +func TestPluginEgressRejectsInvalidRequests(t *testing.T) { + token := strings.Repeat("a", 64) + allowed := []NetworkTarget{{Scheme: "https", Host: "allowed.example.com", Port: 443}} + cases := []struct { + name string + change func(*http.Request) + status int + }{ + {"missing_token", func(r *http.Request) { r.Header.Del(egressTokenHeader) }, http.StatusForbidden}, + {"wrong_token", func(r *http.Request) { r.Header.Set(egressTokenHeader, strings.Repeat("b", 64)) }, http.StatusForbidden}, + {"duplicate_token", func(r *http.Request) { r.Header.Add(egressTokenHeader, token) }, http.StatusForbidden}, + {"wrong_host", func(r *http.Request) { r.Host = "other" }, http.StatusBadRequest}, + {"wrong_path", func(r *http.Request) { r.URL.Path = "/other" }, http.StatusBadRequest}, + {"query", func(r *http.Request) { r.URL.RawQuery = "x=1" }, http.StatusBadRequest}, + {"connect", func(r *http.Request) { r.Method = http.MethodConnect }, http.StatusBadRequest}, + {"upgrade", func(r *http.Request) { r.Header.Set("Upgrade", "websocket") }, http.StatusBadRequest}, + {"duplicate_target", func(r *http.Request) { r.Header.Add(egressTargetHeader, "https://allowed.example.com") }, http.StatusForbidden}, + {"http_target", func(r *http.Request) { r.Header.Set(egressTargetHeader, "http://allowed.example.com") }, http.StatusForbidden}, + {"private_target", func(r *http.Request) { r.Header.Set(egressTargetHeader, "https://127.0.0.1") }, http.StatusForbidden}, + {"other_target", func(r *http.Request) { r.Header.Set(egressTargetHeader, "https://other.example.com") }, http.StatusForbidden}, + {"other_port", func(r *http.Request) { r.Header.Set(egressTargetHeader, "https://allowed.example.com:8443") }, http.StatusForbidden}, + {"oversized_body", func(r *http.Request) { r.ContentLength = maxEgressRequestBytes + 1 }, http.StatusRequestEntityTooLarge}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/relay", nil) + request.Host = "plugin-egress" + request.Header.Set(egressTokenHeader, token) + request.Header.Set(egressTargetHeader, "https://allowed.example.com/send") + item.change(request) + response := httptest.NewRecorder() + NewPluginEgressHandler(token, func() []NetworkTarget { return allowed }).ServeHTTP(response, request) + if response.Code != item.status { + t.Fatalf("unexpected rejection status: %d", response.Code) + } + }) + } +} + +func TestPluginEgressEmptyAuthorizationFailsClosed(t *testing.T) { + for _, authorize := range []func() []NetworkTarget{ + nil, + func() []NetworkTarget { return nil }, + func() []NetworkTarget { return []NetworkTarget{{Scheme: "invalid", Host: "allowed.example.com"}} }, + } { + token := strings.Repeat("a", 64) + request := httptest.NewRequest(http.MethodPost, "/relay", nil) + request.Host = "plugin-egress" + request.Header.Set(egressTokenHeader, token) + request.Header.Set(egressTargetHeader, "https://allowed.example.com/send") + response := httptest.NewRecorder() + NewPluginEgressHandler(token, authorize).ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("empty authorization was accepted: %d", response.Code) + } + } +} + +func TestPluginEgressRechecksAuthorizationBeforeOutbound(t *testing.T) { + token := strings.Repeat("a", 64) + calls := 0 + handler := NewPluginEgressHandler(token, func() []NetworkTarget { + calls++ + if calls > 1 { + return nil + } + return []NetworkTarget{{Scheme: "https", Host: "allowed.example.com", Port: 443}} + }) + request := httptest.NewRequest(http.MethodPost, "/relay", strings.NewReader("payload")) + request.Host = "plugin-egress" + request.Header.Set(egressTokenHeader, token) + request.Header.Set(egressTargetHeader, "https://allowed.example.com/send") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden || calls != 2 { + t.Fatalf("authorization was not rechecked: status=%d calls=%d", response.Code, calls) + } +} + +func TestStripEgressHeaders(t *testing.T) { + header := make(http.Header) + for _, name := range []string{egressTokenHeader, egressTargetHeader, "Proxy-Authorization", "X-Hop", "Upgrade"} { + header.Set(name, "sensitive") + } + header.Set("Connection", "X-Hop, Upgrade") + header.Set("Content-Type", "application/json") + stripEgressHeaders(header) + if len(header) != 1 || header.Get("Content-Type") != "application/json" { + t.Fatal("internal or hop-by-hop headers survived filtering") + } +} + +type egressTrackedBody struct { + io.Reader + closed bool +} + +func (b *egressTrackedBody) Close() error { + b.closed = true + return nil +} + +func TestRestrictedTransportsCloseRejectedRequestBody(t *testing.T) { + for _, transport := range []http.RoundTripper{restrictedRoundTripper{}, &pluginEgressTransport{}} { + body := &egressTrackedBody{Reader: strings.NewReader("payload")} + request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/", body) + if _, err := transport.RoundTrip(request); err == nil || !body.closed { + t.Fatal("rejected request body was not closed") + } + } +} + +type egressIdleTransport struct { + http.RoundTripper + closed bool +} + +func (t *egressIdleTransport) CloseIdleConnections() { t.closed = true } + +func TestRestrictedTransportForwardsIdleConnectionCleanup(t *testing.T) { + transport := &egressIdleTransport{} + client := &http.Client{Transport: restrictedRoundTripper{transport: &pluginEgressTransport{transport: transport}}} + client.CloseIdleConnections() + if !transport.closed { + t.Fatal("idle connection cleanup was not forwarded") + } +} diff --git a/backend/utils/credential/egress.go b/backend/utils/credential/egress.go new file mode 100644 index 0000000..4984763 --- /dev/null +++ b/backend/utils/credential/egress.go @@ -0,0 +1,191 @@ +package credential + +import ( + "bytes" + "context" + "crypto/subtle" + "errors" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const ( + PluginEgressSocket = "/egress.socket" + egressTokenHeader = "X-Smail-Egress-Token" + egressTargetHeader = "X-Smail-Egress-Target" + maxEgressRequestBytes int64 = 32 << 20 + maxEgressResponseBytes int64 = 8 << 20 +) + +type pluginEgressTransport struct { + transport http.RoundTripper + token string +} + +func newPluginEgressTransport(timeout time.Duration) http.RoundTripper { + return &pluginEgressTransport{ + token: os.Getenv("SMAIL_PLUGIN_EGRESS_TOKEN"), + transport: &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: timeout}).DialContext(ctx, "unix", PluginEgressSocket) + }, + DisableKeepAlives: true, + ResponseHeaderTimeout: timeout, + MaxResponseHeaderBytes: 32 << 10, + }, + } +} + +func (t *pluginEgressTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if len(t.token) != 64 { + closeRequestBody(request) + return nil, errors.New("plugin egress authorization is unavailable") + } + if request == nil || request.URL == nil { + closeRequestBody(request) + return nil, errors.New("invalid plugin egress request") + } + // 目标仅在本地通道传递,主进程仍独立校验,不信任插件侧校验结果。 + envelope := request.Clone(request.Context()) + envelope.URL = &url.URL{Scheme: "http", Host: "plugin-egress", Path: "/relay"} + envelope.Host = "plugin-egress" + envelope.RequestURI = "" + if envelope.Header == nil { + envelope.Header = make(http.Header) + } + envelope.Header.Set(egressTokenHeader, t.token) + envelope.Header.Set(egressTargetHeader, request.URL.String()) + response, err := t.transport.RoundTrip(envelope) + if err != nil { + return nil, errors.New("plugin egress channel is unavailable") + } + response.Request = request + return response, nil +} + +func (t *pluginEgressTransport) CloseIdleConnections() { + if transport, ok := t.transport.(interface{ CloseIdleConnections() }); ok { + transport.CloseIdleConnections() + } +} + +// NewPluginEgressHandler 只供沙箱专属本地通道使用,不注册到公网路由。 +// 每次重新取权限;空权限拒绝访问,禁止继承通用客户端的空名单语义。 +func NewPluginEgressHandler(token string, authorize func() []NetworkTarget) http.Handler { + slots := make(chan struct{}, 2) + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if len(token) != 64 || len(request.Header.Values(egressTokenHeader)) != 1 || + subtle.ConstantTimeCompare([]byte(request.Header.Get(egressTokenHeader)), []byte(token)) != 1 { + http.Error(writer, "出站认证失败", http.StatusForbidden) + return + } + if request.URL.Path != "/relay" || request.URL.RawQuery != "" || request.URL.IsAbs() || + request.Host != "plugin-egress" || request.Header.Get("Upgrade") != "" || + (request.Method != http.MethodGet && request.Method != http.MethodPost && request.Method != http.MethodHead) { + http.Error(writer, "不支持的出站请求", http.StatusBadRequest) + return + } + select { + case slots <- struct{}{}: + defer func() { <-slots }() + default: + http.Error(writer, "出站请求繁忙", http.StatusTooManyRequests) + return + } + var targets []NetworkTarget + if authorize != nil { + targets = normalizeNetworkTargets(authorize()) + } + if len(targets) == 0 || len(request.Header.Values(egressTargetHeader)) != 1 { + http.Error(writer, "出站目标未获授权", http.StatusForbidden) + return + } + rawTarget := request.Header.Get(egressTargetHeader) + if _, err := NormalizeHTTPSURL(rawTarget); err != nil { + http.Error(writer, "出站目标无效", http.StatusForbidden) + return + } + endpoint, err := url.Parse(rawTarget) + if err != nil || !matchesNetworkTarget(endpoint, targets) { + http.Error(writer, "出站目标未获授权", http.StatusForbidden) + return + } + if request.ContentLength > maxEgressRequestBytes { + http.Error(writer, "出站请求过大", http.StatusRequestEntityTooLarge) + return + } + // 先读取有界请求体,防止未知长度请求在越限前已部分提交给上游。 + body, err := io.ReadAll(http.MaxBytesReader(writer, request.Body, maxEgressRequestBytes)) + if err != nil { + http.Error(writer, "出站请求体无效或过大", http.StatusRequestEntityTooLarge) + return + } + // 请求体读取期间权限可能被撤销,连接上游前再次按当前配置核对。 + targets = normalizeNetworkTargets(authorize()) + if len(targets) == 0 || !matchesNetworkTarget(endpoint, targets) { + http.Error(writer, "出站目标未获授权", http.StatusForbidden) + return + } + ctx, cancel := context.WithTimeout(request.Context(), 30*time.Second) + defer cancel() + outbound, err := http.NewRequestWithContext(ctx, request.Method, endpoint.String(), bytes.NewReader(body)) + if err != nil { + http.Error(writer, "出站请求无效", http.StatusBadRequest) + return + } + outbound.Header = request.Header.Clone() + stripEgressHeaders(outbound.Header) + client := newRestrictedHTTPClientForTargets(30_000, targets, false) + // 每次请求使用独立连接,避免配置撤销后继续复用已有上游连接。 + if restricted, ok := client.Transport.(restrictedRoundTripper); ok { + if transport, ok := restricted.transport.(*http.Transport); ok { + transport.DisableKeepAlives = true + transport.MaxResponseHeaderBytes = 32 << 10 + } + } + defer client.CloseIdleConnections() + // 重定向交还调用方,每一跳重新通过身份、权限与公网地址检查。 + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + response, err := client.Do(outbound) + if err != nil { + // 原始错误可能包含上游 URL 中的令牌,绝不回传或记录。 + http.Error(writer, "获准目标连接失败", http.StatusBadGateway) + return + } + defer response.Body.Close() + payload, err := io.ReadAll(io.LimitReader(response.Body, maxEgressResponseBytes+1)) + if err != nil || int64(len(payload)) > maxEgressResponseBytes { + http.Error(writer, "上游响应无效或过大", http.StatusBadGateway) + return + } + stripEgressHeaders(response.Header) + response.Header.Del("Content-Length") + for name, values := range response.Header { + for _, value := range values { + writer.Header().Add(name, value) + } + } + writer.WriteHeader(response.StatusCode) + _, _ = writer.Write(payload) + }) +} + +func stripEgressHeaders(header http.Header) { + for _, value := range header.Values("Connection") { + for _, name := range strings.Split(value, ",") { + header.Del(strings.TrimSpace(name)) + } + } + for _, name := range []string{ + "Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", + "TE", "Trailer", "Transfer-Encoding", "Upgrade", egressTokenHeader, egressTargetHeader, + } { + header.Del(name) + } +}