This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 8bfdef573 fix(rmqctl): accept scoped IPv6 link-local Studio endpoints 
(#7135)
8bfdef573 is described below

commit 8bfdef573f292665c8216597ec64af6b7d5d2552
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 26 20:34:09 2026 +0800

    fix(rmqctl): accept scoped IPv6 link-local Studio endpoints (#7135)
    
    * fix(rmqctl): accept scoped IPv6 link-local Studio endpoints (#7131)
    url.Parse reports the hostname of an escaped scoped literal such as
    http://[fe80::1%25eth0]:6789 as fe80::1%eth0, zone included. isPrivateHost
    classified that host with net.ParseIP, which rejects the "%eth0" suffix, so
    every scoped loopback, link-local or private IPv6 endpoint was treated as a
    public host and plain HTTP was refused before any request was dispatched.
    Classify the host with net/netip instead: it keeps the zone and lets the
    address be judged by its actual bits, so scoped loopback, link-local and
    RFC1918/ULA addresses are accepted like their unscoped forms. IPv4-mapped
    addresses are unmapped to stay classified like their IPv4 form. The zone is
    left untouched in the outgoing URL (still escaped as "%25eth0"), and public
    or malformed hosts are still rejected.
---
 rmqctl/internal/studio/auth.go        | 26 ++++++++---
 rmqctl/internal/studio/client_test.go | 84 +++++++++++++++++++++++++++++++++++
 2 files changed, 104 insertions(+), 6 deletions(-)

diff --git a/rmqctl/internal/studio/auth.go b/rmqctl/internal/studio/auth.go
index fa234324f..394fa0ba8 100644
--- a/rmqctl/internal/studio/auth.go
+++ b/rmqctl/internal/studio/auth.go
@@ -11,8 +11,8 @@ import (
        "crypto/sha256"
        "encoding/hex"
        "fmt"
-       "net"
        "net/http"
+       "net/netip"
        "strconv"
        "strings"
        "time"
@@ -115,8 +115,22 @@ func isLoopbackHost(host string) bool {
        if strings.EqualFold(host, "localhost") {
                return true
        }
-       ip := net.ParseIP(host)
-       return ip != nil && ip.IsLoopback()
+       addr, ok := parseHostAddress(host)
+       return ok && addr.IsLoopback()
+}
+
+// parseHostAddress parses host as an IP literal while honouring an IPv6 zone,
+// such as the "eth0" in "fe80::1%eth0" (the hostname url.Parse reports for the
+// escaped "[fe80::1%25eth0]" form). net.ParseIP rejects the zone suffix, so
+// every scoped link-local endpoint used to be classified as a public host.
+// IPv4-mapped IPv6 addresses are unmapped so that they keep being classified
+// exactly like the equivalent IPv4 literal.
+func parseHostAddress(host string) (netip.Addr, bool) {
+       addr, err := netip.ParseAddr(host)
+       if err != nil {
+               return netip.Addr{}, false
+       }
+       return addr.Unmap(), true
 }
 
 // isPrivateHost reports whether host is a loopback, RFC1918 private-network,
@@ -128,9 +142,9 @@ func isPrivateHost(host string) bool {
        if isLoopbackHost(host) {
                return true
        }
-       ip := net.ParseIP(host)
-       if ip == nil {
+       addr, ok := parseHostAddress(host)
+       if !ok {
                return false
        }
-       return ip.IsPrivate() || ip.IsLinkLocalUnicast()
+       return addr.IsPrivate() || addr.IsLinkLocalUnicast()
 }
diff --git a/rmqctl/internal/studio/client_test.go 
b/rmqctl/internal/studio/client_test.go
index 4334d6d10..e8302e631 100644
--- a/rmqctl/internal/studio/client_test.go
+++ b/rmqctl/internal/studio/client_test.go
@@ -19,6 +19,7 @@ package studio
 import (
        "context"
        "encoding/json"
+       "io"
        "net/http"
        "net/http/httptest"
        "strings"
@@ -115,3 +116,86 @@ func 
TestRequestPreservesLargeIntegersInPassthroughPayload(t *testing.T) {
                t.Fatalf("payload lost integer precision: %s", encoded)
        }
 }
+
+// A scoped IPv6 literal such as [fe80::1%25eth0] is a valid Studio endpoint:
+// url.Parse decodes the "%25" escape and reports the hostname "fe80::1%eth0",
+// zone included. net.ParseIP rejects that zone suffix, so scoped loopback,
+// link-local and private addresses were misclassified as public hosts and 
plain
+// HTTP was refused. Classification must parse the scoped address as-is, while
+// the zone is preserved in the outgoing URL so the kernel can pick an 
interface.
+func TestValidateServerSchemeScopedIPv6(t *testing.T) {
+       tests := []struct {
+               name    string
+               server  string
+               wantErr string
+       }{
+               {"http link-local named zone", "http://[fe80::1%25eth0]:6789";, 
""},
+               {"http link-local numeric zone", "http://[fe80::1%252]:6789";, 
""},
+               {"http loopback named zone", "http://[::1%25lo]:6789";, ""},
+               {"http private ula named zone", "http://[fd00::1%25eth0]:6789";, 
""},
+               {"https link-local named zone", 
"https://[fe80::1%25eth0]:6789";, ""},
+               {"http public ipv6 with zone rejected", 
"http://[2001:db8::1%25eth0]:6789";, "must use HTTPS"},
+               {"http public ipv6 rejected", "http://[2001:db8::1]:6789";, 
"must use HTTPS"},
+               {"empty zone rejected", "http://[fe80::1%25]:6789";, "invalid 
Studio Server URL"},
+               {"malformed ipv6 rejected", "http://[zz::1%25eth0]:6789";, 
"invalid Studio Server URL"},
+               {"missing bracket rejected", "http://[fe80::1:6789";, "invalid 
Studio Server URL"},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       target := Target{
+                               Server:     tt.server,
+                               InstanceID: "test-instance",
+                               Credential: Credential{AccessKey: "ak", 
SecretKey: "sk"},
+                       }
+                       err := target.validate()
+                       if tt.wantErr == "" {
+                               if err != nil {
+                                       t.Fatalf("validate(%q) = %v, want nil", 
tt.server, err)
+                               }
+                               return
+                       }
+                       if err == nil {
+                               t.Fatalf("validate(%q) = nil, want error 
containing %q", tt.server, tt.wantErr)
+                       }
+                       if !strings.Contains(err.Error(), tt.wantErr) {
+                               t.Fatalf("validate(%q) = %v, want error 
containing %q", tt.server, err, tt.wantErr)
+                       }
+               })
+       }
+}
+
+// The interface zone is part of the outgoing URL: it must survive URL joining
+// and request construction as the "%25eth0" escape, otherwise a scoped literal
+// cannot identify its link-local interface.
+func TestRequestPreservesScopedIPv6Zone(t *testing.T) {
+       var recorded *http.Request
+       client := NewClient(&http.Client{Transport: roundTripFunc(func(request 
*http.Request) (*http.Response, error) {
+               recorded = request
+               return &http.Response{
+                       StatusCode: http.StatusOK,
+                       Header:     make(http.Header),
+                       Body:       
io.NopCloser(strings.NewReader(`{"code":200,"message":"ok","data":null}`)),
+                       Request:    request,
+               }, nil
+       })})
+       target := Target{
+               Server:     "http://[fe80::1%25eth0]:6789";,
+               InstanceID: "instance-1",
+               Credential: Credential{AccessKey: "ak", SecretKey: "sk"},
+               Timeout:    DefaultTimeout,
+       }
+
+       var payload any
+       if err := client.request(context.Background(), target, http.MethodGet, 
"/api/brokers", nil, &payload); err != nil {
+               t.Fatalf("request to scoped link-local endpoint returned error: 
%v", err)
+       }
+       if recorded == nil {
+               t.Fatal("request never reached the transport")
+       }
+       if got, want := recorded.URL.Hostname(), "fe80::1%eth0"; got != want {
+               t.Fatalf("hostname = %q, want %q", got, want)
+       }
+       if got, want := recorded.URL.String(), 
"http://[fe80::1%25eth0]:6789/api/brokers";; got != want {
+               t.Fatalf("request URL = %q, want %q", got, want)
+       }
+}

Reply via email to