boulder/policy/pa_test.go

576 lines
20 KiB
Go

package policy
import (
"fmt"
"net/netip"
"os"
"testing"
"gopkg.in/yaml.v3"
"github.com/letsencrypt/boulder/core"
berrors "github.com/letsencrypt/boulder/errors"
"github.com/letsencrypt/boulder/features"
"github.com/letsencrypt/boulder/identifier"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/test"
)
func paImpl(t *testing.T) *AuthorityImpl {
enabledChallenges := map[core.AcmeChallenge]bool{
core.ChallengeTypeHTTP01: true,
core.ChallengeTypeDNS01: true,
core.ChallengeTypeTLSALPN01: true,
}
pa, err := New(enabledChallenges, blog.NewMock())
if err != nil {
t.Fatalf("Couldn't create policy implementation: %s", err)
}
return pa
}
func TestWellFormedIdentifiers(t *testing.T) {
testCases := []struct {
domain string
err error
}{
{``, errEmptyName}, // Empty name
{`zomb!.com`, errInvalidDNSCharacter}, // ASCII character out of range
{`emailaddress@myseriously.present.com`, errInvalidDNSCharacter},
{`user:pass@myseriously.present.com`, errInvalidDNSCharacter},
{`zömbo.com`, errInvalidDNSCharacter}, // non-ASCII character
{`127.0.0.1`, errIPAddress}, // IPv4 address
{`fe80::1:1`, errInvalidDNSCharacter}, // IPv6 addresses
{`[2001:db8:85a3:8d3:1319:8a2e:370:7348]`, errInvalidDNSCharacter}, // unexpected IPv6 variants
{`[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443`, errInvalidDNSCharacter},
{`2001:db8::/32`, errInvalidDNSCharacter},
{`a.b.c.d.e.f.g.h.i.j.k`, errTooManyLabels}, // Too many labels (>10)
{`www.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345.com`, errNameTooLong}, // Too long (254 characters)
{`www.ef0123456789abcdef013456789abcdef012345.789abcdef012345679abcdef0123456789abcdef01234.6789abcdef0123456789abcdef0.23456789abcdef0123456789a.cdef0123456789abcdef0123456789ab.def0123456789abcdef0123456789.bcdef0123456789abcdef012345.com`, nil}, // OK, not too long (240 characters)
{`www.abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz.com`, errLabelTooLong}, // Label too long (>63 characters)
{`www.-ombo.com`, errInvalidDNSCharacter}, // Label starts with '-'
{`www.zomb-.com`, errInvalidDNSCharacter}, // Label ends with '-'
{`xn--.net`, errInvalidDNSCharacter}, // Label ends with '-'
{`-0b.net`, errInvalidDNSCharacter}, // First label begins with '-'
{`-0.net`, errInvalidDNSCharacter}, // First label begins with '-'
{`-.net`, errInvalidDNSCharacter}, // First label is only '-'
{`---.net`, errInvalidDNSCharacter}, // First label is only hyphens
{`0`, errTooFewLabels},
{`1`, errTooFewLabels},
{`*`, errMalformedWildcard},
{`**`, errTooManyWildcards},
{`*.*`, errTooManyWildcards},
{`zombo*com`, errMalformedWildcard},
{`*.com`, errICANNTLDWildcard},
{`..a`, errLabelTooShort},
{`a..a`, errLabelTooShort},
{`.a..a`, errLabelTooShort},
{`..foo.com`, errLabelTooShort},
{`.`, errNameEndsInDot},
{`..`, errNameEndsInDot},
{`a..`, errNameEndsInDot},
{`.....`, errNameEndsInDot},
{`.a.`, errNameEndsInDot},
{`www.zombo.com.`, errNameEndsInDot},
{`www.zombo_com.com`, errInvalidDNSCharacter},
{`\uFEFF`, errInvalidDNSCharacter}, // Byte order mark
{`\uFEFFwww.zombo.com`, errInvalidDNSCharacter},
{`www.zom\u202Ebo.com`, errInvalidDNSCharacter}, // Right-to-Left Override
{`\u202Ewww.zombo.com`, errInvalidDNSCharacter},
{`www.zom\u200Fbo.com`, errInvalidDNSCharacter}, // Right-to-Left Mark
{`\u200Fwww.zombo.com`, errInvalidDNSCharacter},
// Underscores are technically disallowed in DNS. Some DNS
// implementations accept them but we will be conservative.
{`www.zom_bo.com`, errInvalidDNSCharacter},
{`zombocom`, errTooFewLabels},
{`localhost`, errTooFewLabels},
{`mail`, errTooFewLabels},
// disallow capitalized letters for #927
{`CapitalizedLetters.com`, errInvalidDNSCharacter},
{`example.acting`, errNonPublic},
{`example.internal`, errNonPublic},
// All-numeric final label not okay.
{`www.zombo.163`, errNonPublic},
{`xn--109-3veba6djs1bfxlfmx6c9g.xn--f1awi.xn--p1ai`, errMalformedIDN}, // Not in Unicode NFC
{`bq--abwhky3f6fxq.jakacomo.com`, errInvalidRLDH},
// Three hyphens starting at third second char of first label.
{`bq---abwhky3f6fxq.jakacomo.com`, errInvalidRLDH},
// Three hyphens starting at second char of first label.
{`h---test.hk2yz.org`, errInvalidRLDH},
{`co.uk`, errICANNTLD},
{`foo.bd`, errICANNTLD},
}
// Test syntax errors
for _, tc := range testCases {
err := WellFormedIdentifiers(identifier.ACMEIdentifiers{identifier.NewDNS(tc.domain)})
if tc.err == nil {
test.AssertNil(t, err, fmt.Sprintf("Unexpected error for domain %q, got %s", tc.domain, err))
} else {
test.AssertError(t, err, fmt.Sprintf("Expected error for domain %q, but got none", tc.domain))
var berr *berrors.BoulderError
test.AssertErrorWraps(t, err, &berr)
test.AssertContains(t, berr.Error(), tc.err.Error())
}
}
err := WellFormedIdentifiers(identifier.ACMEIdentifiers{identifier.NewIP(netip.MustParseAddr("9.9.9.9"))})
test.AssertError(t, err, "Expected error for IP, but got none")
var berr *berrors.BoulderError
test.AssertErrorWraps(t, err, &berr)
test.AssertContains(t, berr.Error(), errUnsupportedIdent.Error())
}
func TestWillingToIssue(t *testing.T) {
shouldBeBlocked := []string{
`highvalue.website1.org`,
`website2.co.uk`,
`www.website3.com`,
`lots.of.labels.website4.com`,
`banned.in.dc.com`,
`bad.brains.banned.in.dc.com`,
}
blocklistContents := []string{
`website2.com`,
`website2.org`,
`website2.co.uk`,
`website3.com`,
`website4.com`,
}
exactBlocklistContents := []string{
`www.website1.org`,
`highvalue.website1.org`,
`dl.website1.org`,
}
adminBlockedContents := []string{
`banned.in.dc.com`,
}
shouldBeAccepted := []string{
`lowvalue.website1.org`,
`website4.sucks`,
"www.unrelated.com",
"unrelated.com",
"www.8675309.com",
"8675309.com",
"web5ite2.com",
"www.web-site2.com",
}
policy := blockedNamesPolicy{
HighRiskBlockedNames: blocklistContents,
ExactBlockedNames: exactBlocklistContents,
AdminBlockedNames: adminBlockedContents,
}
yamlPolicyBytes, err := yaml.Marshal(policy)
test.AssertNotError(t, err, "Couldn't YAML serialize blocklist")
yamlPolicyFile, _ := os.CreateTemp("", "test-blocklist.*.yaml")
defer os.Remove(yamlPolicyFile.Name())
err = os.WriteFile(yamlPolicyFile.Name(), yamlPolicyBytes, 0640)
test.AssertNotError(t, err, "Couldn't write YAML blocklist")
pa := paImpl(t)
err = pa.LoadHostnamePolicyFile(yamlPolicyFile.Name())
test.AssertNotError(t, err, "Couldn't load rules")
// Invalid encoding
err = pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewDNS("www.xn--m.com")})
test.AssertError(t, err, "WillingToIssue didn't fail on a malformed IDN")
// Invalid identifier type
err = pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewIP(netip.MustParseAddr("1.1.1.1"))})
test.AssertError(t, err, "WillingToIssue didn't fail on an IP address")
// Valid encoding
err = pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewDNS("www.xn--mnich-kva.com")})
test.AssertNotError(t, err, "WillingToIssue failed on a properly formed IDN")
// IDN TLD
err = pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewDNS("xn--example--3bhk5a.xn--p1ai")})
test.AssertNotError(t, err, "WillingToIssue failed on a properly formed domain with IDN TLD")
features.Reset()
// Test expected blocked domains
for _, domain := range shouldBeBlocked {
err := pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewDNS(domain)})
test.AssertError(t, err, "domain was not correctly forbidden")
var berr *berrors.BoulderError
test.AssertErrorWraps(t, err, &berr)
test.AssertContains(t, berr.Detail, errPolicyForbidden.Error())
}
// Test acceptance of good names
for _, domain := range shouldBeAccepted {
err := pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewDNS(domain)})
test.AssertNotError(t, err, "domain was incorrectly forbidden")
}
}
func TestWillingToIssue_Wildcards(t *testing.T) {
bannedDomains := []string{
"zombo.gov.us",
}
exactBannedDomains := []string{
"highvalue.letsdecrypt.org",
}
pa := paImpl(t)
bannedBytes, err := yaml.Marshal(blockedNamesPolicy{
HighRiskBlockedNames: bannedDomains,
ExactBlockedNames: exactBannedDomains,
})
test.AssertNotError(t, err, "Couldn't serialize banned list")
f, _ := os.CreateTemp("", "test-wildcard-banlist.*.yaml")
defer os.Remove(f.Name())
err = os.WriteFile(f.Name(), bannedBytes, 0640)
test.AssertNotError(t, err, "Couldn't write serialized banned list to file")
err = pa.LoadHostnamePolicyFile(f.Name())
test.AssertNotError(t, err, "Couldn't load policy contents from file")
testCases := []struct {
Name string
Domain string
ExpectedErr error
}{
{
Name: "Too many wildcards",
Domain: "ok.*.whatever.*.example.com",
ExpectedErr: errTooManyWildcards,
},
{
Name: "Misplaced wildcard",
Domain: "ok.*.whatever.example.com",
ExpectedErr: errMalformedWildcard,
},
{
Name: "Missing ICANN TLD",
Domain: "*.ok.madeup",
ExpectedErr: errNonPublic,
},
{
Name: "Wildcard for ICANN TLD",
Domain: "*.com",
ExpectedErr: errICANNTLDWildcard,
},
{
Name: "Forbidden base domain",
Domain: "*.zombo.gov.us",
ExpectedErr: errPolicyForbidden,
},
// We should not allow getting a wildcard for that would cover an exact
// blocklist domain
{
Name: "Wildcard for ExactBlocklist base domain",
Domain: "*.letsdecrypt.org",
ExpectedErr: errPolicyForbidden,
},
// We should allow a wildcard for a domain that doesn't match the exact
// blocklist domain
{
Name: "Wildcard for non-matching subdomain of ExactBlocklist domain",
Domain: "*.lowvalue.letsdecrypt.org",
ExpectedErr: nil,
},
// We should allow getting a wildcard for an exact blocklist domain since it
// only covers subdomains, not the exact name.
{
Name: "Wildcard for ExactBlocklist domain",
Domain: "*.highvalue.letsdecrypt.org",
ExpectedErr: nil,
},
{
Name: "Valid wildcard domain",
Domain: "*.everything.is.possible.at.zombo.com",
ExpectedErr: nil,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
err := pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewDNS(tc.Domain)})
if tc.ExpectedErr == nil {
test.AssertNil(t, err, fmt.Sprintf("Unexpected error for domain %q, got %s", tc.Domain, err))
} else {
test.AssertError(t, err, fmt.Sprintf("Expected error for domain %q, but got none", tc.Domain))
var berr *berrors.BoulderError
test.AssertErrorWraps(t, err, &berr)
test.AssertContains(t, berr.Error(), tc.ExpectedErr.Error())
}
})
}
}
// TestWillingToIssue_SubErrors tests that more than one rejected identifier
// results in an error with suberrors.
func TestWillingToIssue_SubErrors(t *testing.T) {
banned := []string{
"letsdecrypt.org",
"example.com",
}
pa := paImpl(t)
bannedBytes, err := yaml.Marshal(blockedNamesPolicy{
HighRiskBlockedNames: banned,
ExactBlockedNames: banned,
})
test.AssertNotError(t, err, "Couldn't serialize banned list")
f, _ := os.CreateTemp("", "test-wildcard-banlist.*.yaml")
defer os.Remove(f.Name())
err = os.WriteFile(f.Name(), bannedBytes, 0640)
test.AssertNotError(t, err, "Couldn't write serialized banned list to file")
err = pa.LoadHostnamePolicyFile(f.Name())
test.AssertNotError(t, err, "Couldn't load policy contents from file")
// Test multiple malformed domains and one banned domain; only the malformed ones will generate errors
err = pa.WillingToIssue(identifier.ACMEIdentifiers{
identifier.NewDNS("perfectly-fine.com"), // fine
identifier.NewDNS("letsdecrypt_org"), // malformed
identifier.NewDNS("example.comm"), // malformed
identifier.NewDNS("letsdecrypt.org"), // banned
identifier.NewDNS("also-perfectly-fine.com"), // fine
})
test.AssertDeepEquals(t, err,
&berrors.BoulderError{
Type: berrors.RejectedIdentifier,
Detail: "Cannot issue for \"letsdecrypt_org\": Domain name contains an invalid character (and 1 more problems. Refer to sub-problems for more information.)",
SubErrors: []berrors.SubBoulderError{
{
BoulderError: &berrors.BoulderError{
Type: berrors.Malformed,
Detail: "Domain name contains an invalid character",
},
Identifier: identifier.NewDNS("letsdecrypt_org"),
},
{
BoulderError: &berrors.BoulderError{
Type: berrors.Malformed,
Detail: "Domain name does not end with a valid public suffix (TLD)",
},
Identifier: identifier.NewDNS("example.comm"),
},
},
})
// Test multiple banned domains.
err = pa.WillingToIssue(identifier.ACMEIdentifiers{
identifier.NewDNS("perfectly-fine.com"), // fine
identifier.NewDNS("letsdecrypt.org"), // banned
identifier.NewDNS("example.com"), // banned
identifier.NewDNS("also-perfectly-fine.com"), // fine
})
test.AssertError(t, err, "Expected err from WillingToIssueWildcards")
test.AssertDeepEquals(t, err,
&berrors.BoulderError{
Type: berrors.RejectedIdentifier,
Detail: "Cannot issue for \"letsdecrypt.org\": The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy (and 1 more problems. Refer to sub-problems for more information.)",
SubErrors: []berrors.SubBoulderError{
{
BoulderError: &berrors.BoulderError{
Type: berrors.RejectedIdentifier,
Detail: "The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy",
},
Identifier: identifier.NewDNS("letsdecrypt.org"),
},
{
BoulderError: &berrors.BoulderError{
Type: berrors.RejectedIdentifier,
Detail: "The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy",
},
Identifier: identifier.NewDNS("example.com"),
},
},
})
// Test willing to issue with only *one* bad identifier.
err = pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewDNS("letsdecrypt.org")})
test.AssertDeepEquals(t, err,
&berrors.BoulderError{
Type: berrors.RejectedIdentifier,
Detail: "Cannot issue for \"letsdecrypt.org\": The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy",
})
}
func TestChallengeTypesFor(t *testing.T) {
t.Parallel()
pa := paImpl(t)
testCases := []struct {
name string
ident identifier.ACMEIdentifier
wantChalls []core.AcmeChallenge
wantErr string
}{
{
name: "dns",
ident: identifier.NewDNS("example.com"),
wantChalls: []core.AcmeChallenge{
core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01, core.ChallengeTypeTLSALPN01,
},
},
{
name: "wildcard",
ident: identifier.NewDNS("*.example.com"),
wantChalls: []core.AcmeChallenge{
core.ChallengeTypeDNS01,
},
},
{
name: "other",
ident: identifier.NewIP(netip.MustParseAddr("1.2.3.4")),
wantErr: "unrecognized identifier type",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
challs, err := pa.ChallengeTypesFor(tc.ident)
if len(tc.wantChalls) != 0 {
test.AssertNotError(t, err, "should have succeeded")
test.AssertDeepEquals(t, challs, tc.wantChalls)
}
if tc.wantErr != "" {
test.AssertError(t, err, "should have errored")
test.AssertContains(t, err.Error(), tc.wantErr)
}
})
}
}
// TestMalformedExactBlocklist tests that loading a YAML policy file with an
// invalid exact blocklist entry will fail as expected.
func TestMalformedExactBlocklist(t *testing.T) {
pa := paImpl(t)
exactBannedDomains := []string{
// Only one label - not valid
"com",
}
bannedDomains := []string{
"placeholder.domain.not.important.for.this.test.com",
}
// Create YAML for the exactBannedDomains
bannedBytes, err := yaml.Marshal(blockedNamesPolicy{
HighRiskBlockedNames: bannedDomains,
ExactBlockedNames: exactBannedDomains,
})
test.AssertNotError(t, err, "Couldn't serialize banned list")
// Create a temp file for the YAML contents
f, _ := os.CreateTemp("", "test-invalid-exactblocklist.*.yaml")
defer os.Remove(f.Name())
// Write the YAML to the temp file
err = os.WriteFile(f.Name(), bannedBytes, 0640)
test.AssertNotError(t, err, "Couldn't write serialized banned list to file")
// Try to use the YAML tempfile as the hostname policy. It should produce an
// error since the exact blocklist contents are malformed.
err = pa.LoadHostnamePolicyFile(f.Name())
test.AssertError(t, err, "Loaded invalid exact blocklist content without error")
test.AssertEquals(t, err.Error(), "Malformed ExactBlockedNames entry, only one label: \"com\"")
}
func TestValidEmailError(t *testing.T) {
err := ValidEmail("(๑•́ ω •̀๑)")
test.AssertEquals(t, err.Error(), "unable to parse email address")
err = ValidEmail("john.smith@gmail.com #replace with real email")
test.AssertEquals(t, err.Error(), "unable to parse email address")
err = ValidEmail("example@example.com")
test.AssertEquals(t, err.Error(), "contact email has forbidden domain \"example.com\"")
err = ValidEmail("example@-foobar.com")
test.AssertEquals(t, err.Error(), "contact email has invalid domain: Domain name contains an invalid character")
}
func TestCheckAuthzChallenges(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
authz core.Authorization
enabled map[core.AcmeChallenge]bool
wantErr string
}{
{
name: "unrecognized identifier",
authz: core.Authorization{
Identifier: identifier.ACMEIdentifier{Type: "oops", Value: "example.com"},
Challenges: []core.Challenge{{Type: core.ChallengeTypeDNS01, Status: core.StatusValid}},
},
wantErr: "unrecognized identifier type",
},
{
name: "no challenges",
authz: core.Authorization{
Identifier: identifier.NewDNS("example.com"),
Challenges: []core.Challenge{},
},
wantErr: "has no challenges",
},
{
name: "no valid challenges",
authz: core.Authorization{
Identifier: identifier.NewDNS("example.com"),
Challenges: []core.Challenge{{Type: core.ChallengeTypeDNS01, Status: core.StatusPending}},
},
wantErr: "not solved by any challenge",
},
{
name: "solved by disabled challenge",
authz: core.Authorization{
Identifier: identifier.NewDNS("example.com"),
Challenges: []core.Challenge{{Type: core.ChallengeTypeDNS01, Status: core.StatusValid}},
},
enabled: map[core.AcmeChallenge]bool{core.ChallengeTypeHTTP01: true},
wantErr: "disabled challenge type",
},
{
name: "solved by wrong kind of challenge",
authz: core.Authorization{
Identifier: identifier.NewDNS("*.example.com"),
Challenges: []core.Challenge{{Type: core.ChallengeTypeHTTP01, Status: core.StatusValid}},
},
wantErr: "inapplicable challenge type",
},
{
name: "valid authz",
authz: core.Authorization{
Identifier: identifier.NewDNS("example.com"),
Challenges: []core.Challenge{{Type: core.ChallengeTypeTLSALPN01, Status: core.StatusValid}},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
pa := paImpl(t)
if tc.enabled != nil {
pa.enabledChallenges = tc.enabled
}
err := pa.CheckAuthzChallenges(&tc.authz)
if tc.wantErr == "" {
test.AssertNotError(t, err, "should have succeeded")
} else {
test.AssertError(t, err, "should have errored")
test.AssertContains(t, err.Error(), tc.wantErr)
}
})
}
}