@marcel91200 / caddy Mirror · read-only / commits / 873fac5f
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, 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 +}