Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

replacer: Implement file.* global replacements #5463

Merged
merged 7 commits into from Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions caddytest/integration/testdata/foo.txt
@@ -0,0 +1 @@
foo
6 changes: 6 additions & 0 deletions modules/caddyhttp/templates/tplcontext.go
Expand Up @@ -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
}
Expand Down
107 changes: 92 additions & 15 deletions replacer.go
Expand Up @@ -16,6 +16,7 @@ package caddy

import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
Expand All @@ -24,6 +25,8 @@ import (
"strings"
"sync"
"time"

"go.uber.org/zap"
)

// NewReplacer returns a new Replacer.
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mark this as Experimental. I'm still nervous 😅

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. But I think it's less-so this function needing to be experimental, cause this is actually the opt-out.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean. I mostly am just worried about compatibility promises in the very off-chance this is used externally 😆

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.
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -363,3 +438,5 @@ var nowFunc = time.Now
const ReplacerCtxKey CtxKey = "replacer"

const phOpen, phClose, phEscape = '{', '}', '\\'

const filePrefix = "file."
150 changes: 98 additions & 52 deletions replacer_test.go
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -267,7 +267,7 @@ func TestReplacerReplaceKnown(t *testing.T) {
default:
return "NOOO", false
}
},
}),
},
}

Expand Down Expand Up @@ -372,53 +372,99 @@ func TestReplacerMap(t *testing.T) {
}

func TestReplacerNew(t *testing.T) {
rep := 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", "")

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)
repl := NewReplacer()

if len(repl.providers) != 3 {
t.Errorf("Expected providers length '%v' got length '%v'", 3, len(repl.providers))
}

// 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)
}
}
}
Expand Down Expand Up @@ -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{},
}
Expand Down