package web import ( "html/template" "net/url" "strings" ) // contactLink decides how to render the configured contact. // // An address becomes a mailto:, an http(s) URL stays a link, and anything else // — a channel name, an internal ticket queue, a person — is shown as text. // // The scheme allowlist is the point. This string comes from configuration, but // configuration is not always written by the person who runs the gateway, and a // javascript: or data: href on a page served to every visitor is a stored XSS // with a very short path to it. Templates escape the text; they do not make a // scheme safe. func contactLink(contact string) (text, href string) { c := strings.TrimSpace(contact) if c == "" { return "", "" } if u, err := url.Parse(c); err == nil { switch strings.ToLower(u.Scheme) { case "http", "https", "mailto": return c, c case "": // No scheme. A bare address is the common case and worth linking. if looksLikeEmail(c) { return c, "mailto:" + c } } } return c, "" } // contactMarkup is the contact as a link, or as emphasised text when it is not // one. Both parts are escaped here, because the sentence it goes into is // markup; the href has already passed contactLink's scheme allowlist. func contactMarkup(text, href string) template.HTML { esc := template.HTMLEscapeString if href != "" { return template.HTML(`` + esc(text) + ``) //nolint:gosec // escaped, and the scheme is allowlisted } return template.HTML(`` + esc(text) + ``) //nolint:gosec // escaped } // looksLikeEmail is deliberately crude: it decides whether to add a mailto:, // and being wrong means a link that does not open a mail client rather than // anything worse. func looksLikeEmail(s string) bool { at := strings.IndexByte(s, '@') if at <= 0 || at == len(s)-1 || strings.ContainsAny(s, " \t/") { return false } return strings.Contains(s[at+1:], ".") }