fix(bonfire): replace invalid UTF-8 before the control scan in Sanitize (IN-08, quick-261028-rqd)

This commit is contained in:
Jakub Zych
2026-10-28 20:21:19 +01:00
parent 825a3fd2da
commit 8476a65aa0
3 changed files with 26 additions and 7 deletions

View File

@@ -140,7 +140,7 @@ if catalog.Has("blog:import") {
| `bonfire.WithHint` | Wraps an error in a `bonfire.HintError`; `{bin}` in the Try text becomes `bin/<name>` or `summer`. |
| `bonfire.ErrReported` | Marks an error whose details the command already printed. |
| `bonfire.Reported` | Wraps an error so it matches `bonfire.ErrReported`: exit code 1, or the code of `bonfire.WithExitCode`, and no error block. |
| `bonfire.Sanitize` | Removes control characters other than tab (line breaks become spaces), so untrusted text cannot move the cursor or retitle the terminal; error blocks, process views, failure lines, tables and diary's console use it. |
| `bonfire.Sanitize` | Replaces invalid UTF-8 with U+FFFD and removes control characters other than tab (line breaks become spaces), so untrusted text, including a raw 8-bit C1 byte, cannot move the cursor or retitle the terminal; error blocks, process views, failure lines, tables and diary's console use it. |
| `bonfire.WithExitCode` | Makes an error carry the exit code `bonfire.Execute` returns for it (1 to 255; other codes give 1). |
| `bonfire.Listing` | The JSON catalog of `list --format=json`: `Application` (`bonfire.ApplicationInfo`), `GlobalOptions` and `Commands`. |
| `bonfire.ApplicationInfo` | Name, version and framework version of a binary in a `bonfire.Listing`. |

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
"git.golem15.com/golem15/summercms/internal/argmask"
"git.golem15.com/golem15/summercms/modules/sunscreen"
@@ -102,18 +103,23 @@ func Reported(err error) error {
// text, names an administrator typed) then cannot move the cursor, clear
// the screen or set the window title. Error blocks, process views, failure
// lines and tables pass their text through it, and so does diary's console
// channel. A clean string is returned unchanged.
// channel. Invalid UTF-8 becomes U+FFFD first, so a raw 8-bit C1 byte (such
// as 0x9b, CSI) cannot reach a terminal that honours it, and the result is
// always valid UTF-8. A clean, valid string is returned unchanged.
func Sanitize(s string) string {
clean := true
for _, r := range s {
if r != '\t' && unicode.IsControl(r) {
clean = false
break
clean := utf8.ValidString(s)
if clean {
for _, r := range s {
if r != '\t' && unicode.IsControl(r) {
clean = false
break
}
}
}
if clean {
return s
}
s = strings.ToValidUTF8(s, "\uFFFD")
var b strings.Builder
for _, r := range s {
switch {

View File

@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
"unicode/utf8"
)
// TestExitCodes: Execute returns 0 on success, 1 on a failure and 2 on an
@@ -323,9 +324,21 @@ func TestSanitize(t *testing.T) {
"a\x1b]0;title\x07b": "a]0;titleb",
"nul\x00 and del\x7f and \x01": "nul and del and ",
"boom\x1b[2Jclear": "boom[2Jclear",
// IN-08: invalid UTF-8 becomes U+FFFD, so a raw 8-bit C1 byte
// such as 0x9b (CSI) never reaches the terminal.
"a\x9b31mb": "a\uFFFD31mb",
"\x9b": "\uFFFD",
"\xffok": "\uFFFDok",
} {
if got := Sanitize(in); got != want {
t.Errorf("Sanitize(%q) = %q, want %q", in, got, want)
}
if got := Sanitize(in); !utf8.ValidString(got) {
t.Errorf("Sanitize(%q) = %q is not valid UTF-8", in, got)
}
}
clean := "plain ok"
if got := Sanitize(clean); got != clean {
t.Errorf("Sanitize(%q) = %q, want it unchanged", clean, got)
}
}