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, 03:30 UTC
caddyfile: avoid quadratic heredoc marker scanning (#8203)
Francis Lavoie committed
Oct 12, 2026 at 06:52 UTC
7328273d6f48d11b3e5efc78c1e20453b4ee727a
2 files changed
+78
-2
caddyconfig/caddyfile/lexer.go
+26
-2
@@ -175,6 +175,8 @@ func (l *lexer) next() (bool, error) {
175
var val []rune
176
var comment, quoted, btQuoted, inHeredoc, heredocEscaped, escaped, continueLine bool
177
var heredocMarker string
178
+ var heredocPrefix []int
179
+ var heredocMatched int
180
var tokenStartSet bool // whether l.tokenStart has been set for the current token
181
182
// format-mode separator tracking: record how this token was separated from
@@ -257,6 +259,20 @@ func (l *lexer) next() (bool, error) {
259
return false, fmt.Errorf("heredoc marker on line #%d must contain only alphanumeric characters, dashes and underscores; got '%s'", l.line, heredocMarker)
260
}
261
262
+ // Build a prefix table for incremental marker matching (KMP).
263
+ // Comparing the whole suffix after every rune costs O(body * marker)
264
+ // and repeatedly converts large rune slices to strings.
265
+ heredocPrefix = make([]int, len(heredocMarker))
266
+ for i, matched := 1, 0; i < len(heredocMarker); i++ {
267
+ for matched > 0 && heredocMarker[i] != heredocMarker[matched] {
268
+ matched = heredocPrefix[matched-1]
269
+ }
270
+ if heredocMarker[i] == heredocMarker[matched] {
271
+ matched++
272
+ }
273
+ heredocPrefix[i] = matched
274
+ }
275
+
276
inHeredoc = true
277
l.skippedLines++
278
val = nil
@@ -274,8 +290,16 @@ func (l *lexer) next() (bool, error) {
290
l.skippedLines++
291
}
292
277
- // check if we're done, i.e. that the last few characters are the marker
278
- if len(val) >= len(heredocMarker) && heredocMarker == string(val[len(val)-len(heredocMarker):]) {
293
+ // Markers contain only ASCII, so compare each rune directly with
294
+ // the next marker byte. Prefix fallback also handles overlapping
295
+ // partial matches without rescanning the body.
296
+ for heredocMatched > 0 && ch != rune(heredocMarker[heredocMatched]) {
297
+ heredocMatched = heredocPrefix[heredocMatched-1]
298
+ }
299
+ if ch == rune(heredocMarker[heredocMatched]) {
300
+ heredocMatched++
301
+ }
302
+ if heredocMatched == len(heredocMarker) {
303
// set the final value
304
val, err = l.finalizeHeredoc(val, heredocMarker)
305
if err != nil {
caddyconfig/caddyfile/lexer_test.go
+52
@@ -16,6 +16,7 @@ package caddyfile
16
17
import (
18
"reflect"
19
+ "strings"
20
"testing"
21
)
22
@@ -762,3 +763,54 @@ func TestLexEqualsTokenizeWithZeroOptions(t *testing.T) {
763
t.Errorf("Lex(zero opts) != Tokenize\n a=%+v\n b=%+v", a, b)
764
}
765
}
766
+
767
+// TestLexerLongHeredoc exercises long and overlapping marker prefixes. Checking
768
+// the entire marker after every rune made these inputs take seconds to tokenize.
769
+func TestLexerLongHeredoc(t *testing.T) {
770
+ for _, tc := range []struct {
771
+ name, marker, body string
772
+ incomplete bool
773
+ }{
774
+ {name: "long marker", marker: strings.Repeat("a", 8000) + "b", body: strings.Repeat("a", 500000)},
775
+ {name: "overlapping prefixes", marker: "ababaca", body: "ababababac"},
776
+ {name: "unicode body", marker: "END", body: "你好\xffEN"},
777
+ {name: "incomplete", marker: strings.Repeat("a", 8000) + "b", body: strings.Repeat("a", 500000), incomplete: true},
778
+ } {
779
+ t.Run(tc.name, func(t *testing.T) {
780
+ input := "<<" + tc.marker + "\n" + tc.body
781
+ if !tc.incomplete {
782
+ input += "\n" + tc.marker
783
+ }
784
+ for _, opts := range []LexOptions{{}, {Comments: true, Raw: true}} {
785
+ tokens, err := Lex([]byte(input), "Caddyfile", opts)
786
+ if tc.incomplete {
787
+ if err == nil {
788
+ t.Fatal("expected incomplete heredoc error")
789
+ }
790
+ continue
791
+ }
792
+ if err != nil {
793
+ t.Fatal(err)
794
+ }
795
+ if len(tokens) != 1 || tokens[0].Text != string([]rune(tc.body)) {
796
+ t.Fatalf("unexpected heredoc contents: %v", tokens)
797
+ }
798
+ if opts.Raw && tokens[0].Raw() != input {
799
+ t.Fatal("heredoc raw source changed")
800
+ }
801
+ }
802
+ })
803
+ }
804
+}
805
+
806
+func BenchmarkLexerLongHeredoc(b *testing.B) {
807
+ marker := strings.Repeat("a", 8000) + "b"
808
+ input := []byte("<<" + marker + "\n" + strings.Repeat("a", 500000) + "\n" + marker)
809
+ b.SetBytes(int64(len(input)))
810
+ b.ReportAllocs()
811
+ for b.Loop() {
812
+ if _, err := Tokenize(input, "Caddyfile"); err != nil {
813
+ b.Fatal(err)
814
+ }
815
+ }
816
+}