fix(ssl): support IPv6 hosts in panel SSL self-signed certificate flow (#12652)

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>
This commit is contained in:
Sanjay Santhanam
2026-05-06 10:36:23 +08:00
committed by GitHub
parent a2ac2e0b22
commit 68411bddd9
4 changed files with 63 additions and 4 deletions
+2 -2
View File
@@ -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
+20
View File
@@ -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
+36
View File
@@ -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)
}
})
}
}
@@ -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;