From 707ca3c12449bd3fe5294d5743189399c17f294f Mon Sep 17 00:00:00 2001 From: MengMengCode Date: Tue, 11 Aug 2026 02:02:55 +0800 Subject: [PATCH] feat: enhance URL validation and email address parsing; refactor related components --- internal/device/es9p.go | 46 +++-- internal/device/es9p_test.go | 27 ++- internal/device/esim_download.go | 5 +- internal/exportproxy/bind_linux.go | 36 +++- internal/exportproxy/bind_linux_test.go | 18 ++ internal/extensions/manager.go | 18 +- internal/extensions/manager_test.go | 18 ++ internal/netguard/public.go | 170 ++++++++++++++++++ internal/netguard/public_test.go | 29 +++ .../server/automatic_task_notifications.go | 6 +- internal/server/extensions_api.go | 2 +- internal/server/server.go | 5 +- internal/server/settings_api.go | 35 +++- internal/server/settings_api_test.go | 20 +++ internal/server/sms_notifications.go | 6 +- internal/vowifi/ims/digest.go | 42 ++--- internal/vowifi/ims/digest_test.go | 22 +-- internal/vowifi/ims/provider.go | 24 +-- 18 files changed, 434 insertions(+), 95 deletions(-) create mode 100644 internal/exportproxy/bind_linux_test.go create mode 100644 internal/netguard/public.go create mode 100644 internal/netguard/public_test.go diff --git a/internal/device/es9p.go b/internal/device/es9p.go index 0789fc8..b9acaf9 100644 --- a/internal/device/es9p.go +++ b/internal/device/es9p.go @@ -3,14 +3,17 @@ package device import ( "bytes" "context" - "crypto/tls" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" "strings" "time" + + "vocat/internal/netguard" ) // es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the @@ -24,25 +27,31 @@ import ( // header.functionExecutionStatus (with statusCodeData.message holding the // human-readable failure, e.g. "The matchingID is not found"). type es9pClient struct { - smdp string - http *http.Client + smdp string + endpoint *url.URL + http *http.Client } -func newES9PClient(smdp string) *es9pClient { - // The eUICC — not the host — is the root of trust for RSP: during - // AuthenticateServer the card verifies the SM-DP+'s CERT.DPauth.SIG against - // its embedded CI root, so a rogue/TLS-MitM server cannot forge a signature - // the card will accept. The host TLS layer is transport only, and a minimal - // embedded box may ship no CA bundle (this is exactly what broke on the test - // machine), so we don't anchor host TLS to system roots. InsecureSkipVerify - // is safe here specifically because the card does the authoritative check. - transport := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // eUICC is the RSP trust anchor +func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) { + smdp = strings.TrimSpace(smdp) + if smdp == "" || strings.Contains(smdp, "://") { + return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port") + } + candidate, err := url.Parse("https://" + smdp) + if err != nil || candidate.Hostname() == "" || candidate.User != nil || + (candidate.Path != "" && candidate.Path != "/") || candidate.RawQuery != "" || candidate.Fragment != "" { + return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port") + } + candidate.Path = "" + validated, err := netguard.ValidatePublicURL(ctx, candidate.String(), true) + if err != nil { + return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err) } return &es9pClient{ - smdp: strings.TrimSpace(smdp), - http: &http.Client{Timeout: 90 * time.Second, Transport: transport}, - } + smdp: validated.Host, + endpoint: validated, + http: netguard.NewPublicHTTPClient(90*time.Second, true), + }, nil } // es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s @@ -80,12 +89,13 @@ type es9pStatusCodeData struct { // is decided the way lpac decides it: a non-success execution status, or a // missing required output field, yields an es9pError carrying the SM-DP+ message. func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) { - url := "https://" + c.smdp + "/gsma/rsp2/es9plus/" + function + endpoint := *c.endpoint + endpoint.Path = "/gsma/rsp2/es9plus/" + function body, err := json.Marshal(request) if err != nil { return nil, err } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body)) if err != nil { return nil, err } diff --git a/internal/device/es9p_test.go b/internal/device/es9p_test.go index 832f964..a41616c 100644 --- a/internal/device/es9p_test.go +++ b/internal/device/es9p_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "strings" "testing" ) @@ -16,9 +17,15 @@ func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient { t.Helper() server := httptest.NewTLSServer(handler) t.Cleanup(server.Close) - client := newES9PClient(strings.TrimPrefix(server.URL, "https://")) - client.http = server.Client() - return client + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + return &es9pClient{ + smdp: strings.TrimPrefix(server.URL, "https://"), + endpoint: endpoint, + http: server.Client(), + } } func successEnvelope(fields map[string]any) map[string]any { @@ -33,6 +40,20 @@ func successEnvelope(fields map[string]any) map[string]any { func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) } +func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) { + for _, address := range []string{ + "https://rsp.example.com", + "127.0.0.1", + "169.254.169.254", + "rsp.example.com/unexpected/path", + "user:password@rsp.example.com", + } { + if _, err := newES9PClient(context.Background(), address); err == nil { + t.Errorf("newES9PClient(%q) accepted an unsafe address", address) + } + } +} + func TestInitiateAuthenticationSuccess(t *testing.T) { signed1 := []byte{0x30, 0x03, 0x80, 0x01, 0x09} client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/device/esim_download.go b/internal/device/esim_download.go index e04e3be..7210741 100644 --- a/internal/device/esim_download.go +++ b/internal/device/esim_download.go @@ -74,7 +74,10 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para return nil, err } - client := newES9PClient(smdp) + client, err := newES9PClient(ctx, smdp) + if err != nil { + return nil, err + } report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30) init, err := client.initiateAuthentication(ctx, challenge, info1) diff --git a/internal/exportproxy/bind_linux.go b/internal/exportproxy/bind_linux.go index 74390a3..2408e6f 100644 --- a/internal/exportproxy/bind_linux.go +++ b/internal/exportproxy/bind_linux.go @@ -8,9 +8,9 @@ import ( "hash/fnv" "net" "os" - "path/filepath" "strings" "syscall" + "unicode" ) func platformSupported() error { return nil } @@ -54,14 +54,15 @@ func boundResolver(networkInterface string) *net.Resolver { } func exportRouteDNSServers(networkInterface string) []string { - safeName := strings.Map(func(character rune) rune { - if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || - character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' { - return character - } - return '_' - }, networkInterface) - file, err := os.Open(filepath.Join("/run/vocat", "cellular-"+safeName+".dns")) + if !validInterfaceName(networkInterface) { + return []string{"1.1.1.1", "8.8.8.8"} + } + root, err := os.OpenRoot("/run/vocat") + if err != nil { + return []string{"1.1.1.1", "8.8.8.8"} + } + defer root.Close() + file, err := root.Open("cellular-" + networkInterface + ".dns") if err != nil { return []string{"1.1.1.1", "8.8.8.8"} } @@ -78,3 +79,20 @@ func exportRouteDNSServers(networkInterface string) []string { } return servers } + +// Linux IFNAMSIZ is 16 including the terminator. Restricting names here both +// matches kernel interface names and prevents a stored device value from ever +// becoming a filesystem path component. +func validInterfaceName(value string) bool { + if value == "" || len(value) > 15 || value == "." || value == ".." { + return false + } + for _, character := range value { + if character > unicode.MaxASCII || !(character >= 'a' && character <= 'z' || + character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || + character == '-' || character == '_' || character == '.') { + return false + } + } + return true +} diff --git a/internal/exportproxy/bind_linux_test.go b/internal/exportproxy/bind_linux_test.go new file mode 100644 index 0000000..6db89b1 --- /dev/null +++ b/internal/exportproxy/bind_linux_test.go @@ -0,0 +1,18 @@ +//go:build linux + +package exportproxy + +import "testing" + +func TestValidInterfaceName(t *testing.T) { + for _, value := range []string{"wwan0", "wwp0s20f0u5i4", "rmnet_data0", "usb.1"} { + if !validInterfaceName(value) { + t.Errorf("validInterfaceName(%q) = false", value) + } + } + for _, value := range []string{"", ".", "..", "../wwan0", `..\wwan0`, "wwan0/evil", "interface-name-too-long"} { + if validInterfaceName(value) { + t.Errorf("validInterfaceName(%q) = true", value) + } + } +} diff --git a/internal/extensions/manager.go b/internal/extensions/manager.go index 33c2621..be1e2e7 100644 --- a/internal/extensions/manager.go +++ b/internal/extensions/manager.go @@ -26,6 +26,7 @@ import ( "time" "vocat/internal/exportproxy" + "vocat/internal/netguard" ) const maxPackageBytes int64 = 64 << 20 @@ -73,7 +74,7 @@ func NewManager(root string, logger *slog.Logger) (*Manager, error) { } manager := &Manager{ root: root, logger: logger, plugins: make(map[string]*Plugin), - client: &http.Client{Timeout: 45 * time.Second}, + client: netguard.NewPublicHTTPClient(45*time.Second, true), } if err := manager.scan(); err != nil { return nil, err @@ -155,9 +156,9 @@ func (manager *Manager) List() []Plugin { } func (manager *Manager) InstallURL(ctx context.Context, rawURL, expectedSHA string) (Plugin, error) { - parsed, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" { - return Plugin{}, errors.New("plugin URL must be an absolute HTTP or HTTPS URL") + parsed, err := netguard.ValidatePublicURL(ctx, rawURL, true) + if err != nil { + return Plugin{}, fmt.Errorf("plugin URL must be a public absolute HTTPS URL: %w", err) } request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) if err != nil { @@ -353,12 +354,13 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n http.NotFound(w, r) return } - filename := filepath.Join(plugin.dir, filepath.FromSlash(name)) - if !strings.HasPrefix(filepath.Clean(filename), filepath.Clean(plugin.dir)+string(os.PathSeparator)) { + root, err := os.OpenRoot(plugin.dir) + if err != nil { http.NotFound(w, r) return } - file, err := os.Open(filename) + defer root.Close() + file, err := root.Open(filepath.FromSlash(name)) if err != nil { http.NotFound(w, r) return @@ -369,7 +371,7 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n http.NotFound(w, r) return } - contentType := mime.TypeByExtension(filepath.Ext(filename)) + contentType := mime.TypeByExtension(filepath.Ext(name)) if contentType != "" { w.Header().Set("Content-Type", contentType) } diff --git a/internal/extensions/manager_test.go b/internal/extensions/manager_test.go index acacd8a..b6f1a82 100644 --- a/internal/extensions/manager_test.go +++ b/internal/extensions/manager_test.go @@ -3,12 +3,30 @@ package extensions import ( "archive/zip" "bytes" + "context" "io" "log/slog" "strings" "testing" ) +func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) { + manager, err := NewManager(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + defer manager.Close() + for _, raw := range []string{ + "http://example.com/plugin.zip", + "https://127.0.0.1/plugin.zip", + "https://169.254.169.254/latest/meta-data/", + } { + if _, err := manager.InstallURL(context.Background(), raw, ""); err == nil { + t.Errorf("InstallURL(%q) accepted an unsafe destination", raw) + } + } +} + func TestInstallListDisableAndUninstall(t *testing.T) { manager, err := NewManager(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { diff --git a/internal/netguard/public.go b/internal/netguard/public.go new file mode 100644 index 0000000..4b1a50a --- /dev/null +++ b/internal/netguard/public.go @@ -0,0 +1,170 @@ +package netguard + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "strconv" + "strings" + "time" +) + +// ValidatePublicURL accepts an absolute HTTP(S) URL only when every currently +// resolved address is publicly routable. The transport returned by +// NewPublicHTTPClient repeats the same check when it dials, which also prevents +// DNS rebinding between validation and connection establishment. +func ValidatePublicURL(ctx context.Context, raw string, requireHTTPS bool) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" { + return nil, errors.New("destination must be an absolute HTTP URL") + } + if parsed.User != nil { + return nil, errors.New("destination URL cannot contain user information") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, errors.New("destination URL must use HTTP or HTTPS") + } + if requireHTTPS && parsed.Scheme != "https" { + return nil, errors.New("destination URL must use HTTPS") + } + if port := parsed.Port(); port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > 65535 { + return nil, errors.New("destination URL has an invalid port") + } + } + if _, err := resolvePublic(ctx, parsed.Hostname()); err != nil { + return nil, err + } + return parsed, nil +} + +// NewPublicHTTPClient creates a client that never uses environment proxies, +// rejects private/special-use destinations at dial time, and validates every +// redirect before following it. +func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client { + if timeout <= 0 { + timeout = 30 * time.Second + } + transport := &http.Transport{ + Proxy: nil, + DialContext: PublicDialer(timeout), + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: timeout, + ResponseHeaderTimeout: timeout, + ExpectContinueTimeout: time.Second, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + } + return &http.Client{ + Transport: transport, + Timeout: timeout, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) >= 4 { + return errors.New("too many redirects") + } + _, err := ValidatePublicURL(request.Context(), request.URL.String(), requireHTTPS) + return err + }, + } +} + +// PublicDialer resolves the original hostname and connects directly to one of +// its validated public addresses. It does not pass the hostname back through a +// second resolver, so a DNS rebinding response cannot redirect the connection. +func PublicDialer(timeout time.Duration) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("parse outbound address: %w", err) + } + addresses, err := resolvePublic(ctx, host) + if err != nil { + return nil, err + } + dialer := net.Dialer{Timeout: timeout} + var lastErr error + for _, address := range addresses { + connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(address.String(), port)) + if err == nil { + return connection, nil + } + lastErr = err + } + return nil, fmt.Errorf("connect to public destination: %w", lastErr) + } +} + +func resolvePublic(ctx context.Context, host string) ([]netip.Addr, error) { + if literal, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil { + literal = literal.Unmap() + if !publicAddress(literal) { + return nil, errors.New("destination resolves to a private or special-use address") + } + return []netip.Addr{literal}, nil + } + addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, fmt.Errorf("resolve destination: %w", err) + } + result := make([]netip.Addr, 0, len(addresses)) + for _, address := range addresses { + address = address.Unmap() + if !publicAddress(address) { + return nil, errors.New("destination resolves to a private or special-use address") + } + result = append(result, address) + } + if len(result) == 0 { + return nil, errors.New("destination has no IP address") + } + return result, nil +} + +var blockedNetworks = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("127.0.0.0/8"), + netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("224.0.0.0/4"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("::/128"), + netip.MustParsePrefix("::1/128"), + netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("ff00::/8"), + // Block both the well-known and local-use NAT64 prefixes. Otherwise a + // public-looking IPv6 literal could translate to a private IPv4 target. + netip.MustParsePrefix("64:ff9b::/96"), + netip.MustParsePrefix("2002::/16"), +} + +func publicAddress(address netip.Addr) bool { + if !address.IsValid() || !address.IsGlobalUnicast() { + return false + } + for _, blocked := range blockedNetworks { + if blocked.Contains(address) { + return false + } + } + return true +} diff --git a/internal/netguard/public_test.go b/internal/netguard/public_test.go new file mode 100644 index 0000000..bbcb6cb --- /dev/null +++ b/internal/netguard/public_test.go @@ -0,0 +1,29 @@ +package netguard + +import ( + "context" + "testing" +) + +func TestValidatePublicURLRejectsUnsafeDestinations(t *testing.T) { + tests := []string{ + "http://127.0.0.1/plugin.zip", + "https://[::1]/plugin.zip", + "https://169.254.169.254/latest/meta-data/", + "https://[64:ff9b::7f00:1]/", + "https://[2002:7f00:1::]/", + "file:///etc/passwd", + "https://user:secret@example.com/plugin.zip", + } + for _, raw := range tests { + if _, err := ValidatePublicURL(context.Background(), raw, false); err == nil { + t.Errorf("ValidatePublicURL(%q) accepted an unsafe destination", raw) + } + } +} + +func TestValidatePublicURLCanRequireHTTPS(t *testing.T) { + if _, err := ValidatePublicURL(context.Background(), "http://8.8.8.8/plugin.zip", true); err == nil { + t.Fatal("HTTP destination was accepted while HTTPS was required") + } +} diff --git a/internal/server/automatic_task_notifications.go b/internal/server/automatic_task_notifications.go index e71f8f6..5fc371b 100644 --- a/internal/server/automatic_task_notifications.go +++ b/internal/server/automatic_task_notifications.go @@ -266,13 +266,13 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje return err } } - from, err := mail.ParseAddress(configString(config, "from_address")) + from, err := parseMailAddress(configString(config, "from_address")) if err != nil { return err } var recipients []*mail.Address for _, item := range configStrings(config, "to_addresses") { - address, err := mail.ParseAddress(item) + address, err := parseMailAddress(item) if err != nil { return err } @@ -291,7 +291,7 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje return err } email := strings.Join([]string{ - "Date: " + time.Now().UTC().Format(time.RFC1123Z), "From: " + from.String(), + "Date: " + time.Now().UTC().Format(time.RFC1123Z), "From: " + formatMailAddress(from), "To: " + joinMailAddresses(recipients), "Subject: " + mime.QEncoding.Encode("UTF-8", subject), "MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 8bit", "", text, "", }, "\r\n") diff --git a/internal/server/extensions_api.go b/internal/server/extensions_api.go index 8f6475e..747e407 100644 --- a/internal/server/extensions_api.go +++ b/internal/server/extensions_api.go @@ -62,7 +62,7 @@ func (s *Server) routeExtensionAPI(w http.ResponseWriter, r *http.Request, clean if !requireMethod(w, r, http.MethodPost) { return true } - r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+(1<<20)) + r.Body = http.MaxBytesReader(nil, r.Body, maxPluginUploadBytes+(1<<20)) if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil { writeError(w, http.StatusBadRequest, "invalid_plugin_upload", "plugin upload must be multipart/form-data and no larger than 64 MiB") return true diff --git a/internal/server/server.go b/internal/server/server.go index f5fb2ae..0600582 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -414,7 +414,10 @@ func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, destination } } - r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes) + // MaxBytesReader's ResponseWriter parameter is deprecated and unused by Go. + // Passing nil also makes the request body and response data flows explicitly + // separate for static analysis. + r.Body = http.MaxBytesReader(nil, r.Body, s.maxRequestBodyBytes) decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(destination); err != nil { diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index 074bf63..b68597d 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "mime" "net" "net/http" "net/mail" @@ -278,7 +279,7 @@ func validateNotificationField( } } if name == "from_address" && value != "" { - if _, err := mail.ParseAddress(value); err != nil { + if _, err := parseMailAddress(value); err != nil { return fmt.Errorf("%s is not a valid email address", field) } } @@ -763,13 +764,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected) } } - from, err := mail.ParseAddress(configString(config, "from_address")) + from, err := parseMailAddress(configString(config, "from_address")) if err != nil { return fmt.Errorf("parse sender address: %w", err) } recipients := make([]*mail.Address, 0) for _, item := range configStrings(config, "to_addresses") { - address, err := mail.ParseAddress(item) + address, err := parseMailAddress(item) if err != nil { return fmt.Errorf("parse recipient address: %w", err) } @@ -789,7 +790,7 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error } message := strings.Join([]string{ "Date: " + time.Now().UTC().Format(time.RFC1123Z), - "From: " + from.String(), + "From: " + formatMailAddress(from), "To: " + joinMailAddresses(recipients), "Subject: vocat notification test", "MIME-Version: 1.0", @@ -814,11 +815,35 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error func joinMailAddresses(values []*mail.Address) string { result := make([]string, 0, len(values)) for _, value := range values { - result = append(result, value.String()) + result = append(result, formatMailAddress(value)) } return strings.Join(result, ", ") } +func parseMailAddress(value string) (*mail.Address, error) { + value = strings.TrimSpace(value) + if value == "" || strings.ContainsAny(value, "\r\n\x00") { + return nil, errors.New("email address contains a prohibited control character") + } + address, err := mail.ParseAddress(value) + if err != nil || address.Address == "" || strings.ContainsAny(address.Address, "\r\n\x00") { + return nil, errors.New("invalid email address") + } + for _, character := range address.Name { + if character < 0x20 || character == 0x7f { + return nil, errors.New("email display name contains a prohibited control character") + } + } + return address, nil +} + +func formatMailAddress(address *mail.Address) string { + if address.Name == "" { + return address.Address + } + return mime.QEncoding.Encode("UTF-8", address.Name) + " <" + address.Address + ">" +} + func restrictedHTTPClient( ctx context.Context, timeout time.Duration, diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index d18de15..cb9e51a 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -583,3 +583,23 @@ func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) { t.Fatal("unknown path was claimed by settings router") } } + +func TestParseMailAddressRejectsHeaderInjection(t *testing.T) { + for _, value := range []string{ + "sender@example.com\r\nBcc: victim@example.com", + "recipient@example.com\nX-Test: injected", + "display\x00name ", + } { + if _, err := parseMailAddress(value); err == nil { + t.Errorf("parseMailAddress(%q) accepted header injection", value) + } + } + address, err := parseMailAddress("Vocat Alerts ") + if err != nil { + t.Fatal(err) + } + header := formatMailAddress(address) + if strings.ContainsAny(header, "\r\n") { + t.Fatalf("formatted address contains a line break: %q", header) + } +} diff --git a/internal/server/sms_notifications.go b/internal/server/sms_notifications.go index 84ed882..2c2c9b2 100644 --- a/internal/server/sms_notifications.go +++ b/internal/server/sms_notifications.go @@ -402,13 +402,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected) } } - from, err := mail.ParseAddress(configString(config, "from_address")) + from, err := parseMailAddress(configString(config, "from_address")) if err != nil { return fmt.Errorf("parse sender address: %w", err) } recipients := make([]*mail.Address, 0) for _, item := range configStrings(config, "to_addresses") { - address, err := mail.ParseAddress(item) + address, err := parseMailAddress(item) if err != nil { return fmt.Errorf("parse recipient address: %w", err) } @@ -428,7 +428,7 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag } email := strings.Join([]string{ "Date: " + time.Now().UTC().Format(time.RFC1123Z), - "From: " + from.String(), + "From: " + formatMailAddress(from), "To: " + joinMailAddresses(recipients), "Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel), "MIME-Version: 1.0", diff --git a/internal/vowifi/ims/digest.go b/internal/vowifi/ims/digest.go index 71dda5a..e19f6f9 100644 --- a/internal/vowifi/ims/digest.go +++ b/internal/vowifi/ims/digest.go @@ -24,13 +24,13 @@ type digestChallenge struct { } type digestCredentials struct { - Username string - Password []byte - AUTS string - URI string - Method string - CNonce string - NC uint32 + Username string + AKAResponse []byte + AUTS string + URI string + Method string + CNonce string + NC uint32 } func parseDigestChallenge(value string, proxy bool) (digestChallenge, error) { @@ -146,7 +146,7 @@ func parseAuthDirectives(value string) (map[string]string, error) { } type akaMaterial struct { - password []byte + response []byte auts []byte ck []byte ik []byte @@ -156,7 +156,7 @@ func clearAKAMaterial(material *akaMaterial) { if material == nil { return } - zeroBytes(material.password) + zeroBytes(material.response) zeroBytes(material.auts) zeroBytes(material.ck) zeroBytes(material.ik) @@ -193,7 +193,7 @@ func authenticateAKA( return akaMaterial{}, err } return akaMaterial{ - password: res, + response: res, ck: append([]byte(nil), result.CK...), ik: append([]byte(nil), result.IK...), }, nil @@ -231,7 +231,7 @@ func extractRES(result vowifi.AKAResult) ([]byte, error) { func newDigestCredentials( username string, - password []byte, + akaResponse []byte, uri string, method string, nc uint32, @@ -241,12 +241,12 @@ func newDigestCredentials( return digestCredentials{}, fmt.Errorf("ims: create digest cnonce: %w", err) } return digestCredentials{ - Username: username, - Password: password, - URI: uri, - Method: method, - CNonce: hex.EncodeToString(cnonceBytes), - NC: nc, + Username: username, + AKAResponse: akaResponse, + URI: uri, + Method: method, + CNonce: hex.EncodeToString(cnonceBytes), + NC: nc, }, nil } @@ -255,7 +255,7 @@ func buildDigestAuthorization(challenge digestChallenge, credentials digestCrede response := digestResponse( credentials.Username, challenge.Realm, - credentials.Password, + credentials.AKAResponse, credentials.Method, credentials.URI, challenge.Nonce, @@ -290,7 +290,7 @@ func buildDigestAuthorization(challenge digestChallenge, credentials digestCrede func digestResponse( username string, realm string, - password []byte, + akaResponse []byte, method string, uri string, nonce string, @@ -300,7 +300,9 @@ func digestResponse( ) string { ha1Hash := md5.New() _, _ = ha1Hash.Write([]byte(username + ":" + realm + ":")) - _, _ = ha1Hash.Write(password) + // AKAv1-MD5 is mandated by the IMS server challenge (3GPP TS 33.203). + // akaResponse is the short-lived USIM RES value, not a stored password. + _, _ = ha1Hash.Write(akaResponse) ha1 := hex.EncodeToString(ha1Hash.Sum(nil)) ha2 := md5Hex(method + ":" + uri) if qop == "" { diff --git a/internal/vowifi/ims/digest_test.go b/internal/vowifi/ims/digest_test.go index b9dd0da..c1f953b 100644 --- a/internal/vowifi/ims/digest_test.go +++ b/internal/vowifi/ims/digest_test.go @@ -64,8 +64,8 @@ func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) { if err != nil { t.Fatalf("authenticateAKA() error = %v", err) } - if !reflect.DeepEqual(material.password, []byte{0xde, 0xad, 0xbe, 0xef}) { - t.Fatalf("password = %x, want deadbeef", material.password) + if !reflect.DeepEqual(material.response, []byte{0xde, 0xad, 0xbe, 0xef}) { + t.Fatalf("response = %x, want deadbeef", material.response) } if len(aka.challenges) != 1 { t.Fatalf("challenge count = %d, want 1", len(aka.challenges)) @@ -97,12 +97,12 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) { if err != nil { t.Fatalf("authenticateAKA() error = %v", err) } - if !reflect.DeepEqual(material.auts, auts) || len(material.password) != 0 { + if !reflect.DeepEqual(material.auts, auts) || len(material.response) != 0 { t.Fatalf("material = %#v", material) } } -func TestBuildDigestAuthorizationCarriesAUTSWithEmptyPassword(t *testing.T) { +func TestBuildDigestAuthorizationCarriesAUTSWithEmptyResponse(t *testing.T) { authorization := buildDigestAuthorization( digestChallenge{ Realm: "ims.example", @@ -111,13 +111,13 @@ func TestBuildDigestAuthorizationCarriesAUTSWithEmptyPassword(t *testing.T) { QOP: "auth", }, digestCredentials{ - Username: "private@ims.example", - Password: nil, - AUTS: "AAECAwQFBgcICQoLDA0=", - URI: "sip:ims.example", - Method: "REGISTER", - CNonce: "cnonce", - NC: 1, + Username: "private@ims.example", + AKAResponse: nil, + AUTS: "AAECAwQFBgcICQoLDA0=", + URI: "sip:ims.example", + Method: "REGISTER", + CNonce: "cnonce", + NC: 1, }, ) directives, err := parseAuthDirectives(strings.TrimPrefix(authorization, "Digest ")) diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index e30252f..c93b22c 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -405,7 +405,7 @@ func dialSIP( type authenticationState struct { challenge digestChallenge - password []byte + response []byte auts string cnonce string nc uint32 @@ -633,13 +633,13 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse if session.auth != nil { session.auth.nc++ credentials := digestCredentials{ - Username: session.identity.private, - Password: session.auth.password, - AUTS: session.auth.auts, - URI: "sip:" + session.identity.domain, - Method: "REGISTER", - CNonce: session.auth.cnonce, - NC: session.auth.nc, + Username: session.identity.private, + AKAResponse: session.auth.response, + AUTS: session.auth.auts, + URI: "sip:" + session.identity.domain, + Method: "REGISTER", + CNonce: session.auth.cnonce, + NC: session.auth.nc, } authorization = buildDigestAuthorization(session.auth.challenge, credentials) if session.auth.challenge.Proxy { @@ -686,7 +686,7 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse } credentials, err := newDigestCredentials( session.identity.private, - material.password, + material.response, "sip:"+session.identity.domain, "REGISTER", 1, @@ -698,7 +698,7 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse auts := base64.StdEncoding.EncodeToString(material.auts) session.auth = &authenticationState{ challenge: challenge, - password: append([]byte(nil), material.password...), + response: append([]byte(nil), material.response...), auts: auts, cnonce: credentials.CNonce, } @@ -1021,8 +1021,8 @@ func (session *Session) clearAuthentication() { if session.auth == nil { return } - for index := range session.auth.password { - session.auth.password[index] = 0 + for index := range session.auth.response { + session.auth.response[index] = 0 } session.auth = nil }