From d6fdab1c0951b2302a95a686e79c17da532b2a3d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Jul 2025 14:31:56 +0100 Subject: [PATCH] fix: sanitize app status summary --- coderd/util/strings/strings.go | 40 +++++++++++++++++++++++++++++ coderd/util/strings/strings_test.go | 39 ++++++++++++++++++++++++++++ coderd/workspaceagents.go | 6 ++++- codersdk/toolsdk/toolsdk.go | 2 +- go.mod | 2 +- 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/coderd/util/strings/strings.go b/coderd/util/strings/strings.go index f416bba463bbf..49aad579e83f5 100644 --- a/coderd/util/strings/strings.go +++ b/coderd/util/strings/strings.go @@ -2,7 +2,12 @@ package strings import ( "fmt" + "strconv" "strings" + "unicode" + + "github.com/acarl005/stripansi" + "github.com/microcosm-cc/bluemonday" ) // JoinWithConjunction joins a slice of strings with commas except for the last @@ -28,3 +33,38 @@ func Truncate(s string, n int) string { } return s[:n] } + +var bmPolicy = bluemonday.StrictPolicy() + +// UISanitize sanitizes a string for display in the UI. +// The following transformations are applied, in order: +// - HTML tags are removed using bluemonday's strict policy. +// - ANSI escape codes are stripped using stripansi. +// - Consecutive backslashes are replaced with a single backslash. +// - Non-printable characters are removed. +// - Whitespace characters are replaced with spaces. +// - Multiple spaces are collapsed into a single space. +// - Leading and trailing whitespace is trimmed. +func UISanitize(in string) string { + if unq, err := strconv.Unquote(`"` + in + `"`); err == nil { + in = unq + } + in = bmPolicy.Sanitize(in) + in = stripansi.Strip(in) + var b strings.Builder + var spaceSeen bool + for _, r := range in { + if unicode.IsSpace(r) { + if !spaceSeen { + _, _ = b.WriteRune(' ') + spaceSeen = true + } + continue + } + spaceSeen = false + if unicode.IsPrint(r) { + _, _ = b.WriteRune(r) + } + } + return strings.TrimSpace(b.String()) +} diff --git a/coderd/util/strings/strings_test.go b/coderd/util/strings/strings_test.go index 5172fb08e1e69..7a20a06a25f28 100644 --- a/coderd/util/strings/strings_test.go +++ b/coderd/util/strings/strings_test.go @@ -3,6 +3,7 @@ package strings_test import ( "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/util/strings" @@ -37,3 +38,41 @@ func TestTruncate(t *testing.T) { }) } } + +func TestUISanitize(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + s string + expected string + }{ + {"normal text", "normal text"}, + {"\tfoo \r\\nbar ", "foo bar"}, + {"通常のテキスト", "通常のテキスト"}, + {"foo\nbar", "foo bar"}, + {"foo\tbar", "foo bar"}, + {"foo\rbar", "foo bar"}, + {"foo\x00bar", "foobar"}, + {"\u202Eabc", "abc"}, + {"\u200Bzero width", "zero width"}, + {"foo\x1b[31mred\x1b[0mbar", "fooredbar"}, + {"foo\u0008bar", "foobar"}, + {"foo\x07bar", "foobar"}, + {"foo\uFEFFbar", "foobar"}, + {"link", "link"}, + {"", ""}, + {"HTML", "HTML"}, + {"
line break", "line break"}, + {"", ""}, + {"", ""}, + {"visible", "visible"}, + {"", ""}, + {"", ""}, + } { + t.Run(tt.expected, func(t *testing.T) { + t.Parallel() + actual := strings.UISanitize(tt.s) + assert.Equal(t, tt.expected, actual) + }) + } +} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 3ae57d8394d43..d600eff6ecfec 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -41,6 +41,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/telemetry" maputil "github.com/coder/coder/v2/coderd/util/maps" + strutil "github.com/coder/coder/v2/coderd/util/strings" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -383,6 +384,9 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req return } + // Treat the message as untrusted input. + cleaned := strutil.UISanitize(req.Message) + // nolint:gocritic // This is a system restricted operation. _, err = api.Database.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ ID: uuid.New(), @@ -391,7 +395,7 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req AgentID: workspaceAgent.ID, AppID: app.ID, State: database.WorkspaceAppStatusState(req.State), - Message: req.Message, + Message: cleaned, Uri: sql.NullString{ String: req.URI, Valid: req.URI != "", diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 862d0c34a5316..c6c37821e5234 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -229,7 +229,7 @@ ONLY report an "idle" or "failure" state if you have FULLY completed the task. Properties: map[string]any{ "summary": map[string]any{ "type": "string", - "description": "A concise summary of your current progress on the task. This must be less than 160 characters in length.", + "description": "A concise summary of your current progress on the task. This must be less than 160 characters in length and must not include newlines or other control characters.", }, "link": map[string]any{ "type": "string", diff --git a/go.mod b/go.mod index 8e48f67f65885..fcd76e07987e2 100644 --- a/go.mod +++ b/go.mod @@ -365,7 +365,7 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect github.com/mdlayher/socket v0.5.0 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 github.com/miekg/dns v1.1.57 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy