Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions internal/commands/timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/charmbracelet/x/ansi"
"github.com/spf13/cobra"

"github.com/basecamp/basecamp-cli/internal/appctx"
Expand Down Expand Up @@ -395,19 +396,26 @@ func formatEvent(e basecamp.TimelineEvent) string {
timeStr = "--:--"
}

// API-controlled fields are ANSI-stripped before rendering: rune truncation
// preserves escape bytes, and the alt-screen watch TUI would otherwise
// execute embedded OSC/ANSI sequences (terminal injection).
// Strip before the empty check so a name that's only escape sequences
// still falls back to the placeholder rather than rendering blank.
creatorName := "Someone"
if e.Creator != nil && e.Creator.Name != "" {
creatorName = e.Creator.Name
if e.Creator != nil {
if name := ansi.Strip(e.Creator.Name); name != "" {
creatorName = name
}
}

action := e.Action
action := ansi.Strip(e.Action)
if action == "" {
action = "updated"
}

title := e.Title
title := ansi.Strip(e.Title)
if title == "" {
title = e.SummaryExcerpt
title = ansi.Strip(e.SummaryExcerpt)
}
// Truncate at rune boundary for proper Unicode handling
if len([]rune(title)) > 40 {
Expand Down Expand Up @@ -507,7 +515,7 @@ func runTimelineWatch(cmd *cobra.Command, args []string, project, person string,
if err != nil {
return output.ErrUsage("Invalid person ID")
}
description = fmt.Sprintf("activity for %s", personName)
description = fmt.Sprintf("activity for %s", ansi.Strip(personName))
fetchFn = func(ctx context.Context) ([]basecamp.TimelineEvent, error) {
result, err := app.Account().Timeline().PersonProgress(ctx, personID, opts)
if err != nil {
Expand All @@ -525,7 +533,7 @@ func runTimelineWatch(cmd *cobra.Command, args []string, project, person string,
if err != nil {
return output.ErrUsage("Invalid project ID")
}
description = fmt.Sprintf("activity in %s", projectName)
description = fmt.Sprintf("activity in %s", ansi.Strip(projectName))
fetchFn = func(ctx context.Context) ([]basecamp.TimelineEvent, error) {
r, err := app.Account().Timeline().ProjectTimeline(ctx, projectIDInt, opts)
if err != nil {
Expand Down
23 changes: 16 additions & 7 deletions internal/output/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"strings"

"github.com/charmbracelet/x/ansi"
"github.com/itchyny/gojq"

clioutput "github.com/basecamp/cli/output"
Expand Down Expand Up @@ -421,22 +422,26 @@ func (w *Writer) writeLiteralMarkdown(v any) error {
type ResponseOption func(*Response)

// WithSummary adds a summary to the response.
// Summaries frequently interpolate API-controlled strings (project/person/
// entity names), so ANSI/OSC escape sequences are stripped at the source to
// prevent terminal injection in every styled/markdown sink.
func WithSummary(s string) ResponseOption {
return func(r *Response) { r.Summary = s }
return func(r *Response) { r.Summary = ansi.Strip(s) }
}

// WithNotice adds an informational notice to the response.
// Use this for non-error messages like truncation warnings.
// Like WithSummary, the value is ANSI-stripped at the source.
func WithNotice(s string) ResponseOption {
return func(r *Response) { r.Notice = s; r.noticeDiagnostic = false }
return func(r *Response) { r.Notice = ansi.Strip(s); r.noticeDiagnostic = false }
}

// WithDiagnostic sets a notice that is also emitted to stderr in quiet mode.
// Use this for degraded-operation warnings (e.g. unresolved mentions) that
// automation consumers need to detect. Truncation and other informational
// notices should use WithNotice instead.
func WithDiagnostic(s string) ResponseOption {
return func(r *Response) { r.Notice = s; r.noticeDiagnostic = true }
return func(r *Response) { r.Notice = ansi.Strip(s); r.noticeDiagnostic = true }
}

// WithBreadcrumbs adds breadcrumbs to the response.
Expand Down Expand Up @@ -530,13 +535,16 @@ func (w *Writer) presentStyledEntity(resp *Response) bool {
var out strings.Builder
r := NewRenderer(w.opts.Writer, true)

// ansi.Strip defends against terminal injection from API-controlled
// summary/notice content (already stripped at the WithSummary/WithNotice
// source; repeated here as defense-in-depth at the render sink).
if resp.Summary != "" {
out.WriteString(r.Summary.Render(resp.Summary))
out.WriteString(r.Summary.Render(ansi.Strip(resp.Summary)))
out.WriteString("\n")
}

if resp.Notice != "" {
out.WriteString(r.Hint.Render(resp.Notice))
out.WriteString(r.Hint.Render(ansi.Strip(resp.Notice)))
out.WriteString("\n")
}

Expand Down Expand Up @@ -589,12 +597,13 @@ func (w *Writer) presentMarkdownEntity(resp *Response) bool {
var out strings.Builder
mr := NewMarkdownRenderer(w.opts.Writer)

// Defense-in-depth ANSI stripping (see presentStyledEntity).
if resp.Summary != "" {
out.WriteString("## " + resp.Summary + "\n")
out.WriteString("## " + ansi.Strip(resp.Summary) + "\n")
}

if resp.Notice != "" {
out.WriteString("*" + resp.Notice + "*\n")
out.WriteString("*" + ansi.Strip(resp.Notice) + "*\n")
}

if resp.Summary != "" || resp.Notice != "" {
Expand Down
31 changes: 31 additions & 0 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3565,3 +3565,34 @@ func TestPluralNoun(t *testing.T) {
assert.Equal(t, tt.expected, PluralNoun(tt.input), "PluralNoun(%q)", tt.input)
}
}

// TestWithSummaryStripsANSI verifies that API-controlled summary/notice content
// is sanitized of terminal escape sequences at the source, preventing terminal
// injection in every styled/markdown sink.
func TestWithSummaryStripsANSI(t *testing.T) {
// OSC 8 hyperlink + CSI color sequence wrapping a hostile payload.
payload := "\x1b]8;;http://evil\x07click\x1b]8;;\x07\x1b[31mpwn\x1b[0m"

t.Run("WithSummary", func(t *testing.T) {
r := &Response{}
WithSummary(payload)(r)
assert.Equal(t, ansi.Strip(payload), r.Summary)
assert.NotContains(t, r.Summary, "\x1b")
})

t.Run("WithNotice", func(t *testing.T) {
r := &Response{}
WithNotice(payload)(r)
assert.Equal(t, ansi.Strip(payload), r.Notice)
assert.NotContains(t, r.Notice, "\x1b")
assert.False(t, r.noticeDiagnostic)
})

t.Run("WithDiagnostic", func(t *testing.T) {
r := &Response{}
WithDiagnostic(payload)(r)
assert.Equal(t, ansi.Strip(payload), r.Notice)
assert.NotContains(t, r.Notice, "\x1b")
assert.True(t, r.noticeDiagnostic)
})
}
15 changes: 9 additions & 6 deletions internal/output/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,17 @@ func terminalInfo(w io.Writer) (width int, isTTY bool) {
func (r *Renderer) RenderResponse(w io.Writer, resp *Response) error {
var b strings.Builder

// Summary line
// Summary line. ansi.Strip guards against terminal injection from
// API-controlled summary/notice content (defense-in-depth: also stripped
// at the WithSummary/WithNotice source).
if resp.Summary != "" {
b.WriteString(r.Summary.Render(resp.Summary))
b.WriteString(r.Summary.Render(ansi.Strip(resp.Summary)))
b.WriteString("\n")
}

// Notice (e.g., truncation warning)
if resp.Notice != "" {
b.WriteString(r.Hint.Render(resp.Notice))
b.WriteString(r.Hint.Render(ansi.Strip(resp.Notice)))
b.WriteString("\n")
}

Expand Down Expand Up @@ -1116,14 +1118,15 @@ func NewMarkdownRenderer(w io.Writer) *MarkdownRenderer {
func (r *MarkdownRenderer) RenderResponse(w io.Writer, resp *Response) error {
var b strings.Builder

// Summary as heading
// Summary as heading. ansi.Strip guards against terminal injection
// (defense-in-depth; also stripped at the WithSummary/WithNotice source).
if resp.Summary != "" {
b.WriteString("## " + resp.Summary + "\n")
b.WriteString("## " + ansi.Strip(resp.Summary) + "\n")
}

// Notice (e.g., truncation warning)
if resp.Notice != "" {
b.WriteString("*" + resp.Notice + "*\n")
b.WriteString("*" + ansi.Strip(resp.Notice) + "*\n")
}

if resp.Summary != "" || resp.Notice != "" {
Expand Down
22 changes: 17 additions & 5 deletions internal/presenter/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"strings"
"time"

"github.com/charmbracelet/x/ansi"

"github.com/basecamp/basecamp-cli/internal/richtext"
)

Expand Down Expand Up @@ -61,7 +63,7 @@ func formatDate(val any, locale Locale) string {
if t, err := time.Parse("2006-01-02", str); err == nil {
return locale.FormatDate(t)
}
return str
return ansi.Strip(str)
}

// formatRelativeTime formats a timestamp as relative time (e.g. "2 hours ago").
Expand All @@ -77,7 +79,7 @@ func formatRelativeTime(val any, locale Locale) string {
// Try date-only
t, err = time.Parse("2006-01-02", str)
if err != nil {
return str
return ansi.Strip(str)
}
}

Expand Down Expand Up @@ -129,7 +131,9 @@ func formatPeople(val any) string {
for _, item := range arr {
if m, ok := item.(map[string]any); ok {
if name, ok := m["name"].(string); ok {
names = append(names, name)
if stripped := ansi.Strip(name); stripped != "" {
names = append(names, stripped)
}
}
}
}
Expand Down Expand Up @@ -168,7 +172,9 @@ func parseDockItems(val any) []dockItem {
result := make([]dockItem, len(items))
for i, m := range items {
title, _ := m["title"].(string)
title = ansi.Strip(title)
name, _ := m["name"].(string)
name = ansi.Strip(name)
if title == "" {
title = name
}
Expand Down Expand Up @@ -263,7 +269,7 @@ func dockPosition(m map[string]any) int {
func formatPerson(val any) string {
if m, ok := val.(map[string]any); ok {
if name, ok := m["name"].(string); ok {
return name
return ansi.Strip(name)
}
}
return ""
Expand Down Expand Up @@ -295,8 +301,14 @@ func formatText(val any) string {
case nil:
return ""
case string:
// Strip terminal escape sequences from API-controlled strings before
// they reach a styled/markdown sink (terminal injection defense).
v = ansi.Strip(v)
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
if richtext.IsHTML(v) {
return richtext.HTMLToMarkdown(v)
// Defense-in-depth: strip the generated markdown too before it can
// reach a styled/markdown sink. The input is already stripped above
// and HTMLToMarkdown never emits ESC, so this is belt-and-suspenders.
return ansi.Strip(richtext.HTMLToMarkdown(v))
}
return v
case bool:
Expand Down
80 changes: 80 additions & 0 deletions internal/presenter/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package presenter

import (
"encoding/json"
"strings"
"testing"
)

Expand Down Expand Up @@ -133,3 +134,82 @@ func TestFormatDockTitleFallsBackToName(t *testing.T) {
t.Errorf("formatDock(title fallback) = %q, want %q", got, want)
}
}

func TestFormatDockStripsANSIFromTitleAndName(t *testing.T) {
dock := []any{
map[string]any{
"title": "\x1b]8;;http://evil.example\x07To-dos\x1b]8;;\x07",
"name": "\x1b[31mtodoset\x1b[0m",
"enabled": true,
"id": float64(1),
},
}

got := formatDock(dock)
want := "1 To-dos (todoset)"
if got != want {
t.Errorf("formatDock(ANSI in title/name) = %q, want %q", got, want)
}
}

func TestFormatDockTitleFallbackStripsANSIFromName(t *testing.T) {
dock := []any{
map[string]any{
"name": "\x1b[31mtodoset\x1b[0m",
"enabled": true,
"id": float64(1),
},
}

got := formatDock(dock)
want := "1 todoset"
if got != want {
t.Errorf("formatDock(ANSI in name fallback) = %q, want %q", got, want)
}
}

func TestFormatPeopleStripsANSI(t *testing.T) {
people := []any{
map[string]any{"name": "\x1b[31mAlice\x1b[0m"},
map[string]any{"name": "Bob"},
}

got := formatPeople(people)
want := "Alice, Bob"
if got != want {
t.Errorf("formatPeople(ANSI names) = %q, want %q", got, want)
}
}

func TestFormatDateStripsANSIFromUnparseableInput(t *testing.T) {
locale := NewLocale("")
for _, in := range []string{"not-a-date\x1b[31mX", "\x1b]0;pwn\x07bad"} {
got := formatDate(in, locale)
if strings.ContainsRune(got, 0x1b) {
t.Errorf("formatDate(%q) = %q, want no escape byte", in, got)
}
}
}

func TestFormatRelativeTimeStripsANSIFromUnparseableInput(t *testing.T) {
locale := NewLocale("")
for _, in := range []string{"not-a-date\x1b[31mX", "\x1b]0;pwn\x07bad"} {
got := formatRelativeTime(in, locale)
if strings.ContainsRune(got, 0x1b) {
t.Errorf("formatRelativeTime(%q) = %q, want no escape byte", in, got)
}
}
}

func TestFormatPeopleDropsAllEscapeName(t *testing.T) {
people := []any{
map[string]any{"name": "Alice"},
map[string]any{"name": "\x1b[0m\x1b]8;;\x07"},
}

got := formatPeople(people)
want := "Alice"
if got != want {
t.Errorf("formatPeople(all-escape name dropped) = %q, want %q", got, want)
}
}
Loading