Repository: creativeprojects/resticprofile (language: python)

Issue:
HTTP hook Authorization header value is replaced in post requests
Hello
Authorization header value is replaced by value `×××` in POST requests when using HTTP hooks

here is my config
```
    [test.backup.send-finally]
      method = "POST"
      url = "https://someurl/api/v4/posts"
      body-template = "mattermost_body_template.json"

    [[test.backup.send-finally.headers]]
      name = "Content-Type"
      value = "application/json"

    [[test.backup.send-finally.headers]]
      name = "Authorization"
      value = "Bearer ****"
```
Is there a specific way to declare an authorization header to avoid this issue?


Discussion / hints:
Can confirm:

Here is my config:

```toml
[notify-me.backup]
[[notify-me.backup.send-before]]
method = "POST"
url = "https://redacted"
body-template = "ntfy-backup-started.json"
[[notify-me.backup.send-before.headers]]
name = "Authorization"
value = "Bearer secret_token"
[[notify-me.backup.send-before.headers]]
name = "Content-Type"
value = "application/json"
```

Here is resticprofile logs:

```
2024/02/22 16:47:31 starting "send-before" from backup 1/1
2024/02/22 16:47:31 calling: POST "https://redacted"
Authorization:  ×××
Content-Type:   application/json
User-Agent:     resticprofile/0.26.0

2024/02/22 16:47:31 request body:
{
    "topic": "backup",
    "message": "Profile: caddy\nCommand: backup",
    "title": "Backup started",
    "tags": ["caddy"],
    "priority": 1
}

2024/02/22 16:47:31 "https://redacted" returned: 403 Forbidden
Alt-Svc:                      h3=":443"; ma=2592000
Content-Type:                 application/json
Date:                         Thu, 22 Feb 2024 13:47:31 GMT
Server:                       Caddy
Content-Length:               100
Access-Control-Allow-Origin:  *

2024/02/22 16:47:31 "send-before" returned an error: HTTP 403 Forbidden
```

Here is reverse proxy log:

```json
{"level":"error","ts":1708609395.1271415,"logger":"http.log.access.log1","msg":"handled request","request":{"remote_ip":"redacted","remote_port":"45486","client_ip":"redacted","proto":"HTTP/2.0","method":"POST","host":"redacted","uri":"/","headers":{"Content-Type":["application/json"],"User-Agent":["resticprofile/0.26.0"],"Content-Length":["149"],"Accept-Encoding":["gzip"],"Authorization":[]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"redacted"}},"bytes_read":149,"user_id":"","duration":0.004734385,"size":100,"status":403,"resp_headers":{"Server":["Caddy"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"Content-Type":["application/json"],"Date":["Thu, 22 Feb 2024 13:43:15 GMT"],"Content-Length":["100"],"Access-Control-Allow-Origin":["*"]}}
```

Authorization header is empty in reverse proxy logs.
The value of the authorisation header won't be displayed in the logs, this is by design.

But it should be properly sent to the server, I'll have a look 🤔 

Has it stopped working recently, or did you just try?


> Has it stopped working recently, or did you just try?
> 

I just tried to setup integration with the ntfy service. 

I use bearer token for authentication. 

From command line (curl) request, I see Authorization header in reverse proxy logs, and it works as expected. 

From the resticprofile request, Authorization header is empty in logs, and I got 403. 
> I just tried to setup integration with the ntfy service.

Thanks for the tip, it looks like a pretty neat service actually, I'm going to give it a try 😆 

Anyway, yeah I found the bug 😢 
It won't take long to fix, sorry about that


Fix patch:
diff --git a/config/confidential.go b/config/confidential.go
index 7be43ecb..85cb556a 100644
--- a/config/confidential.go
+++ b/config/confidential.go
@@ -14,10 +14,13 @@ type ConfidentialValue struct {
 	public, confidential string
 }
 
+// Value returns the unmasked original value
 func (c ConfidentialValue) Value() string {
 	return c.confidential
 }
 
+// String returns the masked representation of a value, if confidential
+// It returns the original value if not confidential
 func (c ConfidentialValue) String() string {
 	return c.public
 }
diff --git a/monitor/hook/sender.go b/monitor/hook/sender.go
index 03867bb4..5cc27d0a 100644
--- a/monitor/hook/sender.go
+++ b/monitor/hook/sender.go
@@ -21,6 +21,11 @@ import (
 	"github.com/creativeprojects/resticprofile/util/templates"
 )
 
