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