Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 23:30 UTC
httpcaddyfile: Validates TLS DNS challenge options (#7099)
* httpcaddyfile: Validates TLS DNS challenge options Adds validation to the TLS Caddyfile adapter to ensure that when DNS challenge options (such as propagation_delay or dns_ttl) are specified, a DNS provider is also configured. Adds new integration tests to verify this validation logic, and implements a new mechanism for adapt tests to assert a config adapt error. * Add some more AI-generated tests asserting config errors * Parallel doesn't work here, we use global variables * Windows fix
Francis Lavoie committed
Jun 30, 2025 at 19:58 UTC
77dd12cc785990c5c5da947b4e883029ab8bd552
26 files changed
+279
-22
caddyconfig/caddyfile/lexer.go
+2
-1
@@ -323,7 +323,8 @@ func (l *lexer) finalizeHeredoc(val []rune, marker string) ([]rune, error) {
323
324
// if the padding doesn't match exactly at the start then we can't safely strip
325
if index != 0 {
326
- return nil, fmt.Errorf("mismatched leading whitespace in heredoc <<%s on line #%d [%s], expected whitespace [%s] to match the closing marker", marker, l.line+lineNum+1, lineText, paddingToStrip)
326
+ cleanLineText := strings.TrimRight(lineText, "\r\n")
327
+ return nil, fmt.Errorf("mismatched leading whitespace in heredoc <<%s on line #%d [%s], expected whitespace [%s] to match the closing marker", marker, l.line+lineNum+1, cleanLineText, paddingToStrip)
328
}
329
330
// strip, then append the line, with the newline, to the output.
caddyconfig/httpcaddyfile/builtins.go
+20
@@ -130,6 +130,9 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
130
var reusePrivateKeys bool
131
var forceAutomate bool
132
133
+ // Track which DNS challenge options are set
134
+ var dnsOptionsSet []string
135
+
136
firstLine := h.RemainingArgs()
137
switch len(firstLine) {
138
case 0:
@@ -350,6 +353,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
353
if acmeIssuer.Challenges.DNS == nil {
354
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
355
}
356
+ dnsOptionsSet = append(dnsOptionsSet, "resolvers")
357
acmeIssuer.Challenges.DNS.Resolvers = args
358
359
case "propagation_delay":
@@ -371,6 +375,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
375
if acmeIssuer.Challenges.DNS == nil {
376
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
377
}
378
+ dnsOptionsSet = append(dnsOptionsSet, "propagation_delay")
379
acmeIssuer.Challenges.DNS.PropagationDelay = caddy.Duration(delay)
380
381
case "propagation_timeout":
@@ -398,6 +403,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
403
if acmeIssuer.Challenges.DNS == nil {
404
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
405
}
406
+ dnsOptionsSet = append(dnsOptionsSet, "propagation_timeout")
407
acmeIssuer.Challenges.DNS.PropagationTimeout = caddy.Duration(timeout)
408
409
case "dns_ttl":
@@ -419,6 +425,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
425
if acmeIssuer.Challenges.DNS == nil {
426
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
427
}
428
+ dnsOptionsSet = append(dnsOptionsSet, "dns_ttl")
429
acmeIssuer.Challenges.DNS.TTL = caddy.Duration(ttl)
430
431
case "dns_challenge_override_domain":
@@ -435,6 +442,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
442
if acmeIssuer.Challenges.DNS == nil {
443
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
444
}
445
+ dnsOptionsSet = append(dnsOptionsSet, "dns_challenge_override_domain")
446
acmeIssuer.Challenges.DNS.OverrideDomain = arg[0]
447
448
case "ca_root":
@@ -470,6 +478,18 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
478
}
479
}
480
481
+ // Validate DNS challenge config: any DNS challenge option except "dns" requires a DNS provider
482
+ if acmeIssuer != nil && acmeIssuer.Challenges != nil && acmeIssuer.Challenges.DNS != nil {
483
+ dnsCfg := acmeIssuer.Challenges.DNS
484
+ providerSet := dnsCfg.ProviderRaw != nil || h.Option("dns") != nil
485
+ if len(dnsOptionsSet) > 0 && !providerSet {
486
+ return nil, h.Errf(
487
+ "setting DNS challenge options [%s] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option)",
488
+ strings.Join(dnsOptionsSet, ", "),
489
+ )
490
+ }
491
+ }
492
+
493
// a naked tls directive is not allowed
494
if len(firstLine) == 0 && !hasBlock {
495
return nil, h.ArgErr()
caddytest/integration/caddyfile_adapt/ambiguous_site_definition.caddyfiletest
new
+12
@@ -0,0 +1,12 @@
1
+example.com
2
+handle {
3
+ respond "one"
4
+}
5
+
6
+example.com
7
+handle {
8
+ respond "two"
9
+}
10
+----------
11
+Caddyfile:6: unrecognized directive: example.com
12
+Did you mean to define a second site? If so, you must use curly braces around each site to separate their configurations.
\ No newline at end of file
caddytest/integration/caddyfile_adapt/ambiguous_site_definition_duplicate_key.caddyfiletest
new
+9
@@ -0,0 +1,9 @@
1
+:8080 {
2
+ respond "one"
3
+}
4
+
5
+:8080 {
6
+ respond "two"
7
+}
8
+----------
9
+ambiguous site definition: :8080
\ No newline at end of file
caddytest/integration/caddyfile_adapt/directive_as_site_address.caddyfiletest
new
+5
@@ -0,0 +1,5 @@
1
+handle
2
+
3
+respond "should not work"
4
+----------
5
+Caddyfile:1: parsed 'handle' as a site address, but it is a known directive; directives must appear in a site block
\ No newline at end of file
caddytest/integration/caddyfile_adapt/duplicate_listener_address_global.caddyfiletest
new
+12
@@ -0,0 +1,12 @@
1
+{
2
+ servers {
3
+ srv0 {
4
+ listen :8080
5
+ }
6
+ srv1 {
7
+ listen :8080
8
+ }
9
+ }
10
+}
11
+----------
12
+parsing caddyfile tokens for 'servers': unrecognized servers option 'srv0', at Caddyfile:3
\ No newline at end of file
caddytest/integration/caddyfile_adapt/heredoc_extra_indentation.caddyfiletest
new
+41
@@ -0,0 +1,41 @@
1
+:80
2
+
3
+handle {
4
+ respond <<END
5
+ line1
6
+ line2
7
+ END
8
+}
9
+----------
10
+{
11
+ "apps": {
12
+ "http": {
13
+ "servers": {
14
+ "srv0": {
15
+ "listen": [
16
+ ":80"
17
+ ],
18
+ "routes": [
19
+ {
20
+ "handle": [
21
+ {
22
+ "handler": "subroute",
23
+ "routes": [
24
+ {
25
+ "handle": [
26
+ {
27
+ "body": " line1\n line2",
28
+ "handler": "static_response"
29
+ }
30
+ ]
31
+ }
32
+ ]
33
+ }
34
+ ]
35
+ }
36
+ ]
37
+ }
38
+ }
39
+ }
40
+ }
41
+}
caddytest/integration/caddyfile_adapt/heredoc_incomplete.caddyfiletest
new
+9
@@ -0,0 +1,9 @@
1
+:80
2
+
3
+handle {
4
+ respond <<EOF
5
+ Hello
6
+# missing EOF marker
7
+}
8
+----------
9
+mismatched leading whitespace in heredoc <<EOF on line #5 [ Hello], expected whitespace [# missing ] to match the closing marker
\ No newline at end of file
caddytest/integration/caddyfile_adapt/heredoc_invalid_marker.caddyfiletest
new
+9
@@ -0,0 +1,9 @@
1
+:80
2
+
3
+handle {
4
+ respond <<END!
5
+ Hello
6
+ END!
7
+}
8
+----------
9
+heredoc marker on line #4 must contain only alpha-numeric characters, dashes and underscores; got 'END!'
\ No newline at end of file
caddytest/integration/caddyfile_adapt/heredoc_mismatched_whitespace.caddyfiletest
new
+10
@@ -0,0 +1,10 @@
1
+:80
2
+
3
+handle {
4
+ respond <<END
5
+ line1
6
+ line2
7
+ END
8
+}
9
+----------
10
+mismatched leading whitespace in heredoc <<END on line #5 [ line1], expected whitespace [ ] to match the closing marker
\ No newline at end of file
caddytest/integration/caddyfile_adapt/heredoc_missing_marker.caddyfiletest
new
+9
@@ -0,0 +1,9 @@
1
+:80
2
+
3
+handle {
4
+ respond <<
5
+ Hello
6
+ END
7
+}
8
+----------
9
+parsing caddyfile tokens for 'handle': unrecognized directive: Hello - are you sure your Caddyfile structure (nesting and braces) is correct?, at Caddyfile:7
\ No newline at end of file
caddytest/integration/caddyfile_adapt/heredoc_too_many_angle_brackets.caddyfiletest
new
+9
@@ -0,0 +1,9 @@
1
+:80
2
+
3
+handle {
4
+ respond <<<END
5
+ Hello
6
+ END
7
+}
8
+----------
9
+too many '<' for heredoc on line #4; only use two, for example <<END
\ No newline at end of file
caddytest/integration/caddyfile_adapt/import_cycle.caddyfiletest
new
+12
@@ -0,0 +1,12 @@
1
+(import1) {
2
+ import import2
3
+}
4
+
5
+(import2) {
6
+ import import1
7
+}
8
+
9
+import import1
10
+
11
+----------
12
+a cycle of imports exists between Caddyfile:import2 and Caddyfile:import1
\ No newline at end of file
caddytest/integration/caddyfile_adapt/invoke_undefined_named_route.caddyfiletest
new
+5
@@ -0,0 +1,5 @@
1
+example.com {
2
+ invoke foo
3
+}
4
+----------
5
+cannot invoke named route 'foo', which was not defined
\ No newline at end of file
caddytest/integration/caddyfile_adapt/matcher_outside_site_block.caddyfiletest
new
+9
@@ -0,0 +1,9 @@
1
+@foo {
2
+ path /foo
3
+}
4
+
5
+handle {
6
+ respond "should not work"
7
+}
8
+----------
9
+request matchers may not be defined globally, they must be in a site block; found @foo, at Caddyfile:1
\ No newline at end of file
caddytest/integration/caddyfile_adapt/site_address_invalid_port.caddyfiletest
new
+7
@@ -0,0 +1,7 @@
1
+:70000
2
+
3
+handle {
4
+ respond "should not work"
5
+}
6
+----------
7
+port 70000 is out of range
\ No newline at end of file
caddytest/integration/caddyfile_adapt/site_address_negative_port.caddyfiletest
new
+7
@@ -0,0 +1,7 @@
1
+:-1
2
+
3
+handle {
4
+ respond "should not work"
5
+}
6
+----------
7
+port -1 is out of range
\ No newline at end of file
caddytest/integration/caddyfile_adapt/site_address_unsupported_scheme.caddyfiletest
new
+7
@@ -0,0 +1,7 @@
1
+foo://example.com
2
+
3
+handle {
4
+ respond "hello"
5
+}
6
+----------
7
+unsupported URL scheme foo://
\ No newline at end of file
caddytest/integration/caddyfile_adapt/site_address_wss_invalid_port.caddyfiletest
new
+7
@@ -0,0 +1,7 @@
1
+wss://example.com:70000
2
+
3
+handle {
4
+ respond "should not work"
5
+}
6
+----------
7
+port 70000 is out of range
\ No newline at end of file
caddytest/integration/caddyfile_adapt/site_address_wss_scheme.caddyfiletest
new
+7
@@ -0,0 +1,7 @@
1
+wss://example.com
2
+
3
+handle {
4
+ respond "hello"
5
+}
6
+----------
7
+the scheme wss:// is only supported in browsers; use https:// instead
\ No newline at end of file
caddytest/integration/caddyfile_adapt/tls_dns_multiple_options_without_provider.caddyfiletest
new
+9
@@ -0,0 +1,9 @@
1
+localhost
2
+
3
+tls {
4
+ propagation_delay 10s
5
+ dns_ttl 5m
6
+}
7
+
8
+----------
9
+parsing caddyfile tokens for 'tls': setting DNS challenge options [propagation_delay, dns_ttl] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option), at Caddyfile:6
\ No newline at end of file
caddytest/integration/caddyfile_adapt/tls_dns_propagation_timeout_without_provider.caddyfiletest
new
+7
@@ -0,0 +1,7 @@
1
+:443 {
2
+ tls {
3
+ propagation_timeout 30s
4
+ }
5
+}
6
+----------
7
+parsing caddyfile tokens for 'tls': setting DNS challenge options [propagation_timeout] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option), at Caddyfile:4
\ No newline at end of file
caddytest/integration/caddyfile_adapt/tls_dns_propagation_without_provider.caddyfiletest
new
+7
@@ -0,0 +1,7 @@
1
+:443 {
2
+ tls {
3
+ propagation_delay 30s
4
+ }
5
+}
6
+----------
7
+parsing caddyfile tokens for 'tls': setting DNS challenge options [propagation_delay] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option), at Caddyfile:4
\ No newline at end of file
caddytest/integration/caddyfile_adapt/tls_dns_ttl.caddyfiletest
+4
@@ -2,6 +2,7 @@ localhost
2
3
respond "hello from localhost"
4
tls {
5
+ dns mock
6
dns_ttl 5m10s
7
}
8
----------
@@ -54,6 +55,9 @@ tls {
55
{
56
"challenges": {
57
"dns": {
58
+ "provider": {
59
+ "name": "mock"
60
+ },
61
"ttl": 310000000000
62
}
63
},
caddytest/integration/caddyfile_adapt/tls_propagation_options.caddyfiletest
+5
-1
@@ -2,6 +2,7 @@ localhost
2
3
respond "hello from localhost"
4
tls {
5
+ dns mock
6
propagation_delay 5m10s
7
propagation_timeout 10m20s
8
}
@@ -56,7 +57,10 @@ tls {
57
"challenges": {
58
"dns": {
59
"propagation_delay": 310000000000,
59
- "propagation_timeout": 620000000000
60
+ "propagation_timeout": 620000000000,
61
+ "provider": {
62
+ "name": "mock"
63
+ }
64
}
65
},
66
"module": "acme"
caddytest/integration/caddyfile_adapt_test.go
+39
-20
@@ -9,6 +9,7 @@ import (
9
"strings"
10
"testing"
11
12
+ "github.com/caddyserver/caddy/v2/caddyconfig"
13
"github.com/caddyserver/caddy/v2/caddytest"
14
_ "github.com/caddyserver/caddy/v2/internal/testmocks"
15
)
@@ -27,30 +28,48 @@ func TestCaddyfileAdaptToJSON(t *testing.T) {
28
if f.IsDir() {
29
continue
30
}
30
-
31
- // read the test file
31
filename := f.Name()
33
- data, err := os.ReadFile("./caddyfile_adapt/" + filename)
34
- if err != nil {
35
- t.Errorf("failed to read %s dir: %s", filename, err)
36
- }
32
38
- // split the Caddyfile (first) and JSON (second) parts
39
- // (append newline to Caddyfile to match formatter expectations)
40
- parts := strings.Split(string(data), "----------")
41
- caddyfile, json := strings.TrimSpace(parts[0])+"\n", strings.TrimSpace(parts[1])
33
+ // run each file as a subtest, so that we can see which one fails more easily
34
+ t.Run(filename, func(t *testing.T) {
35
+ // read the test file
36
+ data, err := os.ReadFile("./caddyfile_adapt/" + filename)
37
+ if err != nil {
38
+ t.Errorf("failed to read %s dir: %s", filename, err)
39
+ }
40
43
- // replace windows newlines in the json with unix newlines
44
- json = winNewlines.ReplaceAllString(json, "\n")
41
+ // split the Caddyfile (first) and JSON (second) parts
42
+ // (append newline to Caddyfile to match formatter expectations)
43
+ parts := strings.Split(string(data), "----------")
44
+ caddyfile, expected := strings.TrimSpace(parts[0])+"\n", strings.TrimSpace(parts[1])
45
46
- // replace os-specific default path for file_server's hide field
47
- replacePath, _ := jsonMod.Marshal(fmt.Sprint(".", string(filepath.Separator), "Caddyfile"))
48
- json = strings.ReplaceAll(json, `"./Caddyfile"`, string(replacePath))
46
+ // replace windows newlines in the json with unix newlines
47
+ expected = winNewlines.ReplaceAllString(expected, "\n")
48
50
- // run the test
51
- ok := caddytest.CompareAdapt(t, filename, caddyfile, "caddyfile", json)
52
- if !ok {
53
- t.Errorf("failed to adapt %s", filename)
54
- }
49
+ // replace os-specific default path for file_server's hide field
50
+ replacePath, _ := jsonMod.Marshal(fmt.Sprint(".", string(filepath.Separator), "Caddyfile"))
51
+ expected = strings.ReplaceAll(expected, `"./Caddyfile"`, string(replacePath))
52
+
53
+ // if the expected output is JSON, compare it
54
+ if len(expected) > 0 && expected[0] == '{' {
55
+ ok := caddytest.CompareAdapt(t, filename, caddyfile, "caddyfile", expected)
56
+ if !ok {
57
+ t.Errorf("failed to adapt %s", filename)
58
+ }
59
+ return
60
+ }
61
+
62
+ // otherwise, adapt the Caddyfile and check for errors
63
+ cfgAdapter := caddyconfig.GetAdapter("caddyfile")
64
+ _, _, err = cfgAdapter.Adapt([]byte(caddyfile), nil)
65
+ if err == nil {
66
+ t.Errorf("expected error for %s but got none", filename)
67
+ } else {
68
+ normalizedErr := winNewlines.ReplaceAllString(err.Error(), "\n")
69
+ if !strings.Contains(normalizedErr, expected) {
70
+ t.Errorf("expected error for %s to contain:\n%s\nbut got:\n%s", filename, expected, normalizedErr)
71
+ }
72
+ }
73
+ })
74
}
75
}