diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc6289..01b8643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ # Changelog -## [Unreleased] +## [0.0.2] - 2019-04-28 ### Added - Changelog - `hatmill.Terms` type for representing lists of nodes +### Changed +- Attrib.WriteTo replaces special characters in Value with HTML entities. +- Text.WriteTo replaces special characters with HTML entities. + ## [0.0.1] - 2019-04-28 Initial development release diff --git a/go.mod b/go.mod index f1e1ce8..71e73bb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,3 @@ module gitlab.codemonkeysoftware.net/b/hatmill -require ( - github.com/golang/mock v1.2.0 // indirect - github.com/leanovate/gopter v0.2.4 -) +require github.com/leanovate/gopter v0.2.4 diff --git a/go.sum b/go.sum index f0b00dd..c19f72b 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,2 @@ -github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/leanovate/gopter v0.2.4 h1:U4YLBggDFhJdqQsG4Na2zX7joVTky9vHaj/AGEwSuXU= github.com/leanovate/gopter v0.2.4/go.mod h1:gNcbPWNEWRe4lm+bycKqxUYoH5uoVje5SkOJ3uoLer8= diff --git a/hatmill.go b/hatmill.go index 2a9e5f2..bc21ff7 100644 --- a/hatmill.go +++ b/hatmill.go @@ -1,7 +1,11 @@ package hatmill //go:generate go run internal/codegen/codegen.go -input defs.json -elemfile element/generated.go -elempkg element -attribfile attribute/generated.go -attribpkg attribute -import "io" +import ( + "bytes" + "html" + "io" +) // Term represents a fragment of HTML markup, and is one of VoidElement, ParentElement, Terms, or Text. type Term interface { @@ -32,7 +36,8 @@ type Attrib struct { } // WriteTo writes a to w as an HTML attribute in the form key="value", or -// simply key if value is empty. It returns the number of bytes written and any +// simply key if value is empty. Special characters in value are replaced with +// HTML entities. It returns the number of bytes written and any // error encountered. func (a Attrib) WriteTo(w io.Writer) (n int64, err error) { err = writeStringsTo(w, &n, a.Key) @@ -40,7 +45,8 @@ func (a Attrib) WriteTo(w io.Writer) (n int64, err error) { return } if a.Value != "" { - err = writeStringsTo(w, &n, "='", a.Value, "'") + escaped := html.EscapeString(a.Value) + err = writeStringsTo(w, &n, "='", escaped, "'") } return } @@ -56,8 +62,6 @@ func (VoidElement) isHtml() {} // WriteTo writes the HTML markup represented by e to w, returning the number // of bytes written and any error encountered. -// -// See the warning about sanitization in the (Attrib).WriteTo documentation. func (e VoidElement) WriteTo(w io.Writer) (n int64, err error) { err = writeStringsTo(w, &n, "<", e.TagName) if err != nil { @@ -92,8 +96,6 @@ func (e ParentElement) isHtml() {} // WriteTo writes the HTML markup represented by e to w, returning the number // of bytes written and any error encountered. -// -// See the warning about sanitization in the (Attrib).WriteTo documentation. func (e ParentElement) WriteTo(w io.Writer) (n int64, err error) { n, err = e.VoidElement.WriteTo(w) if err != nil { @@ -116,11 +118,50 @@ type Text string func (Text) isHtml() {} +var entities = map[byte][]byte{ + '&': []byte("&"), + '<': []byte("<"), + '>': []byte(">"), + '\'': []byte("'"), + '"': []byte("""), +} + +type escapedWriter struct { + io.Writer +} + +func (w escapedWriter) Write(p []byte) (n int, err error) { + const specialChars = `&<>'"` + for len(p) > 0 { + var nCurr int + idx := bytes.IndexAny(p, specialChars) + if idx == -1 { + nCurr, err = w.Writer.Write(p) + n += nCurr + return + } + + nCurr, err = w.Writer.Write(p[:idx]) + n += nCurr + if err != nil { + return n, err + } + + entity := entities[p[idx]] + nCurr, err = w.Writer.Write(entity) + n += nCurr + if err != nil { + return n, err + } + p = p[idx+1:] + } + return n, nil +} + // WriteTo writes the contents of t to w, returning the number of bytes written -// and any error encountered. It does not replace special characters with HTML -// entities; use html.EscapeString for this purpose. +// and any error encountered. It replaces special characters with HTML entities. func (t Text) WriteTo(w io.Writer) (n int64, err error) { - err = writeStringsTo(w, &n, string(t)) + err = writeStringsTo(escapedWriter{Writer: w}, &n, string(t)) return } diff --git a/hatmill_test.go b/hatmill_test.go index 3475929..02cad26 100644 --- a/hatmill_test.go +++ b/hatmill_test.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" "testing" + "unicode" "github.com/leanovate/gopter" "github.com/leanovate/gopter/gen" @@ -39,27 +40,58 @@ func checkWrite(wt io.WriterTo, expected string) bool { return err == nil && n == int64(buf.Len()) && buf.String() == expected } +var ASCII = &unicode.RangeTable{ + R16: []unicode.Range16{ + {Lo: ' ', Hi: unicode.MaxASCII, Stride: 1}, + }, + LatinOffset: 1, +} + +var dangerousASCII = gen.UnicodeString(ASCII).SuchThat(func(v interface{}) bool { + s := v.(string) + const specialChars = `&<>'"` + for _, c := range specialChars { + if strings.ContainsRune(s, rune(c)) { + return true + } + } + return false +}) + +func printableASCII() string { + const minPrintable rune = 32 + const maxPrintable rune = 126 + var buf bytes.Buffer + for r := minPrintable; r <= maxPrintable; r++ { + buf.WriteRune(r) + } + return buf.String() +} + func TestText(t *testing.T) { properties := gopter.NewProperties(nil) - - properties.Property("WriteTo is correct", prop.ForAll( + properties.Property("WriteTo escapes special chars and outputs all others unchanged", prop.ForAll( func(s string) bool { - return checkWrite(hatmill.Text(s), s) + expected := html.EscapeString(s) + return checkWrite(hatmill.Text(s), expected) }, - gen.AnyString(), + dangerousASCII, )) - properties.TestingRun(t) } -var nonEmptyAlphaString = gen.AlphaString().SuchThat(func(v interface{}) bool { +func stringNotEmpty(v interface{}) bool { return v.(string) != "" -}) +} -var attribGen = gen.Struct(reflect.TypeOf(hatmill.Attrib{}), map[string]gopter.Gen{ - "Key": nonEmptyAlphaString, - "Value": gen.AlphaString(), -}) +var nonEmptyAlphaString = gen.AlphaString().SuchThat(stringNotEmpty) + +func attribGen(value gopter.Gen) gopter.Gen { + return gen.Struct(reflect.TypeOf(hatmill.Attrib{}), map[string]gopter.Gen{ + "Key": nonEmptyAlphaString, + "Value": value, + }) +} func TestAttrib(t *testing.T) { properties := gopter.NewProperties(nil) @@ -79,9 +111,15 @@ func TestAttrib(t *testing.T) { expected := fmt.Sprintf("%s='%s'", attrib.Key, attrib.Value) return checkWrite(attrib, expected) }, - attribGen.SuchThat(func(attrib interface{}) bool { - return attrib.(hatmill.Attrib).Value != "" - }), + attribGen(nonEmptyAlphaString), + )) + + properties.Property("WriteTo escapes attribute value", prop.ForAll( + func(attrib hatmill.Attrib) bool { + expected := fmt.Sprintf("%s='%s'", attrib.Key, html.EscapeString(attrib.Value)) + return checkWrite(attrib, expected) + }, + attribGen(dangerousASCII.SuchThat(stringNotEmpty)), )) properties.TestingRun(t) @@ -120,7 +158,7 @@ func TestEmptyElement(t *testing.T) { return checkWrite(elem, expected) }, gen.AnyString(), - gen.SliceOf(attribGen), + gen.SliceOf(attribGen(gen.AlphaString())), )) properties.TestingRun(t) @@ -147,7 +185,7 @@ func TestParentElement(t *testing.T) { return checkWrite(elem, expected) }, gen.AnyString(), - gen.SliceOf(attribGen), + gen.SliceOf(attribGen(gen.AlphaString())), )) properties.Property("WriteTo writes element correctly with children", prop.ForAll( @@ -171,7 +209,7 @@ func TestParentElement(t *testing.T) { return checkWrite(elem, expected) }, gen.AnyString(), - gen.SliceOf(attribGen), + gen.SliceOf(attribGen(gen.AlphaString())), gen.SliceOf(htmlTextGen), )) @@ -199,7 +237,7 @@ func Example() { document := he.Html()( he.Body()( he.Img(ha.Src("./photo.jpg"), ha.Contenteditable(true)), - hatmill.Text(html.EscapeString(userInput)), + hatmill.Text(userInput), he.Div(ha.Disabled(), ha.CustomData("coolness", "awesome"))(), he.Textarea(ha.Rows(25))(), he.Meter(ha.Min(-1.3), ha.Max(5.5E12))(),