Skip to content

HTTP-01 challenge: solver mishandles host headers containing an IPv6 address without a port number #8423

@SlashNephy

Description

@SlashNephy

Describe the bug:

The parseHost function in pkg/issuer/acme/http/solver/solver.go incorrectly parses the HTTP Host header containing an IPv6 address without a port number.

According to RFC 3986 (Section 3.2.2) and RFC 9110, an IPv6 address in the HTTP Host header must be enclosed in square brackets (e.g., [2a00:8a00:4000:435::13a]). Additionally, a port number may be assigned as needed (e.g., [2a00:8a00:4000:435::13a]:80).

The current implementation uses netip.ParseAddr (which fails on bracketed IP addresses without a port number) and falls back to a naive strings.Split(s, ":"). This logic incorrectly truncates the IPv6 address at the first colon, resulting in an invalid host string (e.g., [2a00).

By the way, parsing IPv6 address literals that include port numbers succeeds. ([2a00:8a00:4000:435::13a]:80 translates to 2a00:8a00:4000:435::13a.)

func parseHost(s string) string {
// ip v4/v6 with port
addrPort, err := netip.ParseAddrPort(s)
if err == nil {
return addrPort.Addr().String()
}
// ip v4/v6 without port
addr, err := netip.ParseAddr(s)
if err == nil {
return addr.String()
}
host := strings.Split(s, ":")
return host[0]
}

This causes ACME HTTP-01 challenges to fail when traffic is routed via IPv6, as the challenge check compares a broken host string.

Expected behaviour:

The function should correctly extract the IPv6 address from the bracketed Host header (e.g., returning 2a00:8a00:4000:435::13a when the Host header is [2a00:8a00:4000:435::13a]), instead of truncating it.

Steps to reproduce the bug:

  1. Create Issuer and Certificate resources with a http01 resolver and IP address subjects. (For reference, Let's Encrypt has released short-lived IP address certificates into GA.)
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: example-issuer

spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    profile: shortlived
    solvers:
      - http01:
          ingress:
            ...

---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: example-certificate

spec:
  secretName: example-certificate-secret
  ipAddresses:
    - 2a00:8a00:4000:435::13a
  issuerRef:
    kind: Issuer
    name: example-issuer
  1. Observe the HTTP-01 challenges executed against the Pod created by cert-manager. Then the ACME HTTP-01 challenge is failing.
I0120 10:16:06.542507       1 solver.go:52] "starting listener" logger="cert-manager.acmesolver" expected_domain="2a00:8a00:4000:435::13a" expected_token="<token>" expected_key="<key>" listen_port=8089
I0120 10:16:15.483387       1 solver.go:89] "validating request" logger="cert-manager.acmesolver" host="[2a00" path="/.well-known/acme-challenge/<token>" base_path="/.well-known/acme-challenge" token="<token>" headers={"Accept-Encoding":["gzip"],"User-Agent":["cert-manager-challenges/v1.19.2 (linux/amd64) cert-manager/6e38ee57a338a1f27bb724ddb5933f4b8e23e567"]}
I0120 10:16:15.483446       1 solver.go:97] "comparing host" logger="cert-manager.acmesolver" host="[2a00" path="/.well-known/acme-challenge/<token>" base_path="/.well-known/acme-challenge" token="<token>" headers={"Accept-Encoding":["gzip"],"User-Agent":["cert-manager-challenges/v1.19.2 (linux/amd64) cert-manager/6e38ee57a338a1f27bb724ddb5933f4b8e23e567"]} expected_host="2a00:8a00:4000:435::13a"
I0120 10:16:15.483469       1 solver.go:99] "invalid host" logger="cert-manager.acmesolver" host="[2a00" path="/.well-known/acme-challenge/<token>" base_path="/.well-known/acme-challenge" token="<token>" headers={"Accept-Encoding":["gzip"],"User-Agent":["cert-manager-challenges/v1.19.2 (linux/amd64) cert-manager/6e38ee57a338a1f27bb724ddb5933f4b8e23e567"]} expected_host="2a00:8a00:4000:435::13a"

Anything else we need to know?:

Here is the minimal code that reproduces the bug.

package main

import (
	"net/netip"
	"strings"
)

func main() {
	result := parseHost("[2a00:8a00:4000:435::13a]")
	println(result) // Output: [2a00
}

func parseHost(s string) string {
	// ip v4/v6 with port
	addrPort, err := netip.ParseAddrPort(s)
	if err == nil {
		return addrPort.Addr().String()
	}

	// ip v4/v6 without port
	addr, err := netip.ParseAddr(s)
	if err == nil {
		return addr.String()
	}

	host := strings.Split(s, ":")
	return host[0]
}

Environment details:

  • Kubernetes version: v1.35.0
  • Cloud-provider/provisioner: Bare metal, plain kubeadm installation
  • cert-manager version: v1.19.2
  • Install method: Helm chart (w/ v1.19.2)

/kind bug

Metadata

Metadata

Assignees

Labels

kind/bugCategorizes issue or PR as related to a bug.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions