From 797973944f9bf60c84350a38848613b6247a66eb Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Wed, 24 Apr 2024 16:26:18 -0400 Subject: [PATCH] replacer: Implement `file.*` global replacements (#5463) Co-authored-by: Matt Holt Co-authored-by: Mohammed Al Sahaf --- caddytest/integration/testdata/foo.txt | 1 + modules/caddyhttp/templates/tplcontext.go | 6 + replacer.go | 107 +++++++++++++--- replacer_test.go | 146 ++++++++++++++-------- 4 files changed, 195 insertions(+), 65 deletions(-) create mode 100644 caddytest/integration/testdata/foo.txt diff --git a/caddytest/integration/testdata/foo.txt b/caddytest/integration/testdata/foo.txt new file mode 100644 index 00000000..19102815 --- /dev/null +++ b/caddytest/integration/testdata/foo.txt @@ -0,0 +1 @@ +foo \ No newline at end of file diff --git a/modules/caddyhttp/templates/tplcontext.go b/modules/caddyhttp/templates/tplcontext.go index 8ba64200..4c7c86e1 100644 --- a/modules/caddyhttp/templates/tplcontext.go +++ b/modules/caddyhttp/templates/tplcontext.go @@ -249,6 +249,12 @@ func (c *TemplateContext) executeTemplateInBuffer(tplName string, buf *bytes.Buf func (c TemplateContext) funcPlaceholder(name string) string { repl := c.Req.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + + // For safety, we don't want to allow the file placeholder in + // templates because it could be used to read arbitrary files + // if the template contents were not trusted. + repl = repl.WithoutFile() + value, _ := repl.GetString(name) return value } diff --git a/replacer.go b/replacer.go index 2ad5b8bc..e5d2913e 100644 --- a/replacer.go +++ b/replacer.go @@ -16,6 +16,7 @@ package caddy import ( "fmt" + "io" "net/http" "os" "path/filepath" @@ -24,6 +25,8 @@ import ( "strings" "sync" "time" + + "go.uber.org/zap" ) // NewReplacer returns a new Replacer. @@ -32,9 +35,10 @@ func NewReplacer() *Replacer { static: make(map[string]any), mapMutex: &sync.RWMutex{}, } - rep.providers = []ReplacerFunc{ - globalDefaultReplacements, - rep.fromStatic, + rep.providers = []replacementProvider{ + globalDefaultReplacementProvider{}, + fileReplacementProvider{}, + ReplacerFunc(rep.fromStatic), } return rep } @@ -46,8 +50,8 @@ func NewEmptyReplacer() *Replacer { static: make(map[string]any), mapMutex: &sync.RWMutex{}, } - rep.providers = []ReplacerFunc{ - rep.fromStatic, + rep.providers = []replacementProvider{ + ReplacerFunc(rep.fromStatic), } return rep } @@ -56,10 +60,25 @@ func NewEmptyReplacer() *Replacer { // A default/empty Replacer is not valid; // use NewReplacer to make one. type Replacer struct { - providers []ReplacerFunc + providers []replacementProvider + static map[string]any + mapMutex *sync.RWMutex +} - static map[string]any - mapMutex *sync.RWMutex +// WithoutFile returns a copy of the current Replacer +// without support for the {file.*} placeholder, which +// may be unsafe in some contexts. +// +// EXPERIMENTAL: Subject to change or removal. +func (r *Replacer) WithoutFile() *Replacer { + rep := &Replacer{static: r.static} + for _, v := range r.providers { + if _, ok := v.(fileReplacementProvider); ok { + continue + } + rep.providers = append(rep.providers, v) + } + return rep } // Map adds mapFunc to the list of value providers. @@ -79,7 +98,7 @@ func (r *Replacer) Set(variable string, value any) { // the value and whether the variable was known. func (r *Replacer) Get(variable string) (any, bool) { for _, mapFunc := range r.providers { - if val, ok := mapFunc(variable); ok { + if val, ok := mapFunc.replace(variable); ok { return val, true } } @@ -298,14 +317,52 @@ func ToString(val any) string { } } -// ReplacerFunc is a function that returns a replacement -// for the given key along with true if the function is able -// to service that key (even if the value is blank). If the -// function does not recognize the key, false should be -// returned. +// ReplacerFunc is a function that returns a replacement for the +// given key along with true if the function is able to service +// that key (even if the value is blank). If the function does +// not recognize the key, false should be returned. type ReplacerFunc func(key string) (any, bool) -func globalDefaultReplacements(key string) (any, bool) { +func (f ReplacerFunc) replace(key string) (any, bool) { + return f(key) +} + +// replacementProvider is a type that can provide replacements +// for placeholders. Allows for type assertion to determine +// which type of provider it is. +type replacementProvider interface { + replace(key string) (any, bool) +} + +// fileReplacementsProvider handles {file.*} replacements, +// reading a file from disk and replacing with its contents. +type fileReplacementProvider struct{} + +func (f fileReplacementProvider) replace(key string) (any, bool) { + if !strings.HasPrefix(key, filePrefix) { + return nil, false + } + + filename := key[len(filePrefix):] + maxSize := 1024 * 1024 + body, err := readFileIntoBuffer(filename, maxSize) + if err != nil { + wd, _ := os.Getwd() + Log().Error("placeholder: failed to read file", + zap.String("file", filename), + zap.String("working_dir", wd), + zap.Error(err)) + return nil, true + } + return string(body), true +} + +// globalDefaultReplacementsProvider handles replacements +// that can be used in any context, such as system variables, +// time, or environment variables. +type globalDefaultReplacementProvider struct{} + +func (f globalDefaultReplacementProvider) replace(key string) (any, bool) { // check environment variable const envPrefix = "env." if strings.HasPrefix(key, envPrefix) { @@ -347,6 +404,24 @@ func globalDefaultReplacements(key string) (any, bool) { return nil, false } +// readFileIntoBuffer reads the file at filePath into a size limited buffer. +func readFileIntoBuffer(filename string, size int) ([]byte, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + buffer := make([]byte, size) + n, err := file.Read(buffer) + if err != nil && err != io.EOF { + return nil, err + } + + // slice the buffer to the actual size + return buffer[:n], nil +} + // ReplacementFunc is a function that is called when a // replacement is being performed. It receives the // variable (i.e. placeholder name) and the value that @@ -363,3 +438,5 @@ var nowFunc = time.Now const ReplacerCtxKey CtxKey = "replacer" const phOpen, phClose, phEscape = '{', '}', '\\' + +const filePrefix = "file." diff --git a/replacer_test.go b/replacer_test.go index d18ec8ee..cf4d321b 100644 --- a/replacer_test.go +++ b/replacer_test.go @@ -240,9 +240,9 @@ func TestReplacerSet(t *testing.T) { func TestReplacerReplaceKnown(t *testing.T) { rep := Replacer{ mapMutex: &sync.RWMutex{}, - providers: []ReplacerFunc{ + providers: []replacementProvider{ // split our possible vars to two functions (to test if both functions are called) - func(key string) (val any, ok bool) { + ReplacerFunc(func(key string) (val any, ok bool) { switch key { case "test1": return "val1", true @@ -255,8 +255,8 @@ func TestReplacerReplaceKnown(t *testing.T) { default: return "NOOO", false } - }, - func(key string) (val any, ok bool) { + }), + ReplacerFunc(func(key string) (val any, ok bool) { switch key { case "1": return "test-123", true @@ -267,7 +267,7 @@ func TestReplacerReplaceKnown(t *testing.T) { default: return "NOOO", false } - }, + }), }, } @@ -372,53 +372,99 @@ func TestReplacerMap(t *testing.T) { } func TestReplacerNew(t *testing.T) { - rep := NewReplacer() + repl := NewReplacer() - if len(rep.providers) != 2 { - t.Errorf("Expected providers length '%v' got length '%v'", 2, len(rep.providers)) - } else { - // test if default global replacements are added as the first provider - hostname, _ := os.Hostname() - wd, _ := os.Getwd() - os.Setenv("CADDY_REPLACER_TEST", "envtest") - defer os.Setenv("CADDY_REPLACER_TEST", "") + if len(repl.providers) != 3 { + t.Errorf("Expected providers length '%v' got length '%v'", 3, len(repl.providers)) + } - for _, tc := range []struct { - variable string - value string - }{ - { - variable: "system.hostname", - value: hostname, - }, - { - variable: "system.slash", - value: string(filepath.Separator), - }, - { - variable: "system.os", - value: runtime.GOOS, - }, - { - variable: "system.arch", - value: runtime.GOARCH, - }, - { - variable: "system.wd", - value: wd, - }, - { - variable: "env.CADDY_REPLACER_TEST", - value: "envtest", - }, - } { - if val, ok := rep.providers[0](tc.variable); ok { - if val != tc.value { - t.Errorf("Expected value '%s' for key '%s' got '%s'", tc.value, tc.variable, val) - } - } else { - t.Errorf("Expected key '%s' to be recognized by first provider", tc.variable) + // test if default global replacements are added as the first provider + hostname, _ := os.Hostname() + wd, _ := os.Getwd() + os.Setenv("CADDY_REPLACER_TEST", "envtest") + defer os.Setenv("CADDY_REPLACER_TEST", "") + + for _, tc := range []struct { + variable string + value string + }{ + { + variable: "system.hostname", + value: hostname, + }, + { + variable: "system.slash", + value: string(filepath.Separator), + }, + { + variable: "system.os", + value: runtime.GOOS, + }, + { + variable: "system.arch", + value: runtime.GOARCH, + }, + { + variable: "system.wd", + value: wd, + }, + { + variable: "env.CADDY_REPLACER_TEST", + value: "envtest", + }, + } { + if val, ok := repl.providers[0].replace(tc.variable); ok { + if val != tc.value { + t.Errorf("Expected value '%s' for key '%s' got '%s'", tc.value, tc.variable, val) } + } else { + t.Errorf("Expected key '%s' to be recognized by first provider", tc.variable) + } + } + + // test if file provider is added as the second provider + for _, tc := range []struct { + variable string + value string + }{ + { + variable: "file.caddytest/integration/testdata/foo.txt", + value: "foo", + }, + } { + if val, ok := repl.providers[1].replace(tc.variable); ok { + if val != tc.value { + t.Errorf("Expected value '%s' for key '%s' got '%s'", tc.value, tc.variable, val) + } + } else { + t.Errorf("Expected key '%s' to be recognized by second provider", tc.variable) + } + } +} + +func TestReplacerNewWithoutFile(t *testing.T) { + repl := NewReplacer().WithoutFile() + + for _, tc := range []struct { + variable string + value string + notFound bool + }{ + { + variable: "file.caddytest/integration/testdata/foo.txt", + notFound: true, + }, + { + variable: "system.os", + value: runtime.GOOS, + }, + } { + if val, ok := repl.Get(tc.variable); ok && !tc.notFound { + if val != tc.value { + t.Errorf("Expected value '%s' for key '%s' got '%s'", tc.value, tc.variable, val) + } + } else if !tc.notFound { + t.Errorf("Expected key '%s' to be recognized", tc.variable) } } } @@ -464,7 +510,7 @@ func BenchmarkReplacer(b *testing.B) { func testReplacer() Replacer { return Replacer{ - providers: make([]ReplacerFunc, 0), + providers: make([]replacementProvider, 0), static: make(map[string]any), mapMutex: &sync.RWMutex{}, }