diff --git a/appview/pages/markup/sanitizer.go b/appview/pages/markup/sanitizer.go index b0f0a97d..61b0117b 100644 --- a/appview/pages/markup/sanitizer.go +++ b/appview/pages/markup/sanitizer.go @@ -14,22 +14,26 @@ import ( var ( sharedDefaultPolicy *bluemonday.Policy sharedDescriptionPolicy *bluemonday.Policy + sharedLogsPolicy *bluemonday.Policy ) func init() { sharedDefaultPolicy = buildDefaultPolicy() sharedDescriptionPolicy = buildDescriptionPolicy() + sharedLogsPolicy = buildLogsPolicy() } type Sanitizer struct { defaultPolicy *bluemonday.Policy descriptionPolicy *bluemonday.Policy + logsPolicy *bluemonday.Policy } func NewSanitizer() Sanitizer { return Sanitizer{ defaultPolicy: sharedDefaultPolicy, descriptionPolicy: sharedDescriptionPolicy, + logsPolicy: sharedLogsPolicy, } } @@ -39,6 +43,9 @@ func (s *Sanitizer) SanitizeDefault(html string) string { func (s *Sanitizer) SanitizeDescription(html string) string { return s.descriptionPolicy.Sanitize(html) } +func (s *Sanitizer) SanitizeLogs(html string) string { + return s.logsPolicy.Sanitize(html) +} func buildDefaultPolicy() *bluemonday.Policy { policy := bluemonday.UGCPolicy() @@ -153,3 +160,17 @@ func buildDescriptionPolicy() *bluemonday.Policy { return policy } + +func buildLogsPolicy() *bluemonday.Policy { + policy := bluemonday.NewPolicy() + + policy.AllowElements("p", "span") + + // allow italics and bold + policy.AllowElements("i", "b", "em", "strong") + + // allow fg/bg classes from terminal-to-html + policy.AllowAttrs("class").Matching(regexp.MustCompile(`term-*`)).OnElements("span") + + return policy +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 5ad48c66..20d4bfd1 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1536,7 +1536,7 @@ func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { type LogLineParams struct { Id int - Content string + Content template.HTML } func (p *Pages) LogLine(w io.Writer, params LogLineParams) error { diff --git a/appview/pipelines/ansi_test.go b/appview/pipelines/ansi_test.go new file mode 100644 index 00000000..5f0a2be5 --- /dev/null +++ b/appview/pipelines/ansi_test.go @@ -0,0 +1,144 @@ +package pipelines + +import ( + "strings" + "testing" +) + +func TestAnsiState_SingleLine(t *testing.T) { + tests := []struct { + name string + input string + wantContain string // substring expected in rendered HTML + }{ + {"bold", "\033[1mBold\033[0m", "term-fg1"}, + {"red", "\033[31mRed\033[0m", "term-fg31"}, + {"green", "\033[32mGreen\033[0m", "term-fg32"}, + {"yellow", "\033[33mYellow\033[0m", "term-fg33"}, + {"blue", "\033[34mBlue\033[0m", "term-fg34"}, + {"magenta", "\033[35mMagenta\033[0m", "term-fg35"}, + {"cyan", "\033[36mCyan\033[0m", "term-fg36"}, + {"bold green", "\033[1;32mBold Green\033[0m", "term-fg32"}, + {"red background", "\033[41m Red background \033[0m", "term-bg41"}, + {"256 orange", "\033[38;5;208mANSI 256 orange\033[0m", "term-fgx208"}, + // true color is stripped + {"true color", "\033[38;2;255;100;0mTrue color orange\033[0m", "True color orange"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := NewAnsiState() + got := string(a.Render(tt.input)) + t.Logf("%s → %s", tt.name, got) + if !strings.Contains(got, tt.wantContain) { + t.Errorf("expected output to contain %q, got: %s", tt.wantContain, got) + } + }) + } +} + +func TestAnsiState_MultiLine_CarryOver(t *testing.T) { + // red opened on line 1 without reset — line 2 should carry it over. + a := NewAnsiState() + + line1 := a.Render("\033[31mstart of red") + if !strings.Contains(string(line1), "term-fg31") { + t.Errorf("line1: expected term-fg31, got: %s", line1) + } + + line2 := a.Render("still red\033[0m") + t.Logf("line2 → %s", line2) + if !strings.Contains(string(line2), "term-fg31") { + t.Errorf("line2: expected carry-over term-fg31, got: %s", line2) + } + + // after the reset, line 3 should have no colour. + line3 := a.Render("plain text") + t.Logf("line3 → %s", line3) + if strings.Contains(string(line3), "term-fg31") { + t.Errorf("line3: expected no term-fg31 after reset, got: %s", line3) + } +} + +func TestAnsiState_MultiLine_StackedSequences(t *testing.T) { + // bold opened line 1, red added line 2 — both should carry to line 2. + a := NewAnsiState() + + a.Render("\033[1mBold opened") + + line2 := a.Render("\033[31mRed added") + t.Logf("line2 → %s", line2) + if !strings.Contains(string(line2), "term-fg1") { + t.Errorf("line2: expected carried-over term-fg1, got: %s", line2) + } + if !strings.Contains(string(line2), "term-fg31") { + t.Errorf("line2: expected term-fg31, got: %s", line2) + } + + a.Render("\033[0mReset") + + line4 := a.Render("plain") + t.Logf("line4 → %s", line4) + if strings.Contains(string(line4), "term-") { + t.Errorf("line4: expected no term- classes after reset, got: %s", line4) + } +} + +func TestAnsiState_Reset_ClearsStack(t *testing.T) { + a := NewAnsiState() + a.Render("\033[31m\033[1m\033[33mMultiple opens") + if len(a.stack) != 3 { + t.Errorf("expected stack depth 3, got %d", len(a.stack)) + } + a.Render("\033[0mReset line") + if len(a.stack) != 0 { + t.Errorf("expected empty stack after reset, got %d: %v", len(a.stack), a.stack) + } +} + +func TestAnsiState_NoAnsi(t *testing.T) { + a := NewAnsiState() + got := string(a.Render("plain text no escapes")) + t.Logf("got → %s", got) + if strings.Contains(got, "term-") { + t.Errorf("expected no term- classes for plain text, got: %s", got) + } +} + +func TestAnsiState_Sanitizer_XSS(t *testing.T) { + tests := []struct { + name string + input string + shuoldBeAbsent string // must not appear literally (unescaped) in output + }{ + { + name: "script tag not executable", + input: "", + shuoldBeAbsent: "\033[0m", + shuoldBeAbsent: "