185 lines
4.9 KiB
Go
185 lines
4.9 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package sando
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// ErrUnsafeURL is the sentinel wrapped by URLSafetyError when an ordinary URL
|
|
// value is ambiguous or uses a scheme outside the v1 allowlist.
|
|
var ErrUnsafeURL = errors.New("sando: unsafe URL")
|
|
|
|
// URLSafetyError reports why an ordinary URL was rejected. It intentionally
|
|
// does not retain or print the complete value, which may contain sensitive
|
|
// application data.
|
|
type URLSafetyError struct {
|
|
Scheme string
|
|
Reason string
|
|
}
|
|
|
|
// Error implements error.
|
|
func (e *URLSafetyError) Error() string {
|
|
if e.Scheme != "" {
|
|
return fmt.Sprintf("%v: scheme %q is not allowed", ErrUnsafeURL, e.Scheme)
|
|
}
|
|
if e.Reason != "" {
|
|
return fmt.Sprintf("%v: %s", ErrUnsafeURL, e.Reason)
|
|
}
|
|
return ErrUnsafeURL.Error()
|
|
}
|
|
|
|
// Unwrap permits errors.Is(err, ErrUnsafeURL).
|
|
func (e *URLSafetyError) Unwrap() error { return ErrUnsafeURL }
|
|
|
|
// WriteString writes a compiler-owned static literal and reports both writer
|
|
// errors and contract-violating short writes. Application data must use the
|
|
// context-specific helpers below instead.
|
|
func WriteString(w io.Writer, value string) error {
|
|
return writeString(w, value)
|
|
}
|
|
|
|
// WriteText writes value escaped for an HTML text node.
|
|
func WriteText(w io.Writer, value any) error {
|
|
if trusted, ok := value.(TrustedHTML); ok {
|
|
return writeString(w, trusted.value)
|
|
}
|
|
return writeString(w, html.EscapeString(normalizeText(stringValue(value))))
|
|
}
|
|
|
|
// WriteRCDATA writes value escaped for an HTML RCDATA element such as title
|
|
// or textarea. Trusted wrappers are intentionally not honored in this context:
|
|
// their contents are escaped like every other value so they cannot close the
|
|
// containing element.
|
|
func WriteRCDATA(w io.Writer, value any) error {
|
|
return writeString(w, html.EscapeString(normalizeText(stringValue(value))))
|
|
}
|
|
|
|
// WriteAttr writes value escaped for a quoted HTML attribute. Generated code
|
|
// must always place this output inside a syntactically complete quoted value.
|
|
func WriteAttr(w io.Writer, value any) error {
|
|
return writeString(w, html.EscapeString(normalizeText(stringValue(value))))
|
|
}
|
|
|
|
// WriteURL writes value escaped for a quoted URL-bearing HTML attribute.
|
|
// Ordinary values are canonicalized and checked before any bytes are written.
|
|
// TrustedURL bypasses the scheme check but never attribute escaping.
|
|
func WriteURL(w io.Writer, value any) error {
|
|
if trusted, ok := value.(TrustedURL); ok {
|
|
return writeString(w, html.EscapeString(normalizeText(trusted.value)))
|
|
}
|
|
|
|
canonical, err := canonicalURL(stringValue(value))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeString(w, html.EscapeString(canonical))
|
|
}
|
|
|
|
// WriteHTML writes deliberately trusted HTML without escaping.
|
|
func WriteHTML(w io.Writer, value TrustedHTML) error {
|
|
return writeString(w, value.value)
|
|
}
|
|
|
|
// WriteJS writes deliberately trusted JavaScript without escaping.
|
|
func WriteJS(w io.Writer, value TrustedJS) error {
|
|
return writeString(w, value.value)
|
|
}
|
|
|
|
// WriteCSS writes deliberately trusted CSS without escaping.
|
|
func WriteCSS(w io.Writer, value TrustedCSS) error {
|
|
return writeString(w, value.value)
|
|
}
|
|
|
|
func writeString(w io.Writer, value string) error {
|
|
if isNil(w) {
|
|
return ErrNilWriter
|
|
}
|
|
|
|
n, err := io.WriteString(w, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n != len(value) {
|
|
return io.ErrShortWrite
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stringValue(value any) string {
|
|
switch value := value.(type) {
|
|
case TrustedHTML:
|
|
return value.value
|
|
case TrustedURL:
|
|
return value.value
|
|
case TrustedJS:
|
|
return value.value
|
|
case TrustedCSS:
|
|
return value.value
|
|
default:
|
|
return fmt.Sprint(value)
|
|
}
|
|
}
|
|
|
|
func normalizeText(value string) string {
|
|
if !utf8.ValidString(value) {
|
|
value = strings.ToValidUTF8(value, "\uFFFD")
|
|
}
|
|
return strings.ReplaceAll(value, "\x00", "\uFFFD")
|
|
}
|
|
|
|
func canonicalURL(value string) (string, error) {
|
|
value = normalizeText(value)
|
|
value = strings.TrimFunc(value, func(r rune) bool {
|
|
return unicode.IsSpace(r) || r == '\uFEFF'
|
|
})
|
|
|
|
for _, r := range value {
|
|
if r < 0x20 || r == 0x7f {
|
|
return "", &URLSafetyError{Reason: "control characters are not allowed"}
|
|
}
|
|
}
|
|
|
|
colon := strings.IndexByte(value, ':')
|
|
boundary := strings.IndexAny(value, "/?#")
|
|
if colon < 0 || boundary >= 0 && boundary < colon {
|
|
return value, nil
|
|
}
|
|
|
|
scheme := value[:colon]
|
|
if !validScheme(scheme) {
|
|
return "", &URLSafetyError{Reason: "ambiguous scheme syntax"}
|
|
}
|
|
|
|
scheme = strings.ToLower(scheme)
|
|
switch scheme {
|
|
case "http", "https", "mailto", "tel":
|
|
return value, nil
|
|
default:
|
|
return "", &URLSafetyError{Scheme: scheme}
|
|
}
|
|
}
|
|
|
|
func validScheme(value string) bool {
|
|
if value == "" || !isASCIIAlpha(value[0]) {
|
|
return false
|
|
}
|
|
for i := 1; i < len(value); i++ {
|
|
c := value[i]
|
|
if !isASCIIAlpha(c) && (c < '0' || c > '9') && c != '+' && c != '-' && c != '.' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isASCIIAlpha(c byte) bool {
|
|
return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'
|
|
}
|