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, 08:30 UTC
caddyfile: treat quoted braces as literal arguments (#7875)
a committed
Jul 11, 2026 at 19:33 UTC
873fac5fc094fe538d0c477509127bb321d51a32
7 files changed
+172
-33
caddyconfig/caddyfile/dispenser.go
+20
-16
@@ -80,8 +80,9 @@ func (d *Dispenser) Prev() bool {
80
}
81
82
// NextArg loads the next token if it is on the same
83
-// line and if it is not a block opening (open curly
84
-// brace). Returns true if an argument token was
83
+// line and if it is not a block opening (unquoted
84
+// open curly brace; a quoted brace is a regular
85
+// argument). Returns true if an argument token was
86
// loaded; false otherwise. If false, all tokens on
87
// the line have been consumed except for potentially
88
// a block opening. It handles imported tokens
@@ -90,7 +91,7 @@ func (d *Dispenser) NextArg() bool {
91
if !d.nextOnSameLine() {
92
return false
93
}
93
- if d.Val() == "{" {
94
+ if isOpenCurlyBrace(d.Token()) {
95
// roll back; a block opening is not an argument
96
d.cursor--
97
return false
@@ -169,9 +170,9 @@ func (d *Dispenser) NextBlock(initialNestingLevel int) bool {
170
if !d.Next() {
171
return false // should be EOF error
172
}
172
- if d.Val() == "}" && !d.nextOnSameLine() {
173
+ if isCloseCurlyBrace(d.Token()) && !d.nextOnSameLine() {
174
d.nesting--
174
- } else if d.Val() == "{" && !d.nextOnSameLine() {
175
+ } else if isOpenCurlyBrace(d.Token()) && !d.nextOnSameLine() {
176
d.nesting++
177
}
178
return d.nesting > initialNestingLevel
@@ -179,12 +180,12 @@ func (d *Dispenser) NextBlock(initialNestingLevel int) bool {
180
if !d.nextOnSameLine() { // block must open on same line
181
return false
182
}
182
- if d.Val() != "{" {
183
+ if !isOpenCurlyBrace(d.Token()) {
184
d.cursor-- // roll back if not opening brace
185
return false
186
}
187
d.Next() // consume open curly brace
187
- if d.Val() == "}" {
188
+ if isCloseCurlyBrace(d.Token()) {
189
return false // open and then closed right away
190
}
191
d.nesting++
@@ -308,9 +309,10 @@ func (d *Dispenser) CountRemainingArgs() int {
309
}
310
311
// RemainingArgs loads any more arguments (tokens on the same line)
311
-// into a slice of strings and returns them. Open curly brace tokens
312
-// also indicate the end of arguments, and the curly brace is not
313
-// included in the return value nor is it loaded.
312
+// into a slice of strings and returns them. An unquoted open curly
313
+// brace also indicates the end of arguments, and it is not included
314
+// in the return value nor is it loaded; quoted braces are returned
315
+// as regular arguments.
316
func (d *Dispenser) RemainingArgs() []string {
317
var args []string
318
for d.NextArg() {
@@ -321,8 +323,9 @@ func (d *Dispenser) RemainingArgs() []string {
323
324
// RemainingArgsRaw loads any more arguments (tokens on the same line,
325
// retaining quotes) into a slice of strings and returns them.
324
-// Open curly brace tokens also indicate the end of arguments,
325
-// and the curly brace is not included in the return value nor is it loaded.
326
+// An unquoted open curly brace also indicates the end of arguments,
327
+// and it is not included in the return value nor is it loaded;
328
+// quoted braces are returned as regular arguments.
329
func (d *Dispenser) RemainingArgsRaw() []string {
330
var args []string
331
for d.NextArg() {
@@ -332,9 +335,10 @@ func (d *Dispenser) RemainingArgsRaw() []string {
335
}
336
337
// RemainingArgsAsTokens loads any more arguments (tokens on the same line)
335
-// into a slice of Token-structs and returns them. Open curly brace tokens
336
-// also indicate the end of arguments, and the curly brace is not included
337
-// in the return value nor is it loaded.
338
+// into a slice of Token-structs and returns them. An unquoted open curly
339
+// brace also indicates the end of arguments, and it is not included in the
340
+// return value nor is it loaded; quoted braces are returned as regular
341
+// arguments.
342
func (d *Dispenser) RemainingArgsAsTokens() []Token {
343
var args []Token
344
for d.NextArg() {
@@ -406,7 +410,7 @@ func (d *Dispenser) Reset() {
410
// a line break or open curly brace was encountered instead of
411
// an argument.
412
func (d *Dispenser) ArgErr() error {
409
- if d.Val() == "{" {
413
+ if isOpenCurlyBrace(d.Token()) {
414
return d.Err("unexpected token '{', expecting argument")
415
}
416
return d.Errf("wrong argument count or unexpected line ending after '%s'", d.Val())
caddyconfig/caddyfile/dispenser_test.go
+30
@@ -168,6 +168,36 @@ func TestDispenser_NextBlock(t *testing.T) {
168
assertNextBlock(false, 8, 0) // empty block is as if it didn't exist
169
}
170
171
+func TestDispenser_QuotedBracesAreArguments(t *testing.T) {
172
+ // quoted braces are literal argument text, not structural tokens
173
+ d := NewTestDispenser(`dir1 "{" "}" foo
174
+ dir2 "}" {
175
+ sub1 "{"
176
+ }`)
177
+
178
+ d.Next() // dir1
179
+ if d.NextBlock(0) {
180
+ t.Errorf("NextBlock(): quoted '{' must not open a block (val: '%s')", d.Val())
181
+ }
182
+ if args := d.RemainingArgs(); !reflect.DeepEqual(args, []string{"{", "}", "foo"}) {
183
+ t.Errorf(`RemainingArgs(): quoted braces should be visible as arguments, got %v`, args)
184
+ }
185
+
186
+ d.Next() // dir2
187
+ if args := d.RemainingArgs(); !reflect.DeepEqual(args, []string{"}"}) {
188
+ t.Errorf(`RemainingArgs(): quoted '}' should be an argument, got %v`, args)
189
+ }
190
+ if !d.NextBlock(0) || d.Val() != "sub1" {
191
+ t.Fatalf("NextBlock(): unquoted '{' should still open a block (val: '%s')", d.Val())
192
+ }
193
+ if args := d.RemainingArgs(); !reflect.DeepEqual(args, []string{"{"}) {
194
+ t.Errorf(`RemainingArgs(): quoted '{' inside block should be an argument, got %v`, args)
195
+ }
196
+ if d.NextBlock(0) || d.Nesting() != 0 {
197
+ t.Errorf("NextBlock(): block should have closed (nesting %d)", d.Nesting())
198
+ }
199
+}
200
+
201
func TestDispenser_Args(t *testing.T) {
202
var s1, s2, s3 string
203
input := `dir1 arg1 arg2 arg3
caddyconfig/caddyfile/formatter_test.go
+5
@@ -444,6 +444,11 @@ block2 {
444
input: "block {respond \"All braces should remain: {{now | date `2006`}}\"}",
445
expect: "block {respond \"All braces should remain: {{now | date `2006`}}\"}",
446
},
447
+ {
448
+ description: "Preserve quoted brace arguments",
449
+ input: "block {\n\trespond \"{\"\n\trespond \"}\"\n}",
450
+ expect: "block {\n\trespond \"{\"\n\trespond \"}\"\n}",
451
+ },
452
{
453
description: "Preserve quoted backticks and backticked quotes",
454
input: "block { respond \"`\" } block { respond `\"`}",
caddyconfig/caddyfile/lexer.go
+10
@@ -347,6 +347,16 @@ func (t Token) Quoted() bool {
347
return t.wasQuoted > 0
348
}
349
350
+// isOpenCurlyBrace returns true if the token is a structural (unquoted) open curly brace.
351
+func isOpenCurlyBrace(t Token) bool {
352
+ return t.Text == "{" && t.wasQuoted == 0
353
+}
354
+
355
+// isCloseCurlyBrace returns true if the token is a structural (unquoted) close curly brace.
356
+func isCloseCurlyBrace(t Token) bool {
357
+ return t.Text == "}" && t.wasQuoted == 0
358
+}
359
+
360
// NumLineBreaks counts how many line breaks are in the token text.
361
func (t Token) NumLineBreaks() int {
362
lineBreaks := strings.Count(t.Text, "\n")
caddyconfig/caddyfile/parse.go
+17
-17
@@ -229,7 +229,7 @@ func (p *parser) addresses() error {
229
}
230
231
// Open brace definitely indicates end of addresses
232
- if value == "{" {
232
+ if isOpenCurlyBrace(token) {
233
if expectingAnother {
234
return p.Errf("Expected another address but had '%s' - check for extra comma", value)
235
}
@@ -243,7 +243,7 @@ func (p *parser) addresses() error {
243
}
244
245
// Users commonly forget to place a space between the address and the '{'
246
- if strings.HasSuffix(value, "{") {
246
+ if strings.HasSuffix(value, "{") && token.wasQuoted == 0 {
247
return p.Errf("Site addresses cannot end with a curly brace: '%s' - put a space between the token and the brace", value)
248
}
249
@@ -320,7 +320,7 @@ func (p *parser) blockContents() error {
320
func (p *parser) directives() error {
321
for p.Next() {
322
// end of server block
323
- if p.Val() == "}" {
323
+ if isCloseCurlyBrace(p.Token()) {
324
// p.nesting has already been decremented
325
break
326
}
@@ -384,7 +384,7 @@ func (p *parser) doImport(nesting int) error {
384
for bd.Next() {
385
currentMappingKey := bd.Val()
386
387
- if currentMappingKey == "{" {
387
+ if isOpenCurlyBrace(bd.Token()) {
388
return p.Err("anonymous blocks are not supported")
389
}
390
@@ -518,14 +518,14 @@ func (p *parser) doImport(nesting int) error {
518
}
519
}
520
521
- switch token.Text {
522
- case "{":
521
+ switch {
522
+ case isOpenCurlyBrace(token):
523
nesting++
524
if index == 1 && maybeSnippetId && nesting == 1 {
525
maybeSnippet = true
526
maybeSnippetId = false
527
}
528
- case "}":
528
+ case isCloseCurlyBrace(token):
529
nesting--
530
if nesting == 0 && maybeSnippet {
531
maybeSnippet = false
@@ -641,24 +641,24 @@ func (p *parser) directive() error {
641
segment = append(segment, p.Token())
642
643
for p.Next() {
644
- if p.Val() == "{" {
644
+ if isOpenCurlyBrace(p.Token()) {
645
p.nesting++
646
- if !p.isNextOnNewLine() && p.Token().wasQuoted == 0 {
646
+ if !p.isNextOnNewLine() {
647
return p.Err("Unexpected next token after '{' on same line")
648
}
649
if p.isNewLine() {
650
return p.Err("Unexpected '{' on a new line; did you mean to place the '{' on the previous line?")
651
}
652
- } else if p.Val() == "{}" {
653
- if p.isNextOnNewLine() && p.Token().wasQuoted == 0 {
652
+ } else if p.Val() == "{}" && p.Token().wasQuoted == 0 {
653
+ if p.isNextOnNewLine() {
654
return p.Err("Unexpected '{}' at end of line")
655
}
656
} else if p.isNewLine() && p.nesting == 0 {
657
p.cursor-- // read too far
658
break
659
- } else if p.Val() == "}" && p.nesting > 0 {
659
+ } else if isCloseCurlyBrace(p.Token()) && p.nesting > 0 {
660
p.nesting--
661
- } else if p.Val() == "}" && p.nesting == 0 {
661
+ } else if isCloseCurlyBrace(p.Token()) && p.nesting == 0 {
662
return p.Err("Unexpected '}' because no matching opening brace")
663
} else if p.Val() == "import" && p.isNewLine() {
664
if err := p.doImport(1); err != nil {
@@ -685,7 +685,7 @@ func (p *parser) directive() error {
685
// because it returns an error if the token is not
686
// an opening curly brace. It does NOT advance the token.
687
func (p *parser) openCurlyBrace() error {
688
- if p.Val() != "{" {
688
+ if !isOpenCurlyBrace(p.Token()) {
689
if p.valLooksLikeGlobalOptionsAfterImportedSnippets() {
690
return p.Err("global options block must appear before import directives; move the global options block to the top of the Caddyfile")
691
}
@@ -713,7 +713,7 @@ func (p *parser) valLooksLikeGlobalOptionsAfterImportedSnippets() bool {
713
// because it returns an error if the token is not
714
// a closing curly brace. It does NOT advance the token.
715
func (p *parser) closeCurlyBrace() error {
716
- if p.Val() != "}" {
716
+ if !isCloseCurlyBrace(p.Token()) {
717
return p.SyntaxErr("}")
718
}
719
return nil
@@ -750,7 +750,7 @@ func (p *parser) blockTokens(retainCurlies bool) ([]Token, error) {
750
tokens = append(tokens, p.Token())
751
}
752
for p.Next() {
753
- if p.Val() == "}" {
753
+ if isCloseCurlyBrace(p.Token()) {
754
nesting--
755
if nesting == 0 {
756
if retainCurlies {
@@ -759,7 +759,7 @@ func (p *parser) blockTokens(retainCurlies bool) ([]Token, error) {
759
break
760
}
761
}
762
- if p.Val() == "{" {
762
+ if isOpenCurlyBrace(p.Token()) {
763
nesting++
764
}
765
tokens = append(tokens, p.tokens[p.cursor])
caddyconfig/caddyfile/parse_test.go
+41
@@ -317,6 +317,18 @@ func TestParseOneAndImport(t *testing.T) {
317
{`localhost
318
dir1 "{}"`, false, []string{"localhost"}, []int{2}},
319
320
+ // quoted braces are literal arguments: they must not open/close blocks or swallow directives
321
+ {"localhost {\n dir1 \"{\" `}`\n dir2 \"}\"\n dir3 \"{\"\n}",
322
+ false, []string{"localhost"}, []int{3, 2, 2}},
323
+
324
+ // quoted "{" as the last argument before a real block
325
+ {`localhost {
326
+ dir1 "{" {
327
+ a b
328
+ }
329
+ dir2 foo
330
+ }`, false, []string{"localhost"}, []int{6, 2}},
331
+
332
// import with args
333
{`import testdata/import_args0.txt a`, false, []string{"a"}, []int{}},
334
{`import testdata/import_args1.txt a b`, false, []string{"a", "b"}, []int{}},
@@ -790,6 +802,35 @@ func TestSnippets(t *testing.T) {
802
}
803
}
804
805
+func TestSnippetWithQuotedBraces(t *testing.T) {
806
+ // quoted braces inside a snippet are literal arguments and must not corrupt block nesting
807
+ p := testParser(`
808
+ (quoted) {
809
+ dir1 "}"
810
+ dir2 "{"
811
+ }
812
+ example.com {
813
+ import quoted
814
+ dir3 foo
815
+ }
816
+ `)
817
+ blocks, err := p.parseAll()
818
+ if err != nil {
819
+ t.Fatal(err)
820
+ }
821
+ if len(blocks) != 1 {
822
+ t.Fatalf("Expect exactly one server block. Got %d.", len(blocks))
823
+ }
824
+ if actual := len(blocks[0].Segments); actual != 3 {
825
+ t.Fatalf("Expected 3 segments, got %d: %+v", actual, blocks[0].Segments)
826
+ }
827
+ for i, expected := range []string{"}", "{", "foo"} {
828
+ if seg := blocks[0].Segments[i]; len(seg) != 2 || seg[1].Text != expected {
829
+ t.Errorf("Segment %d: expected 2 tokens with arg '%s', got %+v", i, expected, seg)
830
+ }
831
+ }
832
+}
833
+
834
func writeStringToTempFileOrDie(t *testing.T, str string) (pathToFile string) {
835
file, err := os.CreateTemp("", t.Name())
836
if err != nil {
caddytest/integration/caddyfile_adapt/quoted_brace_args.caddyfiletest
new
+49
@@ -0,0 +1,49 @@
1
+:8080 {
2
+ header X-Curly-Open "{"
3
+ header X-Curly-Close "}"
4
+ respond "{"
5
+}
6
+----------
7
+{
8
+ "apps": {
9
+ "http": {
10
+ "servers": {
11
+ "srv0": {
12
+ "listen": [
13
+ ":8080"
14
+ ],
15
+ "routes": [
16
+ {
17
+ "handle": [
18
+ {
19
+ "handler": "headers",
20
+ "response": {
21
+ "set": {
22
+ "X-Curly-Open": [
23
+ "{"
24
+ ]
25
+ }
26
+ }
27
+ },
28
+ {
29
+ "handler": "headers",
30
+ "response": {
31
+ "set": {
32
+ "X-Curly-Close": [
33
+ "}"
34
+ ]
35
+ }
36
+ }
37
+ },
38
+ {
39
+ "body": "{",
40
+ "handler": "static_response"
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ }
46
+ }
47
+ }
48
+ }
49
+}