+const (
+	userAgentKey     = "User-Agent"
+	defaultUserAgent = "resticprofile/1.0"
+)
+
 type Sender struct {
 	client         *http.Client
 	insecureClient *http.Client
@@ -30,7 +35,7 @@ type Sender struct {
 
 func NewSender(certificates []string, userAgent string, timeout time.Duration, dryRun bool) *Sender {
 	if userAgent == "" {
-		userAgent = "resticprofile/1.0"
+		userAgent = defaultUserAgent
 	}
 
 	// normal client
@@ -149,24 +154,27 @@ func (s *Sender) logResponse(url string, resp *http.Response) {
 func (s *Sender) stringifyHeaders(headers http.Header, config []config.SendMonitoringHeader) string {
 	buf := &strings.Builder{}
 	w := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0)
-	for name, values := range headers {
+	for name, sendValues := range headers {
 		// Translate values to confidential replacement
-		for i, value := range values {
+		// copy values to a new slice otherwise the original slice will be modified with masked values
+		maskedValues := make([]string, len(sendValues))
+		for i, value := range sendValues {
+			maskedValues[i] = value
 			for _, ch := range config {
 				if ch.Value.IsConfidential() && ch.Value.Value() == value {
-					values[i] = ch.Value.String()
+					maskedValues[i] = ch.Value.String()
+					continue
 				}
 			}
 		}
 		// Print header
-		_, _ = fmt.Fprintf(w, "%s:\t%s\n", name, strings.Join(values, "; "))
+		_, _ = fmt.Fprintf(w, "%s:\t%s\n", name, strings.Join(maskedValues, "; "))
 	}
 	_ = w.Flush()
 	return buf.String()
 }
 
 func (s *Sender) setUserAgent(req *http.Request) {
-	userAgentKey := "User-Agent"
 	if req.Header.Get(userAgentKey) == "" {
 		req.Header.Add(userAgentKey, s.userAgent)
 	}


Test patch:
diff --git a/monitor/hook/sender_test.go b/monitor/hook/sender_test.go
index 1807ba56..1f6480b2 100644
--- a/monitor/hook/sender_test.go
+++ b/monitor/hook/sender_test.go
@@ -215,9 +215,10 @@ func TestFailedRequest(t *testing.T) {
 
 func TestUserAgent(t *testing.T) {
 	calls := 0
+	agentHeader := "User-Agent"
 	testAgent := "test user agent/0.0"
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		assert.Equal(t, testAgent, r.Header.Get("User-Agent"))
+		assert.Equal(t, testAgent, r.Header.Get(agentHeader))
 		calls++
 	}))
 	defer server.Close()
@@ -226,13 +227,87 @@ func TestUserAgent(t *testing.T) {
 	err := sender.Send(config.SendMonitoringSection{
 		URL: config.NewConfidentialValue(server.URL),
 		Headers: []config.SendMonitoringHeader{
-			{Name: "User-Agent", Value: config.NewConfidentialValue(testAgent)},
+			{Name: agentHeader, Value: config.NewConfidentialValue(testAgent)},
 		},
 	}, Context{})
 	assert.NoError(t, err)
 	assert.Equal(t, 1, calls)
 }
 
+func TestConfidentialURL(t *testing.T) {
+	clog.SetTestLog(t)
+	defer clog.CloseTestLog()
+
+	calls := 0
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		user, password, ok := r.BasicAuth()
+		assert.True(t, ok)
+		assert.Equal(t, "user", user)
+		assert.Equal(t, "password", password)
+		calls++
+	}))
+	defer server.Close()
+
+	serverURL := strings.Replace(server.URL, "http://", "http://user:password@", 1)
+
+	profile := &config.Profile{
+		Backup: &config.BackupSection{
+			SectionWithScheduleAndMonitoring: config.SectionWithScheduleAndMonitoring{
+				SendMonitoringSections: config.SendMonitoringSections{
+					SendBefore: []config.SendMonitoringSection{
+						{
+							URL: config.NewConfidentialValue(serverURL),
+						},
+					},
+				},
+			},
+		},
+	}
+	config.ProcessConfidentialValues(profile)
+
+	sender := NewSender(nil, "", 300*time.Millisecond, false)
+	err := sender.Send(profile.Backup.SendBefore[0], Context{})
+	require.NoError(t, err)
+	assert.Equal(t, 1, calls)
+}
+
+func TestConfidentialHeader(t *testing.T) {
+	clog.SetTestLog(t)
+	defer clog.CloseTestLog()
+
+	calls := 0
+	headerKey := "Authorization"
+	headerValue := "Bearer secret_token"
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, headerValue, r.Header.Get(headerKey))
+		calls++
+	}))
+	defer server.Close()
+
+	profile := &config.Profile{
+		Backup: &config.BackupSection{
+			SectionWithScheduleAndMonitoring: config.SectionWithScheduleAndMonitoring{
+				SendMonitoringSections: config.SendMonitoringSections{
+					SendBefore: []config.SendMonitoringSection{
+						{
+							URL: config.NewConfidentialValue(server.URL),
+							Headers: []config.SendMonitoringHeader{
+								{Name: headerKey, Value: config.NewConfidentialValue(headerValue)},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	config.ProcessConfidentialValues(profile)
+
+	sender := NewSender(nil, "", 300*time.Millisecond, false)
+	err := sender.Send(profile.Backup.SendBefore[0], Context{})
+	require.NoError(t, err)
+	assert.Equal(t, 1, calls)
+}
+
 func TestParseTemplate(t *testing.T) {
 	ctx := Context{
 		ProfileName: "test_profile",