detached f4879b5

Harden DNS config validation

commit
f4879b548a618d54fb08c8bbe90f0b39e61d470a
author
brice · · 2026-05-01 17:04 UTC
parent
0b16d6a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +66 -9
M internal/adapters/out/acmelego/issuer.go +6 -3
@@ -92,13 +92,16 @@ func NewIssuer(cfg Config) (*Issuer, error) {
9292 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
9393 }
9494 if len(cfg.DNSResolvers) == 0 {
95- return nil, fmt.Errorf("acmelego: DNSResolvers must contain at least one resolver")
95+ return nil, fmt.Errorf("acmelego: %w: DNSResolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
9696 }
9797 if cfg.DNSPropagationTimeout <= 0 {
98- return nil, fmt.Errorf("acmelego: DNSPropagationTimeout must be positive")
98+ return nil, fmt.Errorf("acmelego: %w: DNSPropagationTimeout must be positive", domain.ErrDNSConfigInvalid)
9999 }
100100 if cfg.DNSPollingInterval <= 0 {
101- return nil, fmt.Errorf("acmelego: DNSPollingInterval must be positive")
101+ return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be positive", domain.ErrDNSConfigInvalid)
102+ }
103+ if cfg.DNSPollingInterval >= cfg.DNSPropagationTimeout {
104+ return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be less than DNSPropagationTimeout", domain.ErrDNSConfigInvalid)
102105 }
103106 default:
104107 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
@@ -92,13 +92,16 @@ func NewIssuer(cfg Config) (*Issuer, error) {
92 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)92 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
93 }93 }
94 if len(cfg.DNSResolvers) == 0 {94 if len(cfg.DNSResolvers) == 0 {
95- return nil, fmt.Errorf("acmelego: DNSResolvers must contain at least one resolver")95+ return nil, fmt.Errorf("acmelego: %w: DNSResolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
96 }96 }
97 if cfg.DNSPropagationTimeout <= 0 {97 if cfg.DNSPropagationTimeout <= 0 {
98- return nil, fmt.Errorf("acmelego: DNSPropagationTimeout must be positive")98+ return nil, fmt.Errorf("acmelego: %w: DNSPropagationTimeout must be positive", domain.ErrDNSConfigInvalid)
99 }99 }
100 if cfg.DNSPollingInterval <= 0 {100 if cfg.DNSPollingInterval <= 0 {
101- return nil, fmt.Errorf("acmelego: DNSPollingInterval must be positive")101+ return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be positive", domain.ErrDNSConfigInvalid)
102+ }
103+ if cfg.DNSPollingInterval >= cfg.DNSPropagationTimeout {
104+ return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be less than DNSPropagationTimeout", domain.ErrDNSConfigInvalid)
102 }105 }
103 default:106 default:
104 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)107 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
M internal/adapters/out/acmelego/issuer_test.go +14 -0
@@ -80,8 +80,22 @@ func TestNewIssuerValidatesCloudflareDNSConfig(t *testing.T) {
8080 DNSPollingInterval: 5 * time.Second,
8181 })
8282 require.Error(t, err)
83+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
8384 assert.Contains(t, err.Error(), "DNSResolvers")
8485
86+ _, err = NewIssuer(Config{
87+ Email: "test@example.com",
88+ Challenge: domain.ACMEChallengeCloudflareDNS01,
89+ Token: "token",
90+ Store: outmocks.NewMockCertificateStore(t),
91+ DNSResolvers: []string{"1.1.1.1:53"},
92+ DNSPropagationTimeout: 5 * time.Second,
93+ DNSPollingInterval: 5 * time.Second,
94+ })
95+ require.Error(t, err)
96+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
97+ assert.Contains(t, err.Error(), "DNSPollingInterval")
98+
8599 _, err = NewIssuer(Config{
86100 Email: "test@example.com",
87101 Challenge: domain.ACMEChallengeCloudflareDNS01,
@@ -80,8 +80,22 @@ func TestNewIssuerValidatesCloudflareDNSConfig(t *testing.T) {
80 DNSPollingInterval: 5 * time.Second,80 DNSPollingInterval: 5 * time.Second,
81 })81 })
82 require.Error(t, err)82 require.Error(t, err)
83+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
83 assert.Contains(t, err.Error(), "DNSResolvers")84 assert.Contains(t, err.Error(), "DNSResolvers")
84 85
86+ _, err = NewIssuer(Config{
87+ Email: "test@example.com",
88+ Challenge: domain.ACMEChallengeCloudflareDNS01,
89+ Token: "token",
90+ Store: outmocks.NewMockCertificateStore(t),
91+ DNSResolvers: []string{"1.1.1.1:53"},
92+ DNSPropagationTimeout: 5 * time.Second,
93+ DNSPollingInterval: 5 * time.Second,
94+ })
95+ require.Error(t, err)
96+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
97+ assert.Contains(t, err.Error(), "DNSPollingInterval")
98+
85 _, err = NewIssuer(Config{99 _, err = NewIssuer(Config{
86 Email: "test@example.com",100 Email: "test@example.com",
87 Challenge: domain.ACMEChallengeCloudflareDNS01,101 Challenge: domain.ACMEChallengeCloudflareDNS01,
M internal/domain/errors.go +1 -0
@@ -96,6 +96,7 @@ var (
9696 ErrACMEDisabled = errors.New("acme disabled")
9797 ErrACMEEmailRequired = errors.New("acme email required")
9898 ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
99+ ErrDNSConfigInvalid = errors.New("dns configuration invalid")
99100 ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
100101 ErrCertificateStoreRequired = errors.New("certificate store required")
101102 ErrCertificateIssuerRequired = errors.New("certificate issuer required")
@@ -96,6 +96,7 @@ var (
96 ErrACMEDisabled = errors.New("acme disabled")96 ErrACMEDisabled = errors.New("acme disabled")
97 ErrACMEEmailRequired = errors.New("acme email required")97 ErrACMEEmailRequired = errors.New("acme email required")
98 ErrACMEChallengeInvalid = errors.New("acme challenge invalid")98 ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
99+ ErrDNSConfigInvalid = errors.New("dns configuration invalid")
99 ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")100 ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
100 ErrCertificateStoreRequired = errors.New("certificate store required")101 ErrCertificateStoreRequired = errors.New("certificate store required")
101 ErrCertificateIssuerRequired = errors.New("certificate issuer required")102 ErrCertificateIssuerRequired = errors.New("certificate issuer required")
M internal/usecase/publictls/dns_config.go +16 -6
@@ -3,6 +3,7 @@ package publictls
33 import (
44 "fmt"
55 "net"
6+ "strconv"
67 "strings"
78 "time"
89 )
@@ -32,19 +33,28 @@ func DefaultDNSConfig() DNSConfig {
3233 }
3334 }
3435
35-// Validate checks DNSConfig for ACME DNS-01 usage.
36-func (c DNSConfig) Validate() error {
36+// Validate checks DNSConfig for ACME DNS-01 usage and normalizes resolver whitespace.
37+func (c *DNSConfig) Validate() error {
3738 if len(c.Resolvers) == 0 {
3839 return fmt.Errorf("dns.resolvers must contain at least one resolver")
3940 }
4041 for i, resolver := range c.Resolvers {
41- resolver = strings.TrimSpace(resolver)
42- if resolver == "" {
42+ trimmed := strings.TrimSpace(resolver)
43+ if trimmed == "" {
4344 return fmt.Errorf("invalid dns.resolvers[%d]: empty resolver", i)
4445 }
45- if _, _, err := net.SplitHostPort(resolver); err != nil {
46- return fmt.Errorf("invalid dns.resolvers[%d]: %q: expected host:port: %w", i, resolver, err)
46+ host, port, err := net.SplitHostPort(trimmed)
47+ if err != nil {
48+ return fmt.Errorf("invalid dns.resolvers[%d]: %q: expected host:port: %w", i, trimmed, err)
4749 }
50+ if host == "" {
51+ return fmt.Errorf("invalid dns.resolvers[%d]: %q: host must not be empty", i, trimmed)
52+ }
53+ portNumber, err := strconv.Atoi(port)
54+ if err != nil || portNumber < 1 || portNumber > 65535 {
55+ return fmt.Errorf("invalid dns.resolvers[%d]: %q: port must be a number between 1 and 65535", i, trimmed)
56+ }
57+ c.Resolvers[i] = trimmed
4858 }
4959 if c.PropagationTimeout <= 0 {
5060 return fmt.Errorf("dns.propagation_timeout must be positive")
@@ -3,6 +3,7 @@ package publictls
3 import (3 import (
4 "fmt"4 "fmt"
5 "net"5 "net"
6+ "strconv"
6 "strings"7 "strings"
7 "time"8 "time"
8 )9 )
@@ -32,19 +33,28 @@ func DefaultDNSConfig() DNSConfig {
32 }33 }
33 }34 }
34 35
35-// Validate checks DNSConfig for ACME DNS-01 usage.36+// Validate checks DNSConfig for ACME DNS-01 usage and normalizes resolver whitespace.
36-func (c DNSConfig) Validate() error {37+func (c *DNSConfig) Validate() error {
37 if len(c.Resolvers) == 0 {38 if len(c.Resolvers) == 0 {
38 return fmt.Errorf("dns.resolvers must contain at least one resolver")39 return fmt.Errorf("dns.resolvers must contain at least one resolver")
39 }40 }
40 for i, resolver := range c.Resolvers {41 for i, resolver := range c.Resolvers {
41- resolver = strings.TrimSpace(resolver)42+ trimmed := strings.TrimSpace(resolver)
42- if resolver == "" {43+ if trimmed == "" {
43 return fmt.Errorf("invalid dns.resolvers[%d]: empty resolver", i)44 return fmt.Errorf("invalid dns.resolvers[%d]: empty resolver", i)
44 }45 }
45- if _, _, err := net.SplitHostPort(resolver); err != nil {46+ host, port, err := net.SplitHostPort(trimmed)
46- return fmt.Errorf("invalid dns.resolvers[%d]: %q: expected host:port: %w", i, resolver, err)47+ if err != nil {
48+ return fmt.Errorf("invalid dns.resolvers[%d]: %q: expected host:port: %w", i, trimmed, err)
47 }49 }
50+ if host == "" {
51+ return fmt.Errorf("invalid dns.resolvers[%d]: %q: host must not be empty", i, trimmed)
52+ }
53+ portNumber, err := strconv.Atoi(port)
54+ if err != nil || portNumber < 1 || portNumber > 65535 {
55+ return fmt.Errorf("invalid dns.resolvers[%d]: %q: port must be a number between 1 and 65535", i, trimmed)
56+ }
57+ c.Resolvers[i] = trimmed
48 }58 }
49 if c.PropagationTimeout <= 0 {59 if c.PropagationTimeout <= 0 {
50 return fmt.Errorf("dns.propagation_timeout must be positive")60 return fmt.Errorf("dns.propagation_timeout must be positive")
M internal/usecase/publictls/dns_config_test.go +29 -0
@@ -16,6 +16,17 @@ func TestDefaultDNSConfig(t *testing.T) {
1616 assert.Equal(t, 5*time.Second, cfg.PollingInterval)
1717 }
1818
19+func TestDNSConfigValidateNormalizesResolvers(t *testing.T) {
20+ cfg := DNSConfig{
21+ Resolvers: []string{" 1.1.1.1:53 ", "\t8.8.8.8:53\n"},
22+ PropagationTimeout: 5 * time.Minute,
23+ PollingInterval: 5 * time.Second,
24+ }
25+
26+ require.NoError(t, cfg.Validate())
27+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, cfg.Resolvers)
28+}
29+
1930 func TestDNSConfigValidate(t *testing.T) {
2031 tests := []struct {
2132 name string
@@ -48,6 +59,24 @@ func TestDNSConfigValidate(t *testing.T) {
4859 },
4960 wantErr: "invalid dns.resolvers[0]",
5061 },
62+ {
63+ name: "whitespace-only resolver",
64+ cfg: DNSConfig{
65+ Resolvers: []string{" "},
66+ PropagationTimeout: 5 * time.Minute,
67+ PollingInterval: 5 * time.Second,
68+ },
69+ wantErr: "invalid dns.resolvers[0]",
70+ },
71+ {
72+ name: "malformed resolver port",
73+ cfg: DNSConfig{
74+ Resolvers: []string{"1.1.1.1:abc"},
75+ PropagationTimeout: 5 * time.Minute,
76+ PollingInterval: 5 * time.Second,
77+ },
78+ wantErr: "invalid dns.resolvers[0]",
79+ },
5180 {
5281 name: "zero timeout",
5382 cfg: DNSConfig{
@@ -16,6 +16,17 @@ func TestDefaultDNSConfig(t *testing.T) {
16 assert.Equal(t, 5*time.Second, cfg.PollingInterval)16 assert.Equal(t, 5*time.Second, cfg.PollingInterval)
17 }17 }
18 18
19+func TestDNSConfigValidateNormalizesResolvers(t *testing.T) {
20+ cfg := DNSConfig{
21+ Resolvers: []string{" 1.1.1.1:53 ", "\t8.8.8.8:53\n"},
22+ PropagationTimeout: 5 * time.Minute,
23+ PollingInterval: 5 * time.Second,
24+ }
25+
26+ require.NoError(t, cfg.Validate())
27+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, cfg.Resolvers)
28+}
29+
19 func TestDNSConfigValidate(t *testing.T) {30 func TestDNSConfigValidate(t *testing.T) {
20 tests := []struct {31 tests := []struct {
21 name string32 name string
@@ -48,6 +59,24 @@ func TestDNSConfigValidate(t *testing.T) {
48 },59 },
49 wantErr: "invalid dns.resolvers[0]",60 wantErr: "invalid dns.resolvers[0]",
50 },61 },
62+ {
63+ name: "whitespace-only resolver",
64+ cfg: DNSConfig{
65+ Resolvers: []string{" "},
66+ PropagationTimeout: 5 * time.Minute,
67+ PollingInterval: 5 * time.Second,
68+ },
69+ wantErr: "invalid dns.resolvers[0]",
70+ },
71+ {
72+ name: "malformed resolver port",
73+ cfg: DNSConfig{
74+ Resolvers: []string{"1.1.1.1:abc"},
75+ PropagationTimeout: 5 * time.Minute,
76+ PollingInterval: 5 * time.Second,
77+ },
78+ wantErr: "invalid dns.resolvers[0]",
79+ },
51 {80 {
52 name: "zero timeout",81 name: "zero timeout",
53 cfg: DNSConfig{82 cfg: DNSConfig{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close