From 2c843d82a44a67643c94832c4ace7bd02c763802 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:41:54 +0800 Subject: [PATCH] FIX #63 --- internal/server/settings_api.go | 24 +++++++++++++++-- internal/server/settings_api_test.go | 40 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index a8aa2b6..cbc6e48 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -295,7 +295,7 @@ func validateNotificationField( } } if name == "proxy" && value != "" { - if _, err := parseOutboundURL(value, false); err != nil { + if _, err := parseProxyURL(value); err != nil { return fmt.Errorf("%s is not a valid HTTP URL", field) } } @@ -989,7 +989,7 @@ func validateOutboundURL( } func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, error) { - parsed, err := parseOutboundURL(raw, false) + parsed, err := parseProxyURL(raw) if err != nil { return nil, err } @@ -999,6 +999,26 @@ func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, er return parsed, nil } +// parseProxyURL parses an HTTP(S) proxy URL. Unlike parseOutboundURL, it +// permits embedded userinfo (http://user:pass@host:port) because HTTP proxies +// commonly authenticate with Proxy-Authorization derived from the URL. +func parseProxyURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false { + return nil, errors.New("proxy must be an absolute HTTP URL") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, errors.New("proxy URL must use HTTP or HTTPS") + } + if parsed.Port() != "" { + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port < 1 || port > 65535 { + return nil, errors.New("proxy URL has an invalid port") + } + } + return parsed, nil +} + func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false { diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 858ead0..0de5769 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -420,6 +420,11 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) { body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`, code: "invalid_notification_config", }, + { + name: "webhook URL with embedded credentials", + body: `{"webhook":{"enabled":true,"urls":["http://user:pass@example.com"]}}`, + code: "invalid_notification_config", + }, { name: "null body", body: `null`, @@ -994,6 +999,41 @@ func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) { } } +func TestNotificationProxyAcceptsAuthenticatedURL(t *testing.T) { + test := newSettingsAPITest(t) + body := `{"telegram":{"enabled":true,"bot_token":"123456:abc","chat_id":"1","proxy":"http://user:password@127.0.0.1:8080"}}` + recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body) + } + response := decodeSettingsResponse(t, recorder) + data, ok := response["data"].(map[string]any) + if !ok { + t.Fatalf("data missing: %#v", response) + } + telegram, ok := data["telegram"].(map[string]any) + if !ok { + t.Fatalf("telegram response missing: %#v", data) + } + if telegram["proxy"] != "http://user:password@127.0.0.1:8080" { + t.Fatalf("proxy not preserved: %#v", telegram["proxy"]) + } +} + +func TestNotificationProxyRejectsMalformedURL(t *testing.T) { + test := newSettingsAPITest(t) + body := `{"telegram":{"enabled":true,"bot_token":"123456:abc","chat_id":"1","proxy":"not-a-url"}}` + recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body) + } + response := decodeSettingsResponse(t, recorder) + detail, ok := response["error"].(map[string]any) + if !ok || detail["code"] != "invalid_notification_config" { + t.Fatalf("error = %#v", detail) + } +} + func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) { test := newSettingsAPITest(t) request := httptest.NewRequest(http.MethodGet, "/api/not-settings", nil)