fix: sanitize workspace agent logs before insert (#24028)

Workspace agent logs could still fail after the earlier invalid UTF-8
fix because NUL bytes are valid Go/protobuf strings but are rejected by
Postgres text columns. The legacy HTTP log upload path also bypassed the
old sanitization entirely, and both server insert paths computed
logs_length from the unsanitized input.

Add a shared log-output sanitizer in agentsdk, use it in the protobuf
conversion path and both server-side insert paths, and compute
OutputLength from the sanitized string so overflow accounting matches
what is actually stored. This keeps the old invalid UTF-8 behavior while
also handling embedded NUL bytes consistently across DRPC and HTTP log
ingestion.

Refs [#23292 ](https://github.com/coder/coder/issues/23292)
Refs [#13433 ](https://github.com/coder/coder/issues/13433)
This commit is contained in:
dylanhuff-at-coder
2026-04-08 16:29:38 -07:00
committed by GitHub
parent 7caef4987f
commit f4240bb8c1
8 changed files with 169 additions and 11 deletions
+1 -1
View File
@@ -376,7 +376,7 @@ func ProtoFromLog(log Log) (*proto.Log, error) {
}
return &proto.Log{
CreatedAt: timestamppb.New(log.CreatedAt),
Output: strings.ToValidUTF8(log.Output, "❌"),
Output: SanitizeLogOutput(log.Output),
Level: proto.Log_Level(lvl),
}, nil
}
+6 -6
View File
@@ -229,7 +229,7 @@ func TestLogSender_SkipHugeLog(t *testing.T) {
require.ErrorIs(t, err, context.Canceled)
}
func TestLogSender_InvalidUTF8(t *testing.T) {
func TestLogSender_SanitizeOutput(t *testing.T) {
t.Parallel()
testCtx := testutil.Context(t, testutil.WaitShort)
ctx, cancel := context.WithCancel(testCtx)
@@ -243,7 +243,7 @@ func TestLogSender_InvalidUTF8(t *testing.T) {
uut.Enqueue(ls1,
Log{
CreatedAt: t0,
Output: "test log 0, src 1\xc3\x28",
Output: "test log 0, src 1\x00\xc3\x28",
Level: codersdk.LogLevelInfo,
},
Log{
@@ -260,10 +260,10 @@ func TestLogSender_InvalidUTF8(t *testing.T) {
req := testutil.TryReceive(ctx, t, fDest.reqs)
require.NotNil(t, req)
require.Len(t, req.Logs, 2, "it should sanitize invalid UTF-8, but still send")
// the 0xc3, 0x28 is an invalid 2-byte sequence in UTF-8. The sanitizer replaces 0xc3 with ❌, and then
// interprets 0x28 as a 1-byte sequence "("
require.Equal(t, "test log 0, src 1❌(", req.Logs[0].GetOutput())
require.Len(t, req.Logs, 2, "it should sanitize invalid output, but still send")
// The sanitizer replaces the NUL byte and invalid UTF-8 with ❌ while
// preserving the valid "(" byte that follows 0xc3.
require.Equal(t, "test log 0, src 1❌❌(", req.Logs[0].GetOutput())
require.Equal(t, proto.Log_INFO, req.Logs[0].GetLevel())
require.Equal(t, "test log 1, src 1", req.Logs[1].GetOutput())
require.Equal(t, proto.Log_INFO, req.Logs[1].GetLevel())
+11
View File
@@ -0,0 +1,11 @@
package agentsdk
import "strings"
// SanitizeLogOutput replaces invalid UTF-8 and NUL characters in log output.
// Invalid UTF-8 cannot be transported in protobuf string fields, and PostgreSQL
// rejects NUL bytes in text columns.
func SanitizeLogOutput(s string) string {
s = strings.ToValidUTF8(s, "❌")
return strings.ReplaceAll(s, "\x00", "❌")
}
+48
View File
@@ -17,6 +17,54 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestSanitizeLogOutput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{
name: "valid",
in: "hello world",
want: "hello world",
},
{
name: "invalid utf8",
in: "test log\xc3\x28",
want: "test log❌(",
},
{
name: "nul byte",
in: "before\x00after",
want: "before❌after",
},
{
name: "invalid utf8 and nul byte",
in: "before\x00middle\xc3\x28after",
want: "before❌middle❌(after",
},
{
name: "nul byte at edges",
in: "\x00middle\x00",
want: "❌middle❌",
},
{
name: "invalid utf8 at edges",
in: "\xc3middle\xc3",
want: "❌middle❌",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, agentsdk.SanitizeLogOutput(tt.in))
})
}
}
func TestStartupLogsWriter_Write(t *testing.T) {
t.Parallel()