mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-31 02:28:09 +00:00 
			
		
		
		
	Fix issue building urls with IPv6 IPs for ACME http-01 challenges (#28718)
* Fix ACME http-01 challenges for IPv6 IPs - We weren't properly encapsulating the IPv6 IP within the url provided to the http client with []. * Add cl * Cleanup a test println
This commit is contained in:
		| @@ -20,6 +20,7 @@ type ACMEIdentifier struct { | |||||||
| 	Value         string             `json:"value"` | 	Value         string             `json:"value"` | ||||||
| 	OriginalValue string             `json:"original_value"` | 	OriginalValue string             `json:"original_value"` | ||||||
| 	IsWildcard    bool               `json:"is_wildcard"` | 	IsWildcard    bool               `json:"is_wildcard"` | ||||||
|  | 	IsV6IP        bool               `json:"is_v6_ip"` | ||||||
| } | } | ||||||
|  |  | ||||||
| func (ai *ACMEIdentifier) MaybeParseWildcard() (bool, string, error) { | func (ai *ACMEIdentifier) MaybeParseWildcard() (bool, string, error) { | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ import ( | |||||||
| 	"container/list" | 	"container/list" | ||||||
| 	"context" | 	"context" | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"strings" | ||||||
| 	"sync" | 	"sync" | ||||||
| 	"time" | 	"time" | ||||||
|  |  | ||||||
| @@ -435,7 +436,9 @@ func (ace *ACMEChallengeEngine) _verifyChallenge(sc *storageContext, id string, | |||||||
| 			return ace._verifyChallengeCleanup(sc, err, id) | 			return ace._verifyChallengeCleanup(sc, err, id) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		valid, err = ValidateHTTP01Challenge(authz.Identifier.Value, cv.Token, cv.Thumbprint, config) | 		domain := encodeIdentifierForHTTP01Challenge(authz.Identifier) | ||||||
|  |  | ||||||
|  | 		valid, err = ValidateHTTP01Challenge(domain, cv.Token, cv.Thumbprint, config) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			err = fmt.Errorf("%w: error validating http-01 challenge %v: %v; %v", ErrIncorrectResponse, id, err, ChallengeAttemptFailedMsg) | 			err = fmt.Errorf("%w: error validating http-01 challenge %v: %v; %v", ErrIncorrectResponse, id, err, ChallengeAttemptFailedMsg) | ||||||
| 			return ace._verifyChallengeRetry(sc, cv, authzPath, authz, challenge, err, id) | 			return ace._verifyChallengeRetry(sc, cv, authzPath, authz, challenge, err, id) | ||||||
| @@ -494,6 +497,27 @@ func (ace *ACMEChallengeEngine) _verifyChallenge(sc *storageContext, id string, | |||||||
| 	return ace._verifyChallengeCleanup(sc, nil, id) | 	return ace._verifyChallengeCleanup(sc, nil, id) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func encodeIdentifierForHTTP01Challenge(identifier *ACMEIdentifier) string { | ||||||
|  | 	if !(identifier.Type == ACMEIPIdentifier && identifier.IsV6IP) { | ||||||
|  | 		return identifier.Value | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// If our IPv6 identifier has a zone we need to encode the % to %25 otherwise | ||||||
|  | 	// the http.Client won't properly use it. RFC6874 specifies how the zone | ||||||
|  | 	// identifier in the URI should be handled. In section 2: | ||||||
|  | 	// | ||||||
|  | 	//    According to URI syntax [RFC3986], "%" is always treated as | ||||||
|  | 	//    an escape character in a URI, so, according to the established URI | ||||||
|  | 	//    syntax [RFC3986] any occurrences of literal "%" symbols in a URI MUST | ||||||
|  | 	//    be percent-encoded and represented in the form "%25". Thus, the | ||||||
|  | 	//    scoped address fe80::a%en1 would appear in a URI as | ||||||
|  | 	//    http://[fe80::a%25en1]. | ||||||
|  | 	escapedIPv6 := strings.Replace(identifier.Value, "%", "%25", 1) | ||||||
|  |  | ||||||
|  | 	// IPv6 addresses need to be surrounded by [] within URLs | ||||||
|  | 	return fmt.Sprintf("[%s]", escapedIPv6) | ||||||
|  | } | ||||||
|  |  | ||||||
| func (ace *ACMEChallengeEngine) _verifyChallengeRetry(sc *storageContext, cv *ChallengeValidation, authzPath string, auth *ACMEAuthorization, challenge *ACMEChallenge, verificationErr error, id string) (bool, time.Time, error) { | func (ace *ACMEChallengeEngine) _verifyChallengeRetry(sc *storageContext, cv *ChallengeValidation, authzPath string, auth *ACMEAuthorization, challenge *ACMEChallenge, verificationErr error, id string) (bool, time.Time, error) { | ||||||
| 	now := time.Now() | 	now := time.Now() | ||||||
| 	path := acmeValidationPrefix + id | 	path := acmeValidationPrefix + id | ||||||
|   | |||||||
							
								
								
									
										47
									
								
								builtin/logical/pki/acme_challenge_engine_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								builtin/logical/pki/acme_challenge_engine_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | // Copyright (c) HashiCorp, Inc. | ||||||
|  | // SPDX-License-Identifier: BUSL-1.1 | ||||||
|  |  | ||||||
|  | package pki | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  |  | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // Test_encodeIdentifierForHTTP01Challenge validates the encoding behaviors of our identifiers | ||||||
|  | // for the HTTP01 challenge. Basically properly encode the identifier for an HTTP request. | ||||||
|  | func Test_encodeIdentifierForHTTP01Challenge(t *testing.T) { | ||||||
|  | 	tests := []struct { | ||||||
|  | 		name string | ||||||
|  | 		arg  *ACMEIdentifier | ||||||
|  | 		want string | ||||||
|  | 	}{ | ||||||
|  | 		{ | ||||||
|  | 			name: "dns", | ||||||
|  | 			arg:  &ACMEIdentifier{Type: ACMEDNSIdentifier, Value: "www.dadgarcorp.com"}, | ||||||
|  | 			want: "www.dadgarcorp.com", | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name: "ipv4", | ||||||
|  | 			arg:  &ACMEIdentifier{Type: ACMEIPIdentifier, Value: "192.168.1.1"}, | ||||||
|  | 			want: "192.168.1.1", | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name: "ipv6", | ||||||
|  | 			arg:  &ACMEIdentifier{Type: ACMEIPIdentifier, Value: "2001:0db8:0000:0000:0000:0000:0000:0068", IsV6IP: true}, | ||||||
|  | 			want: "[2001:0db8:0000:0000:0000:0000:0000:0068]", | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name: "ipv6-zoned", | ||||||
|  | 			arg:  &ACMEIdentifier{Type: ACMEIPIdentifier, Value: "fe80::1cc0:3e8c:119f:c2e1%ens18", IsV6IP: true}, | ||||||
|  | 			want: "[fe80::1cc0:3e8c:119f:c2e1%25ens18]", | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	for _, tt := range tests { | ||||||
|  | 		tt := tt | ||||||
|  | 		t.Run(tt.name, func(t *testing.T) { | ||||||
|  | 			assert.Equalf(t, tt.want, encodeIdentifierForHTTP01Challenge(tt.arg), "encodeIdentifierForHTTP01Challenge(%v)", tt.arg) | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -10,6 +10,7 @@ import ( | |||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net" | 	"net" | ||||||
| 	"net/http" | 	"net/http" | ||||||
|  | 	"net/netip" | ||||||
| 	"sort" | 	"sort" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| @@ -960,10 +961,24 @@ func parseOrderIdentifiers(data map[string]interface{}) ([]*ACMEIdentifier, erro | |||||||
| 		switch typeStr { | 		switch typeStr { | ||||||
| 		case string(ACMEIPIdentifier): | 		case string(ACMEIPIdentifier): | ||||||
| 			identifier.Type = ACMEIPIdentifier | 			identifier.Type = ACMEIPIdentifier | ||||||
| 			ip := net.ParseIP(valueStr) | 			ip, err := netip.ParseAddr(valueStr) | ||||||
| 			if ip == nil { | 			if err != nil { | ||||||
| 				return nil, fmt.Errorf("value argument (%s) failed validation: failed parsing as IP: %w", valueStr, ErrMalformed) | 				return nil, fmt.Errorf("value argument (%s) failed validation: failed parsing as IP: %w", valueStr, ErrMalformed) | ||||||
| 			} | 			} | ||||||
|  | 			if ip.Is6() { | ||||||
|  | 				if len(ip.Zone()) > 0 { | ||||||
|  | 					// If we are given an identifier with a local zone that doesn't make much sense | ||||||
|  | 					// as zone's are specific to the sender not us. For now disallow, perhaps in the | ||||||
|  | 					// future we should simply drop the zone? | ||||||
|  | 					return nil, fmt.Errorf("value argument (%s) failed validation: IPv6 identifiers with zone information are not allowed: %w", valueStr, ErrMalformed) | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// We should keep whatever formatting of the IPv6 address that came in according | ||||||
|  | 				// to RFC8738 Section 2: | ||||||
|  | 				// An identifier for the IPv6 address 2001:db8::1 would be formatted like so: | ||||||
|  | 				//   {"type": "ip", "value": "2001:db8::1"} | ||||||
|  | 				identifier.IsV6IP = true | ||||||
|  | 			} | ||||||
| 		case string(ACMEDNSIdentifier): | 		case string(ACMEDNSIdentifier): | ||||||
| 			identifier.Type = ACMEDNSIdentifier | 			identifier.Type = ACMEDNSIdentifier | ||||||
|  |  | ||||||
| @@ -1010,6 +1025,10 @@ func parseOrderIdentifiers(data map[string]interface{}) ([]*ACMEIdentifier, erro | |||||||
| 		identifiers = append(identifiers, identifier) | 		identifiers = append(identifiers, identifier) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	if len(identifiers) == 0 { | ||||||
|  | 		return nil, fmt.Errorf("no parsed identifiers were found: %w", ErrMalformed) | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	return identifiers, nil | 	return identifiers, nil | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,12 +4,15 @@ | |||||||
| package pki | package pki | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"fmt" | ||||||
| 	"net" | 	"net" | ||||||
|  | 	"strings" | ||||||
| 	"testing" | 	"testing" | ||||||
|  |  | ||||||
| 	"github.com/hashicorp/vault/builtin/logical/pki/issuing" | 	"github.com/hashicorp/vault/builtin/logical/pki/issuing" | ||||||
| 	"github.com/hashicorp/vault/sdk/framework" | 	"github.com/hashicorp/vault/sdk/framework" | ||||||
| 	"github.com/hashicorp/vault/sdk/logical" | 	"github.com/hashicorp/vault/sdk/logical" | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
| 	"github.com/stretchr/testify/require" | 	"github.com/stretchr/testify/require" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -96,6 +99,135 @@ func TestACME_ValidateIdentifiersAgainstRole(t *testing.T) { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Test_parseOrderIdentifiers validates we convert ACME requests into proper ACMEIdentifiers | ||||||
|  | func Test_parseOrderIdentifiers(t *testing.T) { | ||||||
|  | 	tests := []struct { | ||||||
|  | 		name    string | ||||||
|  | 		data    map[string]interface{} | ||||||
|  | 		want    *ACMEIdentifier | ||||||
|  | 		wantErr assert.ErrorAssertionFunc | ||||||
|  | 	}{ | ||||||
|  | 		{ | ||||||
|  | 			name: "ipv4", | ||||||
|  | 			data: map[string]interface{}{"type": "ip", "value": "192.168.1.1"}, | ||||||
|  | 			want: &ACMEIdentifier{ | ||||||
|  | 				Type:          ACMEIPIdentifier, | ||||||
|  | 				Value:         "192.168.1.1", | ||||||
|  | 				OriginalValue: "192.168.1.1", | ||||||
|  | 				IsWildcard:    false, | ||||||
|  | 				IsV6IP:        false, | ||||||
|  | 			}, | ||||||
|  | 			wantErr: assert.NoError, | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name: "ipv6", | ||||||
|  | 			data: map[string]interface{}{"type": "ip", "value": "2001:0:130F::9C0:876A:130B"}, | ||||||
|  | 			want: &ACMEIdentifier{ | ||||||
|  | 				Type:          ACMEIPIdentifier, | ||||||
|  | 				Value:         "2001:0:130F::9C0:876A:130B", | ||||||
|  | 				OriginalValue: "2001:0:130F::9C0:876A:130B", | ||||||
|  | 				IsWildcard:    false, | ||||||
|  | 				IsV6IP:        true, | ||||||
|  | 			}, | ||||||
|  | 			wantErr: assert.NoError, | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name: "ipv4-in-ipv6", | ||||||
|  | 			data: map[string]interface{}{"type": "ip", "value": "::ffff:192.168.1.1"}, | ||||||
|  | 			want: &ACMEIdentifier{ | ||||||
|  | 				Type:          ACMEIPIdentifier, | ||||||
|  | 				Value:         "::ffff:192.168.1.1", | ||||||
|  | 				OriginalValue: "::ffff:192.168.1.1", | ||||||
|  | 				IsWildcard:    false, | ||||||
|  | 				IsV6IP:        true, | ||||||
|  | 			}, | ||||||
|  | 			wantErr: assert.NoError, | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name: "dns", | ||||||
|  | 			data: map[string]interface{}{"type": "dns", "value": "dadgarcorp.com"}, | ||||||
|  | 			want: &ACMEIdentifier{ | ||||||
|  | 				Type:          ACMEDNSIdentifier, | ||||||
|  | 				Value:         "dadgarcorp.com", | ||||||
|  | 				OriginalValue: "dadgarcorp.com", | ||||||
|  | 				IsWildcard:    false, | ||||||
|  | 				IsV6IP:        false, | ||||||
|  | 			}, | ||||||
|  | 			wantErr: assert.NoError, | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name: "wildcard-dns", | ||||||
|  | 			data: map[string]interface{}{"type": "dns", "value": "*.dadgarcorp.com"}, | ||||||
|  | 			want: &ACMEIdentifier{ | ||||||
|  | 				Type:          ACMEDNSIdentifier, | ||||||
|  | 				Value:         "dadgarcorp.com", | ||||||
|  | 				OriginalValue: "*.dadgarcorp.com", | ||||||
|  | 				IsWildcard:    true, | ||||||
|  | 				IsV6IP:        false, | ||||||
|  | 			}, | ||||||
|  | 			wantErr: assert.NoError, | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:    "ipv6-with-zone", // This is debatable if we should strip or fail | ||||||
|  | 			data:    map[string]interface{}{"type": "ip", "value": "fe80::1cc0:3e8c:119f:c2e1%ens18"}, | ||||||
|  | 			wantErr: ErrorContains("IPv6 identifiers with zone information are not allowed"), | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:    "bad-dns-wildcard", | ||||||
|  | 			data:    map[string]interface{}{"type": "dns", "value": "*192.168.1.1"}, | ||||||
|  | 			wantErr: ErrorContains("invalid wildcard"), | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:    "ip-in-dns", | ||||||
|  | 			data:    map[string]interface{}{"type": "dns", "value": "192.168.1.1"}, | ||||||
|  | 			wantErr: ErrorContains("parsed OK as IP address"), | ||||||
|  | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:    "empty-identifiers", | ||||||
|  | 			data:    nil, | ||||||
|  | 			wantErr: ErrorContains("no parsed identifiers were found"), | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	for _, tt := range tests { | ||||||
|  | 		tt := tt | ||||||
|  | 		t.Run(tt.name, func(t *testing.T) { | ||||||
|  | 			identifiers := map[string]interface{}{"identifiers": []interface{}{}} | ||||||
|  | 			if tt.data != nil { | ||||||
|  | 				identifiers["identifiers"] = append(identifiers["identifiers"].([]interface{}), tt.data) | ||||||
|  | 			} | ||||||
|  | 			got, err := parseOrderIdentifiers(identifiers) | ||||||
|  | 			if !tt.wantErr(t, err, fmt.Sprintf("parseOrderIdentifiers(%v)", tt.data)) { | ||||||
|  | 				return | ||||||
|  | 			} else if err != nil { | ||||||
|  | 				// If we passed the test above and an error was set no point in testing below | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			require.Len(t, got, 1, "expected a single return value") | ||||||
|  | 			acmeId := got[0] | ||||||
|  | 			require.Equal(t, tt.want.Type, acmeId.Type) | ||||||
|  | 			require.Equal(t, tt.want.Value, acmeId.Value) | ||||||
|  | 			require.Equal(t, tt.want.OriginalValue, acmeId.OriginalValue) | ||||||
|  | 			require.Equal(t, tt.want.IsWildcard, acmeId.IsWildcard) | ||||||
|  | 			require.Equal(t, tt.want.IsV6IP, acmeId.IsV6IP) | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func ErrorContains(errMsg string) assert.ErrorAssertionFunc { | ||||||
|  | 	return func(t assert.TestingT, err error, i ...interface{}) bool { | ||||||
|  | 		if err == nil { | ||||||
|  | 			return assert.Fail(t, "expected error got none", i...) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if !strings.Contains(err.Error(), errMsg) { | ||||||
|  | 			return assert.Fail(t, fmt.Sprintf("error did not contain '%s':\n%+v", errMsg, err), i...) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		return true | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
| func _buildACMEIdentifiers(values ...string) []*ACMEIdentifier { | func _buildACMEIdentifiers(values ...string) []*ACMEIdentifier { | ||||||
| 	var identifiers []*ACMEIdentifier | 	var identifiers []*ACMEIdentifier | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								changelog/28718.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changelog/28718.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | ```release-note:bug | ||||||
|  | secrets/pki: Address issue with ACME HTTP-01 challenges failing for IPv6 IPs due to improperly formatted URLs | ||||||
|  | ``` | ||||||
		Reference in New Issue
	
	Block a user
	 Steven Clark
					Steven Clark