[client] Strip URLs out of debug bundle upload errors

Redacting the URL in the log line left the error itself untouched, and Go's
*url.Error prints the URL whole. That error does not stay local: it becomes
UploadFailureReason for the CLI and the desktop UI, and for a remote job it is
stored in the management server's job record and shown in the dashboard. A
sample from a failed job:

    Client error: 'upload debug bundle: get presigned URL: Get
    "https://helloworld.asda1234:2356?id=eb23d149..."'

The service URL can carry userinfo or a query token, and the presigned URL the
service hands back carries credentials in its query by design, so the second
step leaks more than the first.

UploadDebugBundle now rewrites every URL in its error down to scheme://host, on
the way out, which covers the daemon, both mobile SDKs and the job runner at
once. The original error stays reachable through Unwrap.

Reported by cubic on #7514.
This commit is contained in:
riccardom
2026-09-14 11:34:11 +02:00
parent a8817cab0b
commit 9e9f5f304c
2 changed files with 113 additions and 0 deletions
@@ -0,0 +1,72 @@
package debug
import (
"errors"
"fmt"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRedactURLsInError(t *testing.T) {
sentinel := errors.New("boom")
tests := []struct {
name string
err error
want string
}{
{name: "nil stays nil"},
{
name: "no URL is left alone",
err: errors.New("file too large"),
want: "file too large",
},
{
// What a failed GET actually looks like: *url.Error prints the URL
// whole, query included.
name: "service URL loses its query",
err: fmt.Errorf("get presigned URL: %w", &url.Error{
Op: "Get",
URL: "https://upload.example.com/upload-url?id=deadbeef",
Err: errors.New("no such host"),
}),
want: `get presigned URL: Get "https://upload.example.com": no such host`,
},
{
// The presigned PUT URL is the one that carries credentials.
name: "presigned URL loses its credentials",
err: errors.New(`upload failed: Put "https://bucket.s3.amazonaws.com/k?X-Amz-Signature=abc123&X-Amz-Credential=AKIA": timeout`),
want: `upload failed: Put "https://bucket.s3.amazonaws.com": timeout`,
},
{
name: "userinfo does not survive",
err: errors.New(`Get "https://user:hunter2@upload.example.com/upload-url": refused`),
want: `Get "https://upload.example.com": refused`,
},
{
name: "two URLs are both cut",
err: errors.New(`redirect from https://a.example.com/x?t=1 to https://b.example.com/y?t=2`),
want: `redirect from https://a.example.com to https://b.example.com`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := redactURLsInError(tc.err)
if tc.err == nil {
assert.NoError(t, got)
return
}
require.Error(t, got)
assert.Equal(t, tc.want, got.Error())
})
}
t.Run("the original error stays reachable", func(t *testing.T) {
wrapped := fmt.Errorf(`Get "https://upload.example.com/x?t=1": %w`, sentinel)
assert.ErrorIs(t, redactURLsInError(wrapped), sentinel)
})
}