From 61ced9b9aa195a0f6db53d96ce8ab7bdf677d63d Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Sun, 3 May 2026 16:21:15 -0700 Subject: [PATCH] fix(ssl): support IPv6 hosts in panel SSL self-signed certificate flow Enabling Panel SSL with the self-sign provider rejected IPv6 hosts with "domain format invalid". Two coupled bugs caused this: 1. The frontend extracted the host from window.location.href with href.split('//')[1].split(':')[0]. For an IPv6 URL like https://[::1]:1234 that yields '[' \u2014 not a valid host \u2014 because the second split splits on the first colon inside the bracketed address. Use window.location.hostname, which natively returns the bracket-stripped IPv6 host. 2. The backend ObtainSSL flow used net.ParseIP(domain) directly. Even if the frontend sent the bracketed form ('[::1]'), net.ParseIP rejects brackets, so the value flowed into IsValidDomain() and failed the regex. Add common.ParseIPLoose() that accepts both bare and bracketed IPv6 in addition to bare IPv4. Use it at both call sites in ObtainSSL (renew path and create path). A unit test guards the regression. Files: - agent/utils/common/common.go (new ParseIPLoose helper) - agent/utils/common/parse_ip_test.go (12 cases, all green) - agent/app/service/website_ca.go (call sites switched) - frontend/src/views/setting/safe/ssl/index.vue (host extraction fix) Fixes #12646 Signed-off-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> --- agent/app/service/website_ca.go | 4 +-- agent/utils/common/common.go | 20 +++++++++++ agent/utils/common/parse_ip_test.go | 36 +++++++++++++++++++ frontend/src/views/setting/safe/ssl/index.vue | 7 ++-- 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 agent/utils/common/parse_ip_test.go diff --git a/agent/app/service/website_ca.go b/agent/app/service/website_ca.go index 6883b198de96..da05222722ec 100644 --- a/agent/app/service/website_ca.go +++ b/agent/app/service/website_ca.go @@ -188,7 +188,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website existDomains = append(existDomains, strings.Split(websiteSSL.Domains, ",")...) } for _, domain := range existDomains { - if ipAddress := net.ParseIP(domain); ipAddress == nil { + if ipAddress := common.ParseIPLoose(domain); ipAddress == nil { domains = append(domains, domain) } else { ips = append(ips, ipAddress) @@ -220,7 +220,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website if req.Domains != "" { domainArray := strings.Split(req.Domains, "\n") for _, domain := range domainArray { - if ipAddress := net.ParseIP(domain); ipAddress == nil { + if ipAddress := common.ParseIPLoose(domain); ipAddress == nil { if domain != "localhost" && !common.IsValidDomain(domain) { err = buserr.WithName("ErrDomainFormat", domain) return nil, err diff --git a/agent/utils/common/common.go b/agent/utils/common/common.go index a49dda60574d..bd27a54dd73d 100644 --- a/agent/utils/common/common.go +++ b/agent/utils/common/common.go @@ -346,6 +346,26 @@ func IsValidIP(ip string) bool { return net.ParseIP(ip) != nil } +// ParseIPLoose parses an IP address, accepting bracketed IPv6 forms +// such as "[::1]" or "[fe80::1%eth0]" in addition to the bare forms +// that net.ParseIP supports natively. It also trims surrounding +// whitespace. Returns nil for non-IP input, matching net.ParseIP. +// +// This is required because some flows pass the host portion of a URL +// (e.g. "[::1]") rather than a bare IP address. Rejecting that form +// caused 1Panel-dev/1Panel#12646 — the panel SSL self-sign workflow +// reported “domain format invalid” for IPv6 hosts. +func ParseIPLoose(s string) net.IP { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return nil + } + if len(trimmed) >= 2 && trimmed[0] == '[' && trimmed[len(trimmed)-1] == ']' { + trimmed = trimmed[1 : len(trimmed)-1] + } + return net.ParseIP(trimmed) +} + const ( b = uint64(1) kb = 1024 * b diff --git a/agent/utils/common/parse_ip_test.go b/agent/utils/common/parse_ip_test.go new file mode 100644 index 000000000000..27fe2a58c8de --- /dev/null +++ b/agent/utils/common/parse_ip_test.go @@ -0,0 +1,36 @@ +package common + +import "testing" + +// Regression test for 1Panel-dev/1Panel#12646: panel SSL self-signed flow +// rejected IPv6 hosts because net.ParseIP does not accept the bracketed +// form (e.g. "[::1]") and the upstream caller passes the host portion of +// a URL rather than a bare IP. +func TestParseIPLoose(t *testing.T) { + cases := []struct { + name string + in string + want bool + }{ + {"bare ipv4", "127.0.0.1", true}, + {"bare ipv6", "::1", true}, + {"bracketed ipv6", "[::1]", true}, + {"bracketed full ipv6", "[2001:db8::1]", true}, + {"trimmed bare ipv6", " ::1 ", true}, + {"trimmed bracketed ipv6", " [::1] ", true}, + {"empty", "", false}, + {"only brackets", "[]", false}, + {"hostname", "example.com", false}, + {"bracketed garbage", "[notanip]", false}, + {"unbalanced bracket left", "[::1", false}, + {"unbalanced bracket right", "::1]", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ParseIPLoose(tc.in) != nil + if got != tc.want { + t.Errorf("ParseIPLoose(%q) ok = %v, want %v", tc.in, got, tc.want) + } + }) + } +} diff --git a/frontend/src/views/setting/safe/ssl/index.vue b/frontend/src/views/setting/safe/ssl/index.vue index cc24efa74214..4966e7bb43ec 100644 --- a/frontend/src/views/setting/safe/ssl/index.vue +++ b/frontend/src/views/setting/safe/ssl/index.vue @@ -243,8 +243,11 @@ const onSaveSSL = async (formEl: FormInstance | undefined) => { cert: form.cert, key: form.key, }; - let href = window.location.href; - param.domain = href.split('//')[1].split(':')[0]; + // window.location.hostname yields the bare host name and strips + // surrounding brackets from IPv6 addresses (e.g. '[::1]' -> '::1'), + // unlike `href.split('//')[1].split(':')[0]` which incorrectly + // returns '[' for IPv6 URLs. See 1Panel-dev/1Panel#12646. + param.domain = window.location.hostname; await updateSSL(param).then(() => { MsgSuccess(i18n.global.t('commons.msg.operationSuccess')); let href = window.location.href;