feat: bounty board, task lifecycle, AI work performer flow, notifications (phase 8)
- whitelist HTML sanitizer (stdlib tokenizer) with XSS vector tests - developer board: pool-scoped visibility, customer/search/minBounty/sort filters, stale-task age badges, competing-claims visibility setting, saved filters in profile extras - claims: request/withdraw (developer), approve/decline (consultant) with notifications to winners and losers; unassign/abandon back to board - work tracking: start, sanitized comments with @mention notifications, time logging, submit for review - review queue + review with per-AC checklist stored on the timeline; approve writes the immutable bountyAwards row (human assignees only) - assign-to-AI: §5.2 job submission, HMAC-verified callback endpoint, idempotent by jobId, artifacts downloaded into GridFS, failure path keeps the task assigned with timeline + notification - notifications API + bell with unread badge, dropdown, page, WS toasts - pages: bounty board, my-tasks kanban, task detail (role-driven actions, review dialog, AI dialog), review queue, developer pool Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// 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 <a> 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 <a>), 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)
|
||||
|
||||
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("</" + top + ">")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if voidTags[name] {
|
||||
out.WriteString("<" + name + ">")
|
||||
continue
|
||||
}
|
||||
if name == "a" {
|
||||
href := safeHref(attrs)
|
||||
if href == "" {
|
||||
out.WriteString("<a>")
|
||||
} else {
|
||||
out.WriteString(`<a href="` + html.EscapeString(href) + `" rel="noopener noreferrer" target="_blank">`)
|
||||
}
|
||||
} else {
|
||||
out.WriteString("<" + name + ">")
|
||||
}
|
||||
stack = append(stack, name)
|
||||
}
|
||||
// balance anything left open
|
||||
for n := len(stack) - 1; n >= 0; n-- {
|
||||
out.WriteString("</" + stack[n] + ">")
|
||||
}
|
||||
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, ""
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeHTML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"plain text escaped", `hello <world> & "friends"`, "hello & "friends""},
|
||||
{"allowed formatting kept", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>"},
|
||||
{"paragraph and lists", "<p>a</p><ul><li>x</li><li>y</li></ul>", "<p>a</p><ul><li>x</li><li>y</li></ul>"},
|
||||
{"code and pre", "<pre><code>x := 1</code></pre>", "<pre><code>x := 1</code></pre>"},
|
||||
{"blockquote", "<blockquote>q</blockquote>", "<blockquote>q</blockquote>"},
|
||||
{"br void", "a<br>b<br/>c", "a<br>b<br>c"},
|
||||
{"script stripped", `<script>alert(1)</script>hi`, "alert(1)hi"},
|
||||
{"img dropped", `<img src=x onerror=alert(1)>safe`, "safe"},
|
||||
{"event handlers stripped from allowed tag", `<b onclick="evil()">x</b>`, "<b>x</b>"},
|
||||
{"style attr stripped", `<p style="position:fixed">x</p>`, "<p>x</p>"},
|
||||
{"https link kept with rel", `<a href="https://x.y/z">l</a>`,
|
||||
`<a href="https://x.y/z" rel="noopener noreferrer" target="_blank">l</a>`},
|
||||
{"http link kept", `<a href="http://x.y">l</a>`,
|
||||
`<a href="http://x.y" rel="noopener noreferrer" target="_blank">l</a>`},
|
||||
{"javascript href removed", `<a href="javascript:alert(1)">l</a>`, "<a>l</a>"},
|
||||
{"data href removed", `<a href="data:text/html,x">l</a>`, "<a>l</a>"},
|
||||
{"single-quoted js href removed", `<a href='javascript:x'>l</a>`, "<a>l</a>"},
|
||||
{"unquoted href", `<a href=https://ok.example>l</a>`,
|
||||
`<a href="https://ok.example" rel="noopener noreferrer" target="_blank">l</a>`},
|
||||
{"unclosed tags balanced", "<b>bold <i>both", "<b>bold <i>both</i></b>"},
|
||||
{"stray close ignored", "</b>text", "text"},
|
||||
{"mismatched nesting closed in order", "<b><i>x</b></i>", "<b><i>x</i></b>"},
|
||||
{"case insensitive tags", "<B>x</B><SCRIPT>y</SCRIPT>", "<b>x</b>y"},
|
||||
{"nested quotes attack", `<a href="https://x" "><script>alert(1)</script>">x</a>`,
|
||||
`<a href="https://x" rel="noopener noreferrer" target="_blank">alert(1)">x</a>`},
|
||||
{"incomplete tag escaped", "a <b", "a <b"},
|
||||
{"empty input", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SanitizeHTML(tt.in); got != tt.want {
|
||||
t.Errorf("SanitizeHTML(%q)\n got %q\n want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHTMLNeverEmitsDangerousOutput(t *testing.T) {
|
||||
vectors := []string{
|
||||
`<script>alert(1)</script>`,
|
||||
`<img src=x onerror=alert(1)>`,
|
||||
`<svg onload=alert(1)>`,
|
||||
`<iframe src="https://evil"></iframe>`,
|
||||
`<a href="javascript:alert(1)">x</a>`,
|
||||
`<A HREF="JAVASCRIPT:alert(1)">x</A>`,
|
||||
`<b onmouseover="alert(1)">x</b>`,
|
||||
`<<script>script>alert(1)<</script>/script>`,
|
||||
`<p><style>*{}</style></p>`,
|
||||
`<form action="https://evil"><input></form>`,
|
||||
}
|
||||
for _, v := range vectors {
|
||||
got := strings.ToLower(SanitizeHTML(v))
|
||||
for _, bad := range []string{"<script", "onerror", "onload", "onmouseover",
|
||||
"javascript:", "<iframe", "<svg", "<img", "<form", "<style", "<input"} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("vector %q produced dangerous output %q (contains %q)", v, got, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePlain(t *testing.T) {
|
||||
if got := SanitizePlain(`<b>x</b> & <script>y</script>`); got != "x & y" {
|
||||
t.Errorf("SanitizePlain = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user