-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathescape_diff_test.go
More file actions
168 lines (153 loc) · 6.21 KB
/
Copy pathescape_diff_test.go
File metadata and controls
168 lines (153 loc) · 6.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
package gsx
import (
"bytes"
"html/template"
"strings"
"testing"
)
// --- html/template oracles ----------------------------------------------------
// htmlTextOracle returns what html/template emits for s in HTML *text* context.
func htmlTextOracle(s string) string { return template.HTMLEscapeString(s) }
// cssOracle returns what html/template emits for s inside <style>{{.}}</style>
// (CSS value context) — the layer gsx's cssValueFilter targets.
func cssOracle(s string) string {
t := template.Must(template.New("c").Parse(`<style>{{.}}</style>`))
var b bytes.Buffer
_ = t.Execute(&b, s)
out := b.String()
out = strings.TrimPrefix(out, "<style>")
out = strings.TrimSuffix(out, "</style>")
return out
}
// urlBlockedByStdlib reports whether html/template neutralizes s as a URL (its
// unsafe-scheme sentinel #ZgotmplZ appears in an href). gsx's urlSanitize does
// scheme-filtering WITHOUT normalization, so we compare the SAFETY DECISION, not
// bytes.
func urlBlockedByStdlib(s string) bool {
t := template.Must(template.New("u").Parse(`<a href="{{.}}">`))
var b bytes.Buffer
_ = t.Execute(&b, s)
return strings.Contains(b.String(), "#ZgotmplZ")
}
// gsxHTML is writeHTML as a string.
func gsxHTML(s string) string {
var b strings.Builder
_ = writeHTML(&b, s)
return b.String()
}
// --- divergence allow-list ----------------------------------------------------
type diffCtx int
const (
ctxHTML diffCtx = iota
ctxCSS
)
type diffKey struct {
ctx diffCtx
in string
}
// knownDivergences records (context,input) pairs where gsx INTENTIONALLY differs
// from html/template, each with a justification. The differential test skips
// ONLY these exact pairs. Add an entry only after confirming the difference is
// deliberate and safe (cite escape.go).
var knownDivergences = map[diffKey]string{
// (populated during Step 2 triage; URL sentinel is handled by safety-decision
// parity, not here.)
}
// isKnownURLDivergence reports whether the URL safety-decision difference between
// gsx and html/template for s is intentional and safe. It covers two classes:
//
// 1. tel: scheme — gsx allows tel: (RFC 3966 phone links); stdlib does not.
// tel: opens the phone dialer and cannot execute scripts. See escape.go:39.
//
// 2. Relative URLs where '#' or '?' precedes ':' — gsx's urlSanitize correctly
// exempts these from scheme detection per RFC 3986 (after '#' begins the
// fragment; after '?' begins the query — neither is a scheme context).
// html/template's isSafeURL only exempts '/' before ':', so it blocks
// strings like "#:", "#anchor:name", "?q=a:b" as conservative false-positives.
// In a browser, <a href="#foo:bar"> is a fragment link, not a protocol handler;
// no script execution or irreversible side effect can occur.
func isKnownURLDivergence(s string) bool {
before, _, ok := strings.Cut(s, ":")
if !ok {
return false
}
// Class 1: tel: scheme intentionally allowed by gsx.
if strings.EqualFold(before, "tel") {
return true
}
// Class 2: '#' or '?' precedes ':' — gsx treats this as non-scheme context.
// stdlib blocks conservatively; gsx follows RFC 3986.
return strings.ContainsAny(before, "#?")
}
func TestEscaperMatchesStdlib(t *testing.T) {
inputs := diffCorpus()
for _, s := range inputs {
// HTML text context: byte-parity.
if _, skip := knownDivergences[diffKey{ctxHTML, s}]; !skip {
if got, want := gsxHTML(s), htmlTextOracle(s); got != want {
t.Errorf("HTML escape mismatch for %q:\n gsx = %q\n std = %q\n(if intentional, add to knownDivergences with a reason)", s, got, want)
}
}
// CSS value context: byte-parity.
if _, skip := knownDivergences[diffKey{ctxCSS, s}]; !skip {
if got, want := cssValueFilter(s), cssOracle(s); got != want {
t.Errorf("CSS filter mismatch for %q:\n gsx = %q\n std = %q\n(if intentional, add to knownDivergences with a reason)", s, got, want)
}
}
// URL context: SAFETY-DECISION parity (gsx blocks <=> stdlib neutralizes).
if !isKnownURLDivergence(s) {
gsxBlocked := urlSanitize(s) == "about:invalid#gsx"
if gsxBlocked != urlBlockedByStdlib(s) {
t.Errorf("URL safety-decision mismatch for %q: gsx blocked=%v, stdlib blocked=%v", s, gsxBlocked, urlBlockedByStdlib(s))
}
}
}
}
// diffCorpus is the shared differential input set: the existing CSS fuzz seeds,
// known XSS vectors, and boundary bytes.
func diffCorpus() []string {
return []string{
// benign
"", "foo", "hello world", "a&b", "10px", "#fff", "color: red",
"/path/to/x", "https://example.com/a?b=c#d", "mailto:a@b.com", "tel:+1",
// HTML-significant
`a<b>c`, `"quoted"`, "'apos'", "x&y", "<script>alert(1)</script>",
// URL schemes (safety decision)
"javascript:alert(1)", "JavaScript:alert(1)", "vbscript:x", "data:text/html,x",
"http://ok", " javascript:x", "/rel?a=b",
"java\tscript:alert(1)", "java\nscript:alert(1)", "java\rscript:alert(1)",
"\tjavascript:alert(1)", "\njavascript:alert(1)", " javascript:alert(1)",
"0;url=javascript:alert(1)", "0; url= java\tscript:alert(1)",
"0;url='javascript:alert(1)'", "0, URL=\"JavaScript:alert(1)\"",
// CSS (reuse FuzzCSSValueFilter seeds)
"rgb(1,2,3)", "<!--", "-->", "</style", "expression(alert(1))", "EXPRESSION",
"-moz-binding", `\3c script`, `-expre\69on`, "url(javascript:alert(1))",
"a;b}c{d", "--x: ;", "1.25in", "`backtick`", "\x00",
// CSS decode-branch seeds (uppercase hex, ws-after-hex, trailing backslash, MaxRune clamp)
"\\3C script\\3E", "expr\\65\tssion", "expr\\65\nssion",
"expr\\65\fssion", "expr\\65\rssion", "expr\\65\r\nssion",
"foo\\", "\\110000",
}
}
func FuzzEscaperMatchesStdlib(f *testing.F) {
for _, s := range diffCorpus() {
f.Add(s)
}
f.Fuzz(func(t *testing.T, s string) {
if _, skip := knownDivergences[diffKey{ctxHTML, s}]; !skip {
if got, want := gsxHTML(s), htmlTextOracle(s); got != want {
t.Fatalf("HTML divergence for %q: gsx=%q std=%q", s, got, want)
}
}
if _, skip := knownDivergences[diffKey{ctxCSS, s}]; !skip {
if got, want := cssValueFilter(s), cssOracle(s); got != want {
t.Fatalf("CSS divergence for %q: gsx=%q std=%q", s, got, want)
}
}
if !isKnownURLDivergence(s) {
if (urlSanitize(s) == "about:invalid#gsx") != urlBlockedByStdlib(s) {
t.Fatalf("URL safety-decision divergence for %q", s)
}
}
})
}