From 1d63e9fd1d6952e1d1cbf594778c66a4ff86adf2 Mon Sep 17 00:00:00 2001 From: smail Date: Sat, 3 Oct 2026 20:42:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DTelegram=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E4=B8=8E=E6=B2=99=E7=AE=B1=E6=8E=A8=E9=80=81=E6=9C=AA=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=9C=AC=E6=9C=BA=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/hooks/plugin_egress_linux.go | 21 +++- backend/services/plugin/telegram_binding.go | 22 +++- backend/utils/credential/credential.go | 47 ++++++++ backend/utils/credential/credential_test.go | 103 ++++++++++++++++++ backend/utils/credential/egress.go | 17 +++ plugins/pmail_telegram_push/hook/bot.go | 12 +- plugins/pmail_telegram_push/hook/runtime.go | 8 +- .../pmail_telegram_push/hook/runtime_test.go | 20 +++- 8 files changed, 233 insertions(+), 17 deletions(-) diff --git a/backend/hooks/plugin_egress_linux.go b/backend/hooks/plugin_egress_linux.go index fd3892fb..0e4be2f4 100644 --- a/backend/hooks/plugin_egress_linux.go +++ b/backend/hooks/plugin_egress_linux.go @@ -4,6 +4,7 @@ package hooks import ( "context" + "encoding/json" "errors" "io" "log" @@ -45,8 +46,24 @@ func startPluginEgress(spec *pluginLaunchSpec, uid, gid int) error { return errors.New("plugin egress socket identity failed") } ctx, cancel := context.WithCancel(context.Background()) + var telegramProxy func() (string, error) + if logicalID == "telegram_push" { + telegramProxy = func() (string, error) { + instance, _, ok := pluginInstanceForStartup(logicalID) + if !ok || validatePluginRuntimeBinding(logicalID, instance) != nil { + return "", errors.New("Telegram plugin configuration is unavailable") + } + var settings struct { + Proxy string `json:"proxy"` + } + if err := json.Unmarshal(instance.Settings, &settings); err != nil { + return "", errors.New("Telegram plugin configuration is invalid") + } + return credential.NormalizeTelegramProxyURL(settings.Proxy) + } + } server := &http.Server{ - Handler: credential.NewPluginEgressHandler(token, func() []credential.NetworkTarget { + Handler: credential.NewPluginEgressHandlerWithTelegramProxy(token, func() []credential.NetworkTarget { instance, _, ok := pluginInstanceForStartup(logicalID) if !ok || validatePluginRuntimeBinding(logicalID, instance) != nil { return nil @@ -65,7 +82,7 @@ func startPluginEgress(spec *pluginLaunchSpec, uid, gid int) error { } } return targets - }), + }, telegramProxy), BaseContext: func(net.Listener) context.Context { return ctx }, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, diff --git a/backend/services/plugin/telegram_binding.go b/backend/services/plugin/telegram_binding.go index 4723b0eb..b57b2680 100644 --- a/backend/services/plugin/telegram_binding.go +++ b/backend/services/plugin/telegram_binding.go @@ -50,6 +50,8 @@ type telegramBinding struct { type telegramBindingConfig struct { Token string + Proxy string + TimeoutMS int BotID string Callback string Secret string @@ -68,6 +70,8 @@ func (s *ConfigService) telegramConfiguration() (telegramBindingConfig, error) { } var settings struct { WebDomain string `json:"webDomain"` + Proxy string `json:"proxy"` + Timeout int `json:"timeout"` } domain := strings.TrimSpace(current.WebDomain) parsed, err := url.Parse("https://" + domain) @@ -76,6 +80,16 @@ func (s *ConfigService) telegramConfiguration() (telegramBindingConfig, error) { !strings.EqualFold(settings.WebDomain, domain) { return result, TelegramNotice("机器人网页域名与后台域名不一致,请联系超级管理员") } + proxyURL, err := credential.NormalizeTelegramProxyURL(settings.Proxy) + if err != nil { + return result, TelegramNotice(err.Error()) + } + if settings.Timeout == 0 { + settings.Timeout = 30 + } + if settings.Timeout < 1 || settings.Timeout > 120 { + return result, TelegramNotice("Telegram 请求超时时间无效") + } token, err := s.openSecretForPlugin(telegramPluginID, "botToken") parts := telegramTokenPattern.FindStringSubmatch(token) if err != nil || len(parts) != 2 { @@ -90,7 +104,7 @@ func (s *ConfigService) telegramConfiguration() (telegramBindingConfig, error) { return result, ErrPluginStoreUnavailable } // 回调地址只携带配置标识,不包含机器人令牌或请求认证密钥。 - return telegramBindingConfig{Token: token, BotID: parts[1], Secret: secret, Fingerprint: fingerprint, + return telegramBindingConfig{Token: token, Proxy: proxyURL, TimeoutMS: settings.Timeout * 1000, BotID: parts[1], Secret: secret, Fingerprint: fingerprint, Callback: "https://" + domain + TelegramCallbackPath + "?v=" + fingerprint[:16]}, nil } @@ -107,7 +121,11 @@ func requestTelegramBindingJSON(ctx context.Context, configuration telegramBindi return ErrTelegramBinding } request.Header.Set("Content-Type", "application/json") - client := credential.NewRestrictedHTTPClientForTargets(10_000, []credential.NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}}) + client, err := credential.NewTelegramHTTPClient(configuration.TimeoutMS, configuration.Proxy) + if err != nil { + return TelegramNotice(err.Error()) + } + defer client.CloseIdleConnections() response, err := client.Do(request) if err != nil { return TelegramNotice("无法连接 Telegram,请稍后重试") diff --git a/backend/utils/credential/credential.go b/backend/utils/credential/credential.go index c30830c6..6bc3ef66 100644 --- a/backend/utils/credential/credential.go +++ b/backend/utils/credential/credential.go @@ -105,6 +105,53 @@ func NewRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget) * return newRestrictedHTTPClientForTargets(timeoutMS, targets, os.Getenv("SMAIL_PLUGIN_SANDBOX") == "1") } +// NormalizeTelegramProxyURL 仅接受超管指定的本机 HTTP 代理,不解析代理域名。 +func NormalizeTelegramProxyURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + endpoint, err := url.Parse(raw) + if err != nil || endpoint.Scheme != "http" || endpoint.User != nil || endpoint.Path != "" || + endpoint.RawQuery != "" || endpoint.ForceQuery || strings.ContainsRune(raw, '#') || len(raw) > 256 { + return "", errors.New("Telegram 代理仅支持带端口的本机 HTTP 地址,如 http://127.0.0.1:9000") + } + address, addressErr := netip.ParseAddr(endpoint.Hostname()) + port, portErr := strconv.Atoi(endpoint.Port()) + if addressErr != nil || !address.IsLoopback() || address.Zone() != "" || portErr != nil || port < 1 || port > 65535 { + return "", errors.New("Telegram 代理仅支持带端口的本机 HTTP 地址,如 http://127.0.0.1:9000") + } + return "http://" + net.JoinHostPort(address.String(), strconv.Itoa(port)), nil +} + +// NewTelegramHTTPClient 将代理权限限定为 Telegram;沙箱始终由主进程代为出站。 +func NewTelegramHTTPClient(timeoutMS int, proxyURL string) (*http.Client, error) { + return newTelegramHTTPClient(timeoutMS, proxyURL, os.Getenv("SMAIL_PLUGIN_SANDBOX") == "1") +} + +func newTelegramHTTPClient(timeoutMS int, proxyURL string, sandbox bool) (*http.Client, error) { + proxyURL, err := NormalizeTelegramProxyURL(proxyURL) + if err != nil { + return nil, err + } + targets := []NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}} + client := newRestrictedHTTPClientForTargets(timeoutMS, targets, sandbox) + if proxyURL == "" || sandbox { + return client, nil + } + endpoint, _ := url.Parse(proxyURL) + transport := client.Transport.(restrictedRoundTripper).transport.(*http.Transport) + transport.Proxy = http.ProxyURL(endpoint) + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + if address != endpoint.Host { + return nil, errors.New("Telegram 代理连接目标不匹配") + } + return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, endpoint.Host) + } + // HTTPS CONNECT 后仍验证 Telegram 的系统可信证书,不接受其他目标或明文请求。 + return client, nil +} + func newRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget, sandbox bool) *http.Client { timeout := time.Duration(NormalizeTimeoutMS(timeoutMS)) * time.Millisecond allowedTargets := normalizeNetworkTargets(targets) diff --git a/backend/utils/credential/credential_test.go b/backend/utils/credential/credential_test.go index 7fabfb1e..39563005 100644 --- a/backend/utils/credential/credential_test.go +++ b/backend/utils/credential/credential_test.go @@ -1,6 +1,8 @@ package credential import ( + "context" + "errors" "io" "net/http" "net/http/httptest" @@ -9,6 +11,107 @@ import ( "testing" ) +func TestTelegramProxyValidation(t *testing.T) { + for _, raw := range []string{"", "http://127.0.0.1:9000", "http://[::1]:8080"} { + if _, err := NormalizeTelegramProxyURL(raw); err != nil { + t.Fatalf("valid proxy rejected: %q", raw) + } + } + for _, raw := range []string{ + "http://localhost:9000", "http://10.0.0.1:9000", "http://8.8.8.8:9000", + "https://127.0.0.1:9000", "socks5://127.0.0.1:9000", "http://127.0.0.1", + "http://127.0.0.1:0", "http://127.0.0.1:65536", "http://user:pass@127.0.0.1:9000", + "http://127.0.0.1:9000/", "http://127.0.0.1:9000?", "http://127.0.0.1:9000#", + "http://[::1%25lo]:9000", + } { + if _, err := NormalizeTelegramProxyURL(raw); err == nil { + t.Fatalf("invalid proxy accepted: %q", raw) + } + } +} + +func TestTelegramProxyRetainsTargetAndTLSRestrictions(t *testing.T) { + client, err := newTelegramHTTPClient(30000, "http://127.0.0.1:9000", false) + if err != nil { + t.Fatal(err) + } + defer client.CloseIdleConnections() + transport := client.Transport.(restrictedRoundTripper).transport.(*http.Transport) + request := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot-test/getMe", nil) + proxy, err := transport.Proxy(request) + if err != nil || proxy.String() != "http://127.0.0.1:9000" { + t.Fatal("configured proxy was not applied") + } + if transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("TLS verification was disabled") + } + if connection, err := transport.DialContext(context.Background(), "tcp", "127.0.0.1:9001"); err == nil { + connection.Close() + t.Fatal("dial escaped configured proxy") + } + for _, target := range []string{"https://example.com/", "https://127.0.0.1/", "http://api.telegram.org/", "https://api.telegram.org:444/"} { + request := httptest.NewRequest(http.MethodGet, target, nil) + if _, err := client.Transport.RoundTrip(request); err == nil { + t.Fatalf("unauthorized target accepted: %s", target) + } + if err := client.CheckRedirect(request, nil); err == nil { + t.Fatalf("unauthorized redirect accepted: %s", target) + } + } +} + +func TestTelegramSandboxNeverDialsProxyDirectly(t *testing.T) { + client, err := newTelegramHTTPClient(30000, "http://127.0.0.1:9000", true) + if err != nil { + t.Fatal(err) + } + defer client.CloseIdleConnections() + if _, ok := client.Transport.(restrictedRoundTripper).transport.(*pluginEgressTransport); !ok { + t.Fatal("sandbox escaped host-managed egress") + } +} + +func TestTelegramProxyUsesConnectWithoutSendingTokenInPlaintext(t *testing.T) { + requests := make(chan string, 1) + proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests <- request.Method + " " + request.RequestURI + writer.WriteHeader(http.StatusForbidden) + })) + defer proxy.Close() + client, err := newTelegramHTTPClient(1000, proxy.URL, false) + if err != nil { + t.Fatal(err) + } + defer client.CloseIdleConnections() + if _, err := client.Get("https://api.telegram.org/bot-secret/getMe"); err == nil { + t.Fatal("failed CONNECT was accepted") + } + select { + case request := <-requests: + if request != "CONNECT api.telegram.org:443" { + t.Fatalf("unexpected proxy request: %s", request) + } + default: + t.Fatal("proxy was not contacted") + } +} + +func TestTelegramEgressRejectsInvalidProxyWithoutFallback(t *testing.T) { + token := strings.Repeat("a", 64) + handler := NewPluginEgressHandlerWithTelegramProxy(token, func() []NetworkTarget { + return []NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}} + }, func() (string, error) { return "", errors.New("private configuration detail") }) + request := httptest.NewRequest(http.MethodPost, "/relay", strings.NewReader("{}")) + request.Host = "plugin-egress" + request.Header.Set(egressTokenHeader, token) + request.Header.Set(egressTargetHeader, "https://api.telegram.org/bot-secret/getMe") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden || strings.Contains(response.Body.String(), "private") { + t.Fatal("invalid proxy did not fail closed with a sanitized error") + } +} + func TestValidateSecret(t *testing.T) { value, err := ValidateSecret(" token-value ", 64) if err != nil || value != "token-value" { diff --git a/backend/utils/credential/egress.go b/backend/utils/credential/egress.go index 4984763e..f3c38e01 100644 --- a/backend/utils/credential/egress.go +++ b/backend/utils/credential/egress.go @@ -78,6 +78,11 @@ func (t *pluginEgressTransport) CloseIdleConnections() { // NewPluginEgressHandler 只供沙箱专属本地通道使用,不注册到公网路由。 // 每次重新取权限;空权限拒绝访问,禁止继承通用客户端的空名单语义。 func NewPluginEgressHandler(token string, authorize func() []NetworkTarget) http.Handler { + return NewPluginEgressHandlerWithTelegramProxy(token, authorize, nil) +} + +// NewPluginEgressHandlerWithTelegramProxy 的代理只能由主进程读取,插件请求不能指定代理。 +func NewPluginEgressHandlerWithTelegramProxy(token string, authorize func() []NetworkTarget, telegramProxy func() (string, error)) 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 || @@ -142,6 +147,18 @@ func NewPluginEgressHandler(token string, authorize func() []NetworkTarget) http outbound.Header = request.Header.Clone() stripEgressHeaders(outbound.Header) client := newRestrictedHTTPClientForTargets(30_000, targets, false) + if telegramProxy != nil { + proxyURL, proxyErr := telegramProxy() + if proxyErr != nil { + http.Error(writer, "Telegram 代理配置无效", http.StatusForbidden) + return + } + client, err = newTelegramHTTPClient(30_000, proxyURL, false) + if err != nil { + http.Error(writer, "Telegram 代理配置无效", http.StatusForbidden) + return + } + } // 每次请求使用独立连接,避免配置撤销后继续复用已有上游连接。 if restricted, ok := client.Transport.(restrictedRoundTripper); ok { if transport, ok := restricted.transport.(*http.Transport); ok { diff --git a/plugins/pmail_telegram_push/hook/bot.go b/plugins/pmail_telegram_push/hook/bot.go index 6592ace2..9437c113 100644 --- a/plugins/pmail_telegram_push/hook/bot.go +++ b/plugins/pmail_telegram_push/hook/bot.go @@ -19,16 +19,10 @@ func NewBot(config pluginSettings, token string) (*bot.Bot, error) { }), ) } - if config.Proxy != "" { - return nil, errUnsupportedProxy + httpClient, err := credential.NewTelegramHTTPClient(config.Timeout*1000, config.Proxy) + if err != nil { + return nil, err } - httpClient := credential.NewRestrictedHTTPClientForTargets(config.Timeout*1000, []credential.NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}}) opts = append(opts, bot.WithHTTPClient(timeout, httpClient)) return bot.New(token, opts...) } - -var errUnsupportedProxy = &unsupportedProxyError{} - -type unsupportedProxyError struct{} - -func (*unsupportedProxyError) Error() string { return "不支持的代理协议" } diff --git a/plugins/pmail_telegram_push/hook/runtime.go b/plugins/pmail_telegram_push/hook/runtime.go index df8473d1..a26d38ef 100644 --- a/plugins/pmail_telegram_push/hook/runtime.go +++ b/plugins/pmail_telegram_push/hook/runtime.go @@ -7,6 +7,8 @@ import ( "regexp" "strings" "sync" + + "git.pcux.cn/xuans/Smail/utils/credential" ) const ( @@ -162,9 +164,11 @@ func parseRuntimeConfig(data json.RawMessage) (pluginRuntimeConfig, pluginSettin if settings.Timeout < 1 || settings.Timeout > maxTimeout { return pluginRuntimeConfig{}, pluginSettings{}, "", errors.New("Telegram 请求超时时间无效") } - if settings.Proxy != "" { - return pluginRuntimeConfig{}, pluginSettings{}, "", errors.New("Telegram 插件不允许使用未授权代理") + proxyURL, err := credential.NormalizeTelegramProxyURL(settings.Proxy) + if err != nil { + return pluginRuntimeConfig{}, pluginSettings{}, "", err } + settings.Proxy = proxyURL secret := strings.TrimSpace(runtime.Secrets["botToken"]) if secret == "" { return pluginRuntimeConfig{}, pluginSettings{}, "", errors.New("Telegram Bot Token 未配置") diff --git a/plugins/pmail_telegram_push/hook/runtime_test.go b/plugins/pmail_telegram_push/hook/runtime_test.go index 34f93944..97b24df9 100644 --- a/plugins/pmail_telegram_push/hook/runtime_test.go +++ b/plugins/pmail_telegram_push/hook/runtime_test.go @@ -48,6 +48,22 @@ func TestParseRuntimeConfigRejectsZeroGeneration(t *testing.T) { } } +func TestParseRuntimeConfigAcceptsHostManagedLocalProxy(t *testing.T) { + var payload pluginRuntimeConfig + if err := json.Unmarshal(validRuntimePayload(), &payload); err != nil { + t.Fatal(err) + } + payload.Settings = json.RawMessage(`{"proxy":"http://127.0.0.1:9000","timeout":30}`) + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + _, settings, _, err := parseRuntimeConfig(data) + if err != nil || settings.Proxy != "http://127.0.0.1:9000" { + t.Fatal("host-managed local proxy was rejected") + } +} + func TestParseRuntimeConfigRejectsSecurityBoundaryViolations(t *testing.T) { tests := []struct { name string @@ -70,8 +86,8 @@ func TestParseRuntimeConfigRejectsSecurityBoundaryViolations(t *testing.T) { payload.Network = append(payload.Network, pluginNetworkTarget{Scheme: "https", Host: "example.test", Port: 443}) }, wantError: "Telegram \u7f51\u7edc\u6743\u9650\u672a\u914d\u7f6e"}, {name: "proxy bypass", mutate: func(payload *pluginRuntimeConfig) { - payload.Settings = json.RawMessage(`{"proxy":"http://127.0.0.1:8080"}`) - }, wantError: "Telegram \u63d2\u4ef6\u4e0d\u5141\u8bb8\u4f7f\u7528\u672a\u6388\u6743\u4ee3\u7406"}, + payload.Settings = json.RawMessage(`{"proxy":"http://192.168.1.1:8080"}`) + }, wantError: "Telegram 代理仅支持带端口的本机 HTTP 地址"}, {name: "timeout too large", mutate: func(payload *pluginRuntimeConfig) { payload.Settings = json.RawMessage(`{"timeout":121}`) }, wantError: "Telegram \u8bf7\u6c42\u8d85\u65f6\u65f6\u95f4\u65e0\u6548"}, {name: "unknown setting", mutate: func(payload *pluginRuntimeConfig) { payload.Settings = json.RawMessage(`{"unknown":true}`) }, wantError: "Telegram \u63d2\u4ef6\u914d\u7f6e\u65e0\u6548"}, }