@marcel91200 / caddy Mirror · read-only / commits / 77dd12cc
Fork 0

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 }