fix(server): bound the public-IP lookup and accept only an address

getPublicIP read a third-party IP echo service's body whole and cached
whatever came back as the panel's address, so one misbehaving service
could make the panel inflate an endless response at startup, and an
error page became the address shown in every status sample.

The read is capped at 64 bytes and only a parsable IP is accepted;
anything else counts as unavailable. The GitHub release lookups are
capped as well.
This commit is contained in:
MHSanaei
2026-11-01 13:18:01 +01:00
parent f99c6c664d
commit 06cb1742a1
3 changed files with 45 additions and 7 deletions
+2 -1
View File
@@ -43,6 +43,7 @@ type PanelUpdateInfo struct {
const (
panelUpdaterURL = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh"
maxPanelUpdaterBytes = 2 << 20
maxPanelReleaseBytes = 2 << 20
// devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the
// newest main commit; the dev update channel installs from it.
devReleaseTag = "dev-latest"
@@ -450,7 +451,7 @@ func fetchPanelRelease(tag string) (*service.Release, error) {
}
var release service.Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, maxPanelReleaseBytes)).Decode(&release); err != nil {
return nil, err
}
return &release, nil
+16 -6
View File
@@ -19,6 +19,7 @@ import (
"mime/multipart"
stdnet "net"
"net/http"
"net/netip"
"net/url"
"os"
"os/exec"
@@ -402,6 +403,10 @@ type MLKEM768Response struct {
Client string `json:"client" example:"mlkem768-client"`
}
// maxPublicIPBytes caps an IP echo service's answer; a textual IPv6 address
// with its line ending fits well within it.
const maxPublicIPBytes = 64
func getPublicIP(url string) string {
client := &http.Client{
Timeout: 3 * time.Second,
@@ -425,17 +430,19 @@ func getPublicIP(url string) string {
return "N/A"
}
ip, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPublicIPBytes))
if err != nil {
return "N/A"
}
ipString := strings.TrimSpace(string(ip))
if ipString == "" {
// The answer is cached and sent with every status sample, so only a bare
// address is accepted; an over-long one is cut above and fails to parse.
ip, err := netip.ParseAddr(strings.TrimSpace(string(body)))
if err != nil {
return "N/A"
}
return ipString
return ip.WithZone("").String()
}
var publicIPv4Services = []string{
@@ -921,6 +928,9 @@ const (
// maxXrayDigestBytes caps the .dgst checksum sidecar read; it is a few
// hundred bytes in practice.
maxXrayDigestBytes = 64 << 10
// maxXrayReleasesBytes caps the GitHub release list read; a page of
// releases with all their assets is a few MiB.
maxXrayReleasesBytes = 32 << 20
)
func (s *ServerService) GetXrayVersions() ([]string, error) {
@@ -941,7 +951,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
// Check HTTP status code - GitHub API returns object instead of array on error
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxXrayReleasesBytes))
var errorResponse struct {
Message string `json:"message"`
}
@@ -953,7 +963,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
buffer := bytes.NewBuffer(make([]byte, bufferSize))
buffer.Reset()
if _, err := buffer.ReadFrom(resp.Body); err != nil {
if _, err := buffer.ReadFrom(io.LimitReader(resp.Body, maxXrayReleasesBytes)); err != nil {
return nil, err
}
@@ -564,3 +564,30 @@ func TestPublicIPv6BackoffPreservesIPv4RefreshAndRetry(t *testing.T) {
}
})
}
// The IP echo services are third parties; their answer is cached for the life
// of the process and copied into every status sample.
func TestGetPublicIPAcceptsOnlyAnAddress(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{name: "ipv4 with line ending", body: "203.0.113.7\n", want: "203.0.113.7"},
{name: "ipv6", body: "2001:db8::7", want: "2001:db8::7"},
{name: "not an address", body: "<html>blocked</html>", want: "N/A"},
{name: "address followed by megabytes", body: "203.0.113.7\n" + strings.Repeat("1", 8<<20), want: "N/A"},
{name: "empty", body: "", want: "N/A"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()
if got := getPublicIP(server.URL); got != tt.want {
t.Fatalf("getPublicIP = %.40q (%d bytes), want %q", got, len(got), tt.want)
}
})
}
}