Skip to content

Commit 108bdd5

Browse files
committed
fix(logging): credentials in headers are leaked via log
Refs: #77
1 parent a873d96 commit 108bdd5

7 files changed

Lines changed: 249 additions & 20 deletions

File tree

pkg/configuration/new_fetcher.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ func NewFetcherFromConfiguration(configuration *pb.FetcherConfiguration,
2828
authorizer auth.Authorizer,
2929
) (fetch.Fetcher, error) {
3030
var fetcher fetch.Fetcher
31+
var loggedHeaderNames []string
3132
if configuration == nil {
3233
fetcher = fetch.DefaultFetcher
3334
} else {
35+
loggedHeaderNames = configuration.LoggedHeaderNames
3436
switch backend := configuration.Backend.(type) {
3537
case *pb.FetcherConfiguration_Http:
3638
roundTripper, err := bb_http.NewRoundTripperFromConfiguration(backend.Http.Client)
@@ -62,6 +64,7 @@ func NewFetcherFromConfiguration(configuration *pb.FetcherConfiguration,
6264
fetch.NewMetricsFetcher(
6365
fetch.NewLoggingFetcher(
6466
fetch.NewValidatingFetcher(fetcher),
67+
loggedHeaderNames,
6568
),
6669
clock.SystemClock,
6770
"fetch",

pkg/fetch/BUILD.bazel

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,13 @@ go_test(
7070
"@org_golang_google_protobuf//types/known/timestamppb",
7171
],
7272
)
73+
74+
go_test(
75+
name = "logging_fetcher_internal_test",
76+
srcs = ["logging_fetcher_test.go"],
77+
embed = [":fetch"],
78+
deps = [
79+
"@bazel_remote_apis//build/bazel/remote/asset/v1:remote_asset_go_proto",
80+
"@com_github_stretchr_testify//require",
81+
],
82+
)

pkg/fetch/http_fetcher.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/base64"
66
"encoding/hex"
77
"errors"
8+
"fmt"
89
"io"
910
"log"
1011
"net/http"
@@ -270,21 +271,29 @@ func getAuthHeaders(uris []string, qualifiers []*remoteasset.Qualifier) (*AuthHe
270271
}
271272
// If we have per URL headers, we need to go through and apply them after applying the global headers.
272273
for k, v := range perURLQualifiers {
273-
parts := strings.Split(k, ":")
274-
if len(parts) != 3 {
275-
return nil, status.Errorf(codes.InvalidArgument, "Invalid http_header_url qualifier: %s", k)
276-
}
277-
uriIdx, err := strconv.ParseInt(parts[1], 10, 64)
274+
uriIdx, header, err := parseHTTPHeaderURLQualifierName(k)
278275
if err != nil {
279-
return nil, status.Errorf(codes.InvalidArgument, "Invalid http_header_url qualifier: %s: Bad URL index: %v: %v", k, parts[1], err)
276+
return nil, status.Errorf(codes.InvalidArgument, "%s", err.Error())
280277
}
281-
if uriIdx < 0 || uriIdx >= int64(len(uris)) {
278+
if uriIdx < 0 || uriIdx >= len(uris) {
282279
return nil, status.Errorf(codes.InvalidArgument, "Invalid http_header_url qualifier: %s: URL index out of range: %v", k, uriIdx)
283280
}
284-
header := parts[2]
285281
ah.AddHeader(uris[uriIdx], header, v)
286-
287282
}
288283

289284
return &ah, nil
290285
}
286+
287+
// parseHTTPHeaderURLQualifierName parses the URI index and header name
288+
// encoded in a "http_header_url:<index>:<header>" qualifier name.
289+
func parseHTTPHeaderURLQualifierName(name string) (int, string, error) {
290+
parts := strings.SplitN(name, ":", 3)
291+
if len(parts) != 3 {
292+
return 0, "", fmt.Errorf("invalid http_header_url qualifier: %s", name)
293+
}
294+
uriIdx, err := strconv.ParseInt(parts[1], 10, 64)
295+
if err != nil {
296+
return 0, "", fmt.Errorf("invalid http_header_url qualifier: %s: bad URL index: %v: %w", name, parts[1], err)
297+
}
298+
return int(uriIdx), parts[2], nil
299+
}

pkg/fetch/logging_fetcher.go

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,40 @@ package fetch
22

33
import (
44
"context"
5+
"encoding/json"
6+
"fmt"
57
"log"
8+
"strings"
69

710
remoteasset "github.com/bazelbuild/remote-apis/build/bazel/remote/asset/v1"
811
"github.com/buildbarn/bb-remote-asset/pkg/qualifier"
912
"google.golang.org/grpc/status"
1013
)
1114

15+
const redactedValue = "<redacted>"
16+
1217
type loggingFetcher struct {
13-
fetcher Fetcher
18+
fetcher Fetcher
19+
loggedHeaderNames map[string]struct{}
1420
}
1521

16-
// NewLoggingFetcher creates a fetcher which logs requests and results
17-
func NewLoggingFetcher(fetcher Fetcher) Fetcher {
22+
// NewLoggingFetcher creates a fetcher which logs requests and results.
23+
//
24+
// Qualifier values that carry HTTP header contents which may contain secrets such as "Authorization" tokens.
25+
// Their values are redacted from the log unless the corresponding header name is present in loggedHeaderNames.
26+
func NewLoggingFetcher(fetcher Fetcher, loggedHeaderNames []string) Fetcher {
27+
names := make(map[string]struct{}, len(loggedHeaderNames))
28+
for _, name := range loggedHeaderNames {
29+
names[strings.ToLower(name)] = struct{}{}
30+
}
1831
return &loggingFetcher{
19-
fetcher: fetcher,
32+
fetcher: fetcher,
33+
loggedHeaderNames: names,
2034
}
2135
}
2236

2337
func (lf *loggingFetcher) FetchBlob(ctx context.Context, req *remoteasset.FetchBlobRequest) (*remoteasset.FetchBlobResponse, error) {
24-
log.Printf("Fetching Blob %s with qualifiers %s", req.Uris, req.Qualifiers)
38+
log.Printf("Fetching Blob %s with qualifiers %s", req.Uris, lf.redactQualifiers(req.Qualifiers))
2539
resp, err := lf.fetcher.FetchBlob(ctx, req)
2640
if err == nil {
2741
log.Printf("FetchBlob completed for %s with status code %d", req.Uris, resp.Status.GetCode())
@@ -32,7 +46,7 @@ func (lf *loggingFetcher) FetchBlob(ctx context.Context, req *remoteasset.FetchB
3246
}
3347

3448
func (lf *loggingFetcher) FetchDirectory(ctx context.Context, req *remoteasset.FetchDirectoryRequest) (*remoteasset.FetchDirectoryResponse, error) {
35-
log.Printf("Fetching Directory %s with qualifiers %s", req.Uris, req.Qualifiers)
49+
log.Printf("Fetching Directory %s with qualifiers %s", req.Uris, lf.redactQualifiers(req.Qualifiers))
3650
resp, err := lf.fetcher.FetchDirectory(ctx, req)
3751
if err == nil {
3852
log.Printf("FetchBlob completed for %s with status code %d", req.Uris, resp.Status.GetCode())
@@ -45,3 +59,63 @@ func (lf *loggingFetcher) FetchDirectory(ctx context.Context, req *remoteasset.F
4559
func (lf *loggingFetcher) CheckQualifiers(qualifiers qualifier.Set) qualifier.Set {
4660
return lf.fetcher.CheckQualifiers(qualifiers)
4761
}
62+
63+
func (lf *loggingFetcher) isHeaderNameLogged(headerName string) bool {
64+
_, ok := lf.loggedHeaderNames[strings.ToLower(headerName)]
65+
return ok
66+
}
67+
68+
// redactQualifiers formats qualifiers for logging,
69+
// redacting the values of any HTTP header qualifiers whose header name isn't whitelisted.
70+
func (lf *loggingFetcher) redactQualifiers(qualifiers []*remoteasset.Qualifier) string {
71+
parts := make([]string, 0, len(qualifiers))
72+
for _, q := range qualifiers {
73+
parts = append(parts, fmt.Sprintf("name:%q value:%q", q.Name, lf.redactQualifierValue(q)))
74+
}
75+
return "[" + strings.Join(parts, " ") + "]"
76+
}
77+
78+
func (lf *loggingFetcher) redactQualifierValue(q *remoteasset.Qualifier) string {
79+
switch {
80+
case q.Name == QualifierLegacyBazelHTTPHeaders:
81+
return lf.redactLegacyAuthHeaders(q.Value)
82+
case strings.HasPrefix(q.Name, QualifierHTTPHeaderURLPrefix):
83+
_, header, err := parseHTTPHeaderURLQualifierName(q.Name)
84+
if err != nil || !lf.isHeaderNameLogged(header) {
85+
return redactedValue
86+
}
87+
return q.Value
88+
case strings.HasPrefix(q.Name, QualifierHTTPHeaderPrefix):
89+
header := strings.TrimPrefix(q.Name, QualifierHTTPHeaderPrefix)
90+
if !lf.isHeaderNameLogged(header) {
91+
return redactedValue
92+
}
93+
return q.Value
94+
default:
95+
return q.Value
96+
}
97+
}
98+
99+
// redactLegacyAuthHeaders redacts the header values carried by a legacy
100+
// "bazel.auth_headers" qualifier, keeping only whitelisted header names.
101+
func (lf *loggingFetcher) redactLegacyAuthHeaders(value string) string {
102+
ah, err := NewAuthHeadersFromQualifier(value)
103+
if err != nil {
104+
return redactedValue
105+
}
106+
redacted := NewAuthHeaders()
107+
for uri, headers := range *ah {
108+
for header, v := range headers {
109+
if lf.isHeaderNameLogged(header) {
110+
redacted.AddHeader(uri, header, v)
111+
} else {
112+
redacted.AddHeader(uri, header, redactedValue)
113+
}
114+
}
115+
}
116+
b, err := json.Marshal(redacted)
117+
if err != nil {
118+
return redactedValue
119+
}
120+
return string(b)
121+
}

pkg/fetch/logging_fetcher_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package fetch
2+
3+
import (
4+
"testing"
5+
6+
remoteasset "github.com/bazelbuild/remote-apis/build/bazel/remote/asset/v1"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func newTestLoggingFetcher(loggedHeaderNames []string) *loggingFetcher {
11+
lf := NewLoggingFetcher(nil, loggedHeaderNames)
12+
return lf.(*loggingFetcher)
13+
}
14+
15+
func TestIsHeaderNameLogged(t *testing.T) {
16+
lf := newTestLoggingFetcher([]string{"Authorization", "X-Custom"})
17+
18+
require.True(t, lf.isHeaderNameLogged("Authorization"))
19+
require.True(t, lf.isHeaderNameLogged("authorization"))
20+
require.True(t, lf.isHeaderNameLogged("X-Custom"))
21+
require.False(t, lf.isHeaderNameLogged("X-Other"))
22+
}
23+
24+
func TestRedactQualifierValue(t *testing.T) {
25+
lf := newTestLoggingFetcher([]string{"X-Custom"})
26+
27+
tests := []struct {
28+
name string
29+
q *remoteasset.Qualifier
30+
want string
31+
}{
32+
{
33+
name: "http_header allowed",
34+
q: &remoteasset.Qualifier{Name: "http_header:X-Custom", Value: "visible-value"},
35+
want: "visible-value",
36+
},
37+
{
38+
name: "http_header not allowed",
39+
q: &remoteasset.Qualifier{Name: "http_header:Authorization", Value: "Bearer secret-token"},
40+
want: redactedValue,
41+
},
42+
{
43+
name: "http_header_url allowed",
44+
q: &remoteasset.Qualifier{Name: "http_header_url:0:X-Custom", Value: "visible-value"},
45+
want: "visible-value",
46+
},
47+
{
48+
name: "http_header_url not allowed",
49+
q: &remoteasset.Qualifier{Name: "http_header_url:0:Authorization", Value: "Bearer secret-token"},
50+
want: redactedValue,
51+
},
52+
{
53+
name: "http_header_url malformed",
54+
q: &remoteasset.Qualifier{Name: "http_header_url:not-an-index:X-Custom", Value: "visible-value"},
55+
want: redactedValue,
56+
},
57+
{
58+
name: "legacy auth headers",
59+
q: &remoteasset.Qualifier{Name: QualifierLegacyBazelHTTPHeaders, Value: `{"source.test":{"X-Custom":"visible-value"}}`},
60+
want: `{"source.test":{"X-Custom":"visible-value"}}`,
61+
},
62+
{
63+
name: "legacy auth headers malformed",
64+
q: &remoteasset.Qualifier{Name: QualifierLegacyBazelHTTPHeaders, Value: "not-json"},
65+
want: redactedValue,
66+
},
67+
{
68+
name: "other qualifier passthrough",
69+
q: &remoteasset.Qualifier{Name: "checksum.sri", Value: "sha256-abc123"},
70+
want: "sha256-abc123",
71+
},
72+
}
73+
74+
for _, tc := range tests {
75+
t.Run(tc.name, func(t *testing.T) {
76+
require.Equal(t, tc.want, lf.redactQualifierValue(tc.q))
77+
})
78+
}
79+
}
80+
81+
func TestRedactLegacyAuthHeaders(t *testing.T) {
82+
lf := newTestLoggingFetcher([]string{"X-Custom"})
83+
84+
t.Run("mixed allowed and redacted headers", func(t *testing.T) {
85+
got := lf.redactLegacyAuthHeaders(`{"source.test":{"Authorization":"Bearer secret-token","X-Custom":"visible-value"}}`)
86+
require.NotContains(t, got, "secret-token")
87+
require.Contains(t, got, "visible-value")
88+
89+
ah, err := NewAuthHeadersFromQualifier(got)
90+
require.NoError(t, err)
91+
require.Equal(t, redactedValue, (*ah)["source.test"]["Authorization"])
92+
require.Equal(t, "visible-value", (*ah)["source.test"]["X-Custom"])
93+
})
94+
95+
t.Run("malformed JSON", func(t *testing.T) {
96+
require.Equal(t, redactedValue, lf.redactLegacyAuthHeaders("not-json"))
97+
})
98+
}
99+
100+
func TestRedactQualifiers(t *testing.T) {
101+
lf := newTestLoggingFetcher([]string{"X-Custom"})
102+
103+
qualifiers := []*remoteasset.Qualifier{
104+
{Name: "http_header:X-Custom", Value: "visible-value"},
105+
{Name: "http_header:Authorization", Value: "Bearer secret-token"},
106+
{Name: "checksum.sri", Value: "sha256-abc123"},
107+
}
108+
109+
got := lf.redactQualifiers(qualifiers)
110+
require.NotContains(t, got, "secret-token")
111+
require.Contains(t, got, `name:"http_header:X-Custom" value:"visible-value"`)
112+
require.Contains(t, got, `name:"http_header:Authorization" value:"<redacted>"`)
113+
require.Contains(t, got, `name:"checksum.sri" value:"sha256-abc123"`)
114+
}

pkg/proto/configuration/bb_remote_asset/fetch/fetcher.pb.go

Lines changed: 14 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/proto/configuration/bb_remote_asset/fetch/fetcher.proto

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ message FetcherConfiguration {
3838
RemoteExecutionFetcherConfiguration remote_execution = 4;
3939
}
4040

41+
// Optional: Names of HTTP headers (case-insensitive) whose values
42+
// are permitted to be written to the log.
43+
//
44+
// Qualifiers such as "http_header:<name>", "http_header_url:<idx>:<name>"
45+
// and the legacy "bazel.auth_headers" may carry sensitive
46+
// values (e.g. "Authorization" tokens).
47+
// By default, the values of all such headers are redacted from the log.
48+
// Add a header name to this list to permit its value to be logged.
49+
repeated string logged_header_names = 5;
50+
4151
message HttpFetcherConfiguration {
4252
// Formerly used to specify CAS
4353
reserved 1;

0 commit comments

Comments
 (0)