mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-15 19:29:08 +02:00
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.
212 lines
6.2 KiB
Go
212 lines
6.2 KiB
Go
package debug
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
neturl "net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/netbirdio/netbird/upload-server/types"
|
|
)
|
|
|
|
const maxBundleUploadSize = 50 * 1024 * 1024
|
|
|
|
// requireHTTPS refuses any URL the daemon would fetch or upload to that is not
|
|
// https. The daemon runs as root and the bundle carries its logs and state, so a
|
|
// plaintext hop is a place to intercept the bundle or the presigned redirect.
|
|
// The server-side gate already enforces this for the desktop path; this also
|
|
// covers the mobile and job-runner callers that reach this package directly.
|
|
// Skipped when the caller opted into an insecure upload (self-hosted server).
|
|
func requireHTTPS(what, rawURL string) error {
|
|
parsed, err := neturl.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("parse %s: %w", what, err)
|
|
}
|
|
if parsed.Scheme != "https" {
|
|
return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// uploadClient returns the HTTP client for the upload requests. The default
|
|
// client verifies TLS and refuses a redirect that would downgrade to a non-https
|
|
// hop, so a bundle can never leave over http after an https start. The insecure
|
|
// variant accepts http and untrusted certificates, and is only reachable for a
|
|
// privileged caller that passed --upload-bundle-insecure (see
|
|
// requirePrivilegeForUploadURL).
|
|
func uploadClient(insecure bool) *http.Client {
|
|
if !insecure {
|
|
return &http.Client{CheckRedirect: rejectInsecureRedirect}
|
|
}
|
|
return &http.Client{
|
|
Transport: &http.Transport{
|
|
//nolint:gosec // opt-in, privileged, self-hosted upload servers
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
|
|
},
|
|
}
|
|
}
|
|
|
|
// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the
|
|
// standard library's 10-hop limit that a custom CheckRedirect would otherwise
|
|
// disable.
|
|
func rejectInsecureRedirect(req *http.Request, via []*http.Request) error {
|
|
if req.URL.Scheme != "https" {
|
|
return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted())
|
|
}
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("stopped after 10 redirects")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) {
|
|
// Every error out of here is surfaced somewhere durable: the daemon log, the
|
|
// CLI, and — for a remote job — the management server's job record and the
|
|
// dashboard. Go's *url.Error prints the URL whole, and the presigned URL the
|
|
// service hands back can carry credentials in its query, so nothing leaves
|
|
// this function with a URL longer than scheme://host.
|
|
defer func() { err = redactURLsInError(err) }()
|
|
|
|
if !insecure {
|
|
if err := requireHTTPS("upload service URL", url); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
response, err := getUploadURL(ctx, url, managementURL, insecure)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !insecure {
|
|
if err := requireHTTPS("upload URL from service", response.URL); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
err = upload(ctx, filePath, response, insecure)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return response.Key, nil
|
|
}
|
|
|
|
func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error {
|
|
fileData, err := os.Open(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("open file: %w", err)
|
|
}
|
|
|
|
defer fileData.Close()
|
|
|
|
stat, err := fileData.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("stat file: %w", err)
|
|
}
|
|
|
|
if stat.Size() > maxBundleUploadSize {
|
|
return fmt.Errorf("file size exceeds maximum limit of %d bytes", maxBundleUploadSize)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "PUT", response.URL, fileData)
|
|
if err != nil {
|
|
return fmt.Errorf("create PUT request: %w", err)
|
|
}
|
|
|
|
req.ContentLength = stat.Size()
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
|
|
putResp, err := uploadClient(insecure).Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("upload failed: %v", err)
|
|
}
|
|
defer putResp.Body.Close()
|
|
|
|
if putResp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(putResp.Body)
|
|
return fmt.Errorf("upload status %d: %s", putResp.StatusCode, string(body))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getUploadURL(ctx context.Context, serviceURL string, managementURL string, insecure bool) (*types.GetURLResponse, error) {
|
|
parsed, err := neturl.Parse(serviceURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse upload service URL: %w", err)
|
|
}
|
|
q := parsed.Query()
|
|
q.Set("id", getURLHash(managementURL))
|
|
parsed.RawQuery = q.Encode()
|
|
|
|
getReq, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create GET request: %w", err)
|
|
}
|
|
|
|
getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue)
|
|
|
|
resp, err := uploadClient(insecure).Do(getReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get presigned URL: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("get presigned URL status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
urlBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response body: %w", err)
|
|
}
|
|
var response types.GetURLResponse
|
|
if err := json.Unmarshal(urlBytes, &response); err != nil {
|
|
return nil, fmt.Errorf("unmarshal response: %w", err)
|
|
}
|
|
return &response, nil
|
|
}
|
|
|
|
func getURLHash(url string) string {
|
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(url)))
|
|
}
|
|
|
|
// urlInText matches an absolute http(s) URL inside a free-form message.
|
|
var urlInText = regexp.MustCompile(`https?://[^\s"']+`)
|
|
|
|
// redactedError keeps the original error reachable for errors.Is/As while
|
|
// presenting a message with every URL cut down to scheme://host.
|
|
type redactedError struct {
|
|
msg string
|
|
err error
|
|
}
|
|
|
|
func (e *redactedError) Error() string { return e.msg }
|
|
func (e *redactedError) Unwrap() error { return e.err }
|
|
|
|
func redactURLsInError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
msg := err.Error()
|
|
redacted := urlInText.ReplaceAllStringFunc(msg, func(raw string) string {
|
|
parsed, perr := neturl.Parse(strings.TrimRight(raw, `.,;:)]}"'`))
|
|
if perr != nil || parsed.Host == "" {
|
|
return "(redacted URL)"
|
|
}
|
|
return parsed.Scheme + "://" + parsed.Host
|
|
})
|
|
if redacted == msg {
|
|
return err
|
|
}
|
|
return &redactedError{msg: redacted, err: err}
|
|
}
|