Repository: google/go-safeweb (go)

Issue:
htmlinject: Provide a helper to parse templates
In order to use htmlinject the user needs to:

* Create a new empty template
* Call `Funcs` on it to make it so the functions `Parse` expects are defined (e.g. passing no-op functions that have the right signature and name for csrftoken and xsrftoken generation)
* Load the template source
* Use htmlinject to rewrite the template
* Pass the result to `Parse` on the aforementioned template

The code would more or less look like this:
```go
func parsetpl(path string) (*template.Template, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	noop := func() string { panic("this function should never be called") }
	t := template.New("").Funcs(map[string]interface{}{"CSPNonce": noop, "XSRFToken": noop})
	transf, err := htmlinject.Transform(f, htmlinject.CSPNoncesDefault, htmlinject.XSRFTokensDefault)
	if err != nil {
		return nil, err
	}
	return t.Parse(transf)
}
```

It would be nice if htmlinject provided helpers to perform all of those operations with a single call, and maybe even an helper to do so while loading templates from disk (like the standard html/template package does).

Pull request:
htmlinject: provide helpers to load safehtml.Templates
It was previously impossible to use htmlinject together with safehtml without an uncheckedconversion.

Depends on PR #197 
Fixes #173

Patch:
diff --git a/safehttp/plugins/htmlinject/htmlinject.go b/safehttp/plugins/htmlinject/htmlinject.go
index c7dbc89..840e037 100644
--- a/safehttp/plugins/htmlinject/htmlinject.go
+++ b/safehttp/plugins/htmlinject/htmlinject.go
@@ -19,8 +19,12 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"io/ioutil"
+	"path/filepath"
 	"strings"
 
+	"github.com/google/safehtml/template"
+	"github.com/google/safehtml/template/uncheckedconversions"
 	"golang.org/x/net/html"
 )
 
@@ -45,8 +49,8 @@ type Rule struct {
 
 func (r Rule) String() string { return r.Name }
 
-// Config is a slice of Rules that are somehow related to each other.
-type Config []Rule
+// TransformConfig is a slice of Rules that are somehow related to each other.
+type TransformConfig []Rule
 
 // CSPNoncesDefaultFuncName is the default func name for the func that generates CSP nonces.
 const CSPNoncesDefaultFuncName = "CSPNonce"
@@ -57,9 +61,9 @@ var CSPNoncesDefault = CSPNonces(`nonce="{{` + CSPNoncesDefaultFuncName + `}}"`)
 
 // CSPNonces constructs a Config to add CSP nonces to a template. The given nonce
 // attribute will be automatically prefixed with the required empty space.
-func CSPNonces(nonceAttr string) Config {
+func CSPNonces(nonceAttr string) TransformConfig {
 	nonceAttr = " " + nonceAttr
-	return Config{
+	return TransformConfig{
 		Rule{
 			Name:          "Nonces for scripts",
 			OnTag:         "script",
@@ -88,8 +92,8 @@ const XSRFTokensDefaultFuncName = `XSRFToken`
 var XSRFTokensDefault = XSRFTokens(`<input type="hidden" name="xsrf-token" value="{{` + XSRFTokensDefaultFuncName + `}}">`)
 
 // XSRFTokens constructs a Config to add the given string as a child node to forms.
-func XSRFTokens(inputTag string) Config {
-	return Config{Rule{
+func XSRFTokens(inputTag string) TransformConfig {
+	return TransformConfig{Rule{
 		Name:     "XSRFTokens on forms",
 		OnTag:    "form",
 		AddNodes: []string{inputTag}}}
@@ -97,7 +101,7 @@ func XSRFTokens(inputTag string) Config {
 
 // Transform rewrites the given template according to the given configs.
 // If the passed io.Rewriter has a `Size() int64` method it will be used to pre-allocate buffers.
-func Transform(src io.Reader, cfg ...Config) (string, error) {
+func Transform(src io.Reader, cfg ...TransformConfig) (string, error) {
 	rw := rewriter{
 		rules:     map[string][]Rule{},
 		tokenizer: html.NewTokenizer(src),
@@ -117,6 +121,92 @@ func Transform(src io.Reader, cfg ...Config) (string, error) {
 	return rw.out.String(), nil
 }
 
+// LoadConfig is a configuration to use with loaders when processing a template.
+type LoadConfig struct {
+	// DisableCsp disables CSP autononcing
+	DisableCsp bool
+	// DisableXsrf disables XSRF token injection
+	DisableXsrf bool
+}
+
+// LoadTrustedTemplate processes the given TrustedTemplate with the specified default configurations and
+// adds it to the given template.
+// If the given template is nil a new one is created.
+func LoadTrustedTemplate(tpl *template.Template, lcfg LoadConfig, src template.TrustedTemplate) (*template.Template, error) {
+	var cfg []TransformConfig
+	noop := func() string {
+		panic("this function should never be called, templates should be cloned and injected with the noncing functions, not executed directly")
+	}
+	funcMap := map[string]interface{}{}
+	if !lcfg.DisableCsp {
+		cfg = append(cfg, CSPNoncesDefault)
+		funcMap[CSPNoncesDefaultFuncName] = noop
+	}
+	if !lcfg.DisableXsrf {
+		cfg = append(cfg, XSRFTokensDefault)
+		funcMap[XSRFTokensDefaultFuncName] = noop
+	}
+	got, err := Transform(strings.NewReader(src.String()), cfg...)
+	if err != nil {
+		return nil, err
+	}
+	// We took a TrustedTemplate and transformed it with rules that we trust, so we know the output is still trusted.
+	tt := uncheckedconversions.TrustedTemplateFromStringKnownToSatisfyTypeContract(got)
+	if tpl == nil {
+		tpl = template.New("htmlinjected")
+	}
+	return tpl.Funcs(funcMap).ParseFromTrustedTemplate(tt)
+}
+
+// LoadFiles matches the behavior of safehtml.ParseFiles but runs a transformation on every loaded template.
+func LoadFiles(tpl *template.Template, lcfg LoadConfig, filenames ...template.TrustedSource) (*template.Template, error) {
+	// The naming juggling below is quite odd but is kept for consistency.
+	if len(filenames) == 0 {
+		return nil, fmt.Errorf("no files named in call to LoadFiles")
+	}
+	for _, fnts := range filenames {
+		fn := fnts.String()
+		b, err := ioutil.ReadFile(fn)
+		if err != nil {
+			return nil, err
+		}
+		name := filepath.Base(fn)
+		var t *template.Template
+		if tpl == nil {
+			tpl = template.New(name)
+		}
+		if name == tpl.Name() {
+			t = tpl
+		} else {
+			t = tpl.New(name)
+		}
+		// We are loading a file from a TrustedSource, so this conversion is safe.
+		tts := uncheckedconversions.TrustedTemplateFromStringKnownToSatisfyTypeContract(string(b))
+		_, err = LoadTrustedTemplate(t, lcfg, tts)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return tpl, nil
+}
+
+// LoadGlob matches the behavior of safehtml.ParseGlob but runs a transformation on every loaded template.
+func LoadGlob(tpl *template.Template, lcfg LoadConfig, pattern template.TrustedSource) (*template.Template, error) {
+	filenames, err := filepath.Glob(pattern.String())
+	if err != nil {
+		return nil, err
+	}
+	if len(filenames) == 0 {
+		return nil, fmt.Errorf("pattern matches no files: %#q", pattern.String())
+	}
+	var tts []template.TrustedSource
+	for _, fn := range filenames {
+		// The pattern expanded from a trusted source, so the expansion is still trusted.
+		tts = append(tts, uncheckedconversions.TrustedSourceFromStringKnownToSatisfyTypeContract(fn))
+	}
+	return LoadFiles(tpl, lcfg, tts...)
+}
+
 type rewriter struct {
 	// tag -> rules for that tag
 	rules     map[string][]Rule


Test patch:
diff --git a/safehttp/plugins/htmlinject/htmlinject_test.go b/safehttp/plugins/htmlinject/htmlinject_test.go
index 80e2d88..ffc0343 100644
--- a/safehttp/plugins/htmlinject/htmlinject_test.go
+++ b/safehttp/plugins/htmlinject/htmlinject_test.go
@@ -16,11 +16,15 @@ package htmlinject
 
 import (
 	"html/template"
+	"io/ioutil"
 	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
+	safetemplate "github.com/google/safehtml/template"
+	"github.com/google/safehtml/template/uncheckedconversions"
 )
 
 func ExampleTransform() {
@@ -71,7 +75,7 @@ Last name:<br>
 func BenchmarkTransform(b *testing.B) {
 	b.ReportAllocs()
 	var (
-		config = []Config{CSPNoncesDefault, XSRFTokensDefault}
+		config = []TransformConfig{CSPNoncesDefault, XSRFTokensDefault}
 		in     = `
 <html>
 <head>
@@ -116,15 +120,15 @@ func BenchmarkTransform(b *testing.B) {
 		}
 	}
 }
-func TestTransform(t *testing.T) {
-	var tests = []struct {
-		name     string
-		config   []Config
-		in, want string
-	}{
-		{
-			name: "nothing to change",
-			in: `
+
+var tests = []struct {
+	name      string
+	xsrf, csp bool
+	in, want  string
+}{
+	{
+		name: "nothing to change",
+		in: `
 <html>
 <header><title>This is title</title></header>
 <body>
@@ -132,7 +136,7 @@ Hello world
 </body>
 </html>
 `,
-			want: `
+		want: `
 <html>
 <header><title>This is title</title></header>
 <body>
@@ -140,11 +144,11 @@ Hello world
 </body>
 </html>
 `,
-		},
-		{
-			name:   "add CSP nonces",
-			config: []Config{CSPNoncesDefault},
-			in: `
+	},
+	{
+		name: "add CSP nonces",
+		csp:  true,
+		in: `
 <html>
 <head>
 <link rel=preload as="script" src="gopher.js">
@@ -159,7 +163,7 @@ h1 {
 </body>
 </html>
 `,
-			want: `
+		want: `
 <html>
 <head>
 <link nonce="{{CSPNonce}}" rel=preload as="script" src="gopher.js">
@@ -174,11 +178,11 @@ h1 {
 </body>
 </html>
 `,
-		},
-		{
-			name:   "add XSRF protection",
-			config: []Config{XSRFTokensDefault},
-			in: `
+	},
+	{
+		name: "add XSRF protection",
+		xsrf: true,
+		in: `
 <form>
   First name:<br>
   <input type="text" name="firstname"><br>
@@ -186,7 +190,7 @@ h1 {
   <input type="text" name="lastname">
 </form>
 `,
-			want: `
+		want: `
 <form><input type="hidden" name="xsrf-token" value="{{XSRFToken}}">
   First name:<br>
   <input type="text" name="firstname"><br>
@@ -194,11 +198,12 @@ h1 {
   <input type="text" name="lastname">
 </form>
 `,
-		},
-		{
-			name:   "all configs",
-			config: []Config{CSPNoncesDefault, XSRFTokensDefault},
-			in: `
+	},
+	{
+		name: "all configs",
+		xsrf: true,
+		csp:  true,
+		in: `
 <html>
 <head>
 <link rel="stylesheet" href="styles.css">
@@ -215,7 +220,7 @@ h1 {
 </body>
 </html>
 `,
-			want: `
+		want: `
 <html>
 <head>
 <link rel="stylesheet" href="styles.css">
@@ -232,11 +237,21 @@ h1 {
 </body>
 </html>
 `,
-		},
-	}
+	},
+}
+
+func TestTransform(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got, err := Transform(strings.NewReader(tt.in), tt.config...)
+			cfg := []TransformConfig{}
+			if tt.csp {
+				cfg = append(cfg, CSPNoncesDefault)
+			}
+			if tt.xsrf {
+				cfg = append(cfg, XSRFTokensDefault)
+
+			}
+			got, err := Transform(strings.NewReader(tt.in), cfg...)
 			if err != nil {
 				t.Fatalf("Transform: got err %q, didn't want one", err)
 			}
@@ -246,3 +261,67 @@ h1 {
 		})
 	}
 }
+
+func TestLoadTrustedTemplateWithDefaultConfig(t *testing.T) {
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cfg := LoadConfig{DisableCsp: !tt.csp, DisableXsrf: !tt.xsrf}
+			gotTpl, err := LoadTrustedTemplate(nil, cfg, uncheckedconversions.TrustedTemplateFromStringKnownToSatisfyTypeContract(tt.in))
+			if err != nil {
+				t.Fatalf("LoadTrustedTemplate: got err %q", err)
+			}
+			// Test that whatever we provide is clonable or injection won't be possible.
+			gotTpl, err = gotTpl.Clone()
+			if err != nil {
+				t.Fatalf("Clone loaded template: got err %q", err)
+			}
+			var sb strings.Builder
+			// Make functions return the source code that calls them and compare the results with the source.
+			// Sadly there is no good way to take the parsed sources out of a text/template.Template.
+			err = gotTpl.Funcs(map[string]interface{}{
+				XSRFTokensDefaultFuncName: func() string { return "{{" + XSRFTokensDefaultFuncName + "}}" },
+				CSPNoncesDefaultFuncName:  func() string { return "{{" + CSPNoncesDefaultFuncName + "}}" },
+			}).Execute(&sb, nil)
+			if err != nil {
+				t.Fatalf("Execute: got err %q", err)
+			}
+			if diff := cmp.Diff(tt.want, sb.String()); diff != "" {
+				t.Errorf("-want +got %s", diff)
+			}
+		})
+	}
+}
+
+func TestLoadGlob(t *testing.T) {
+	tpl, err := LoadGlob(nil, LoadConfig{}, safetemplate.TrustedSourceFromConstant("testdata/*.tpl"))
+
+	// Test that whatever we provide is clonable or injection won't be possible.
+	tpl, err = tpl.Clone()
+	if err != nil {
+		t.Fatalf("Clone loaded template: got err %q", err)
+	}
+	tpl = tpl.Funcs(map[string]interface{}{
+		XSRFTokensDefaultFuncName: func() string { return "{{" + XSRFTokensDefaultFuncName + "}}" },
+		CSPNoncesDefaultFuncName:  func() string { return "{{" + CSPNoncesDefaultFuncName + "}}" },
+	})
+	if got, want := len(tpl.Templates()), 2; got != want {
+		t.Fatalf("Loaded templates: got %d want %d %s", got, want, tpl.DefinedTemplates())
+	}
+	for _, inner := range tpl.Templates() {
+		t.Run(inner.Name(), func(t *testing.T) {
+			var sb strings.Builder
+			err := tpl.ExecuteTemplate(&sb, inner.Name(), nil)
+			if err != nil {
+				t.Fatalf("Executing: %v", err)
+			}
+			stripExt := strings.TrimSuffix(inner.Name(), filepath.Ext(inner.Name()))
+			b, err := ioutil.ReadFile("testdata/" + stripExt + ".want")
+			if err != nil {
+				t.Fatalf("Reading '.want' file: %v", err)
+			}
+			if got, want := sb.String(), string(b); got != want {
+				t.Errorf("got: %v, want: %v", got, want)
+			}
+		})
+	}
+}
diff --git a/safehttp/plugins/htmlinject/testdata/first.tpl b/safehttp/plugins/htmlinject/testdata/first.tpl
new file mode 100644
index 0000000..56a87fa
--- /dev/null
+++ b/safehttp/plugins/htmlinject/testdata/first.tpl
@@ -0,0 +1,20 @@
+<html>
+<head>
+<link rel="stylesheet" href="styles.css">
+<link rel=preload as="script" src="gopher.js">
+<style>
+h1 {
+  border: 5px solid yellow;
+}
+</style>
+</head>
+<body>
+<script type="application/javascript">alert("script")</script>
+<form>
+  First name:<br>
+  <input type="text" name="firstname"><br>
+  Last name:<br>
+  <input type="text" name="lastname">
+</form>
+</body>
+</html>
\ No newline at end of file
diff --git a/safehttp/plugins/htmlinject/testdata/first.want b/safehttp/plugins/htmlinject/testdata/first.want
new file mode 100644
index 0000000..cdfc7f9
--- /dev/null
+++ b/safehttp/plugins/htmlinject/testdata/first.want
@@ -0,0 +1,20 @@
+<html>
+<head>
+<link rel="stylesheet" href="styles.css">
+<link nonce="{{CSPNonce}}" rel=preload as="script" src="gopher.js">
+<style nonce="{{CSPNonce}}">
+h1 {
+  border: 5px solid yellow;
+}
+</style>
+</head>
+<body>
+<script nonce="{{CSPNonce}}" type="application/javascript">alert("script")</script>
+<form><input type="hidden" name="xsrf-token" value="{{XSRFToken}}">
+  First name:<br>
+  <input type="text" name="firstname"><br>
+  Last name:<br>
+  <input type="text" name="lastname">
+</form>
+</body>
+</html>
\ No newline at end of file
diff --git a/safehttp/plugins/htmlinject/testdata/second.tpl b/safehttp/plugins/htmlinject/testdata/second.tpl
new file mode 100644
index 0000000..fd41e13
--- /dev/null
+++ b/safehttp/plugins/htmlinject/testdata/second.tpl
@@ -0,0 +1,15 @@
+<html>
+<head>
+<link rel="stylesheet" href="styles.css">
+<link rel=preload as="script" src="gopher.js">
+</head>
+<body>
+<script type="application/javascript">alert("script")</script>
+<form>
+  First name:<br>
+  <input type="text" name="firstname"><br>
+  Last name:<br>
+  <input type="text" name="lastname">
+</form>
+</body>
+</html>
\ No newline at end of file
diff --git a/safehttp/plugins/htmlinject/testdata/second.want b/safehttp/plugins/htmlinject/testdata/second.want
new file mode 100644
index 0000000..e6f3f4b
--- /dev/null
+++ b/safehttp/plugins/htmlinject/testdata/second.want
@@ -0,0 +1,15 @@
+<html>
+<head>
+<link rel="stylesheet" href="styles.css">
+<link nonce="{{CSPNonce}}" rel=preload as="script" src="gopher.js">
+</head>
+<body>
+<script nonce="{{CSPNonce}}" type="application/javascript">alert("script")</script>
+<form><input type="hidden" name="xsrf-token" value="{{XSRFToken}}">
+  First name:<br>
+  <input type="text" name="firstname"><br>
+  Last name:<br>
+  <input type="text" name="lastname">
+</form>
+</body>
+</html>
\ No newline at end of file