// Package chat implements messaging primitives. sanitize.go is the // server-side rich-text whitelist sanitizer (§2.2): allowed tags are // b i u s a code pre ul ol li br p blockquote; only keeps an attribute // (href, http/https/mailto only). Everything else is escaped as text. // Implemented with a small hand-written tokenizer — no parser dependency. package chat import ( "html" "strings" ) var allowedTags = map[string]bool{ "b": true, "i": true, "u": true, "s": true, "a": true, "code": true, "pre": true, "ul": true, "ol": true, "li": true, "br": true, "p": true, "blockquote": true, } var voidTags = map[string]bool{"br": true} // SanitizeHTML rewrites untrusted rich text into safe HTML. Unknown tags are // dropped (their text content kept), attributes are stripped (except a safe // href on ), tags are re-balanced so output never leaks open elements. func SanitizeHTML(in string) string { var out strings.Builder var stack []string i := 0 for i < len(in) { c := in[i] if c != '<' { j := strings.IndexByte(in[i:], '<') if j < 0 { out.WriteString(html.EscapeString(in[i:])) break } out.WriteString(html.EscapeString(in[i : i+j])) i += j continue } // find the end of the tag end := strings.IndexByte(in[i:], '>') if end < 0 { out.WriteString(html.EscapeString(in[i:])) break } raw := in[i+1 : i+end] // between < and > i += end + 1 closing := strings.HasPrefix(raw, "/") raw = strings.TrimPrefix(raw, "/") raw = strings.TrimSuffix(strings.TrimSpace(raw), "/") name, attrs := splitTag(raw) name = strings.ToLower(name) // @-mention tokens: @Name. // Only this exact shape is allowed; any other span is dropped. if name == "span" { if closing { for n := len(stack) - 1; n >= 0; n-- { if stack[n] == "span" { for len(stack) > n { top := stack[len(stack)-1] stack = stack[:len(stack)-1] out.WriteString("") } break } } continue } uid := mentionUID(attrs) if uid == "" { continue // drop the opening span, keep its text } out.WriteString(``) stack = append(stack, "span") continue } if !allowedTags[name] { continue // drop the tag entirely, keep surrounding text } if closing { // close up to the matching open tag, if any for n := len(stack) - 1; n >= 0; n-- { if stack[n] == name { for len(stack) > n { top := stack[len(stack)-1] stack = stack[:len(stack)-1] out.WriteString("") } break } } continue } if voidTags[name] { out.WriteString("<" + name + ">") continue } if name == "a" { href := safeHref(attrs) if href == "" { out.WriteString("") } else { out.WriteString(``) } } else { out.WriteString("<" + name + ">") } stack = append(stack, name) } // balance anything left open for n := len(stack) - 1; n >= 0; n-- { out.WriteString("") } return out.String() } // splitTag separates the tag name from its raw attribute string. func splitTag(raw string) (string, string) { for i := 0; i < len(raw); i++ { switch raw[i] { case ' ', '\t', '\n', '\r': return raw[:i], raw[i+1:] } } return raw, "" } // mentionUID validates a mention span's attributes and returns the referenced // user id (alphanumeric, ULID-shaped), or "" if the span is not a valid // mention. func mentionUID(attrs string) string { low := strings.ToLower(attrs) if !strings.Contains(low, "mention") { return "" } idx := strings.Index(low, "data-user-card") if idx < 0 { return "" } rest := strings.TrimSpace(attrs[idx+len("data-user-card"):]) if !strings.HasPrefix(rest, "=") { return "" } rest = strings.TrimSpace(rest[1:]) var val string switch { case strings.HasPrefix(rest, `"`): if e := strings.IndexByte(rest[1:], '"'); e >= 0 { val = rest[1 : 1+e] } case strings.HasPrefix(rest, "'"): if e := strings.IndexByte(rest[1:], '\''); e >= 0 { val = rest[1 : 1+e] } default: val = rest if sp := strings.IndexAny(val, " \t"); sp >= 0 { val = val[:sp] } } if val == "" || len(val) > 32 { return "" } for _, r := range val { if !((r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) { return "" } } return val } // safeHref extracts href and accepts only http, https, or mailto schemes. func safeHref(attrs string) string { lower := strings.ToLower(attrs) idx := strings.Index(lower, "href") if idx < 0 { return "" } rest := strings.TrimSpace(attrs[idx+4:]) if !strings.HasPrefix(rest, "=") { return "" } rest = strings.TrimSpace(rest[1:]) var val string switch { case strings.HasPrefix(rest, `"`): if end := strings.IndexByte(rest[1:], '"'); end >= 0 { val = rest[1 : 1+end] } case strings.HasPrefix(rest, "'"): if end := strings.IndexByte(rest[1:], '\''); end >= 0 { val = rest[1 : 1+end] } default: val = rest if sp := strings.IndexAny(val, " \t\n"); sp >= 0 { val = val[:sp] } } val = strings.TrimSpace(val) low := strings.ToLower(val) if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") || strings.HasPrefix(low, "mailto:") { return val } return "" } // SanitizePlain strips ALL tags, returning escaped plain text (used where // rich text is not allowed). func SanitizePlain(in string) string { var out strings.Builder i := 0 for i < len(in) { if in[i] != '<' { j := strings.IndexByte(in[i:], '<') if j < 0 { out.WriteString(html.EscapeString(in[i:])) break } out.WriteString(html.EscapeString(in[i : i+j])) i += j continue } end := strings.IndexByte(in[i:], '>') if end < 0 { out.WriteString(html.EscapeString(in[i:])) break } i += end + 1 } return out.String() }