Skip to content

Commit fbf3aa5

Browse files
authored
web: user-agent can be non-ASCII (#8723)
We send the user-agent header through grpc as metadata. grpc-go by default validates metadata values as printable ASCII. We can override that assumption by naming our header with "-bin". For a transition period, send and accept both header names. After deploy, we'll remove the old one. Also, trim the User-Agent to a max size before logging, and add a unittest for the max size.
1 parent 7c555b0 commit fbf3aa5

3 files changed

Lines changed: 49 additions & 1 deletion

File tree

grpc/interceptors.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const (
2626
meaningfulWorkOverhead = 100 * time.Millisecond
2727
clientRequestTimeKey = "client-request-time"
2828
userAgentKey = "acme-client-user-agent"
29+
// We use the "-bin" suffix to tell grpc's metadata package not to require the user-agent
30+
// be printable ASCII. It's rare, but ACME clients can send non-ASCII user-agents.
31+
// Remove `userAgentKey` once this has been deployed.
32+
userAgentKey2 = "acme-client-user-agent-bin"
2933
)
3034

3135
type serverInterceptor interface {
@@ -91,7 +95,9 @@ func (smi *serverMetadataInterceptor) Unary(
9195
return nil, err
9296
}
9397
}
94-
if len(md[userAgentKey]) > 0 {
98+
if len(md[userAgentKey2]) > 0 {
99+
ctx = web.WithUserAgent(ctx, md[userAgentKey2][0])
100+
} else if len(md[userAgentKey]) > 0 {
95101
ctx = web.WithUserAgent(ctx, md[userAgentKey][0])
96102
}
97103
}
@@ -276,6 +282,7 @@ func (cmi *clientMetadataInterceptor) Unary(
276282
reqMD := metadata.New(map[string]string{
277283
clientRequestTimeKey: nowTS,
278284
userAgentKey: web.UserAgent(ctx),
285+
userAgentKey2: web.UserAgent(ctx),
279286
})
280287
// Configure the localCtx with the metadata so it gets sent along in the request
281288
localCtx = metadata.NewOutgoingContext(localCtx, reqMD)
@@ -384,6 +391,7 @@ func (cmi *clientMetadataInterceptor) Stream(
384391
reqMD := metadata.New(map[string]string{
385392
clientRequestTimeKey: nowTS,
386393
userAgentKey: web.UserAgent(ctx),
394+
userAgentKey2: web.UserAgent(ctx),
387395
})
388396
// Configure the localCtx with the metadata so it gets sent along in the request
389397
localCtx = metadata.NewOutgoingContext(localCtx, reqMD)

web/context.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ func (th *TopHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
142142
}
143143

144144
userAgent := r.Header.Get("User-Agent")
145+
// If someone sends a very long User-Agent we're only interested in logging the beginning.
146+
if len(userAgent) > 100 {
147+
userAgent = userAgent[:100] + "..."
148+
}
145149

146150
logEvent := &RequestEvent{
147151
RealIP: realIP,

web/context_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"bytes"
55
"context"
66
"crypto/tls"
7+
"encoding/json"
78
"fmt"
89
"net/http"
910
"net/http/httptest"
11+
"regexp"
1012
"strings"
1113
"testing"
1214
"time"
@@ -39,6 +41,40 @@ func TestLogCode(t *testing.T) {
3941
}
4042
}
4143

44+
// TestLogUA tests that user-agents are truncated before logging, and faithfully pass along non-ASCII.
45+
func TestLogUA(t *testing.T) {
46+
mockLog := blog.UseMock()
47+
th := NewTopHandler(mockLog, myHandler{})
48+
req, err := http.NewRequest("GET", "/thisisignored", &bytes.Reader{})
49+
if err != nil {
50+
t.Fatal(err)
51+
}
52+
req.Header.Add("User-Agent", "🪨"+strings.Repeat("a", 200))
53+
th.ServeHTTP(httptest.NewRecorder(), req)
54+
55+
matching := mockLog.GetAllMatching("JSON=")
56+
if len(matching) != 1 {
57+
t.Errorf("Expected exactly one log line. Got: %s",
58+
strings.Join(mockLog.GetAllMatching(".*"), "\n"))
59+
}
60+
re := regexp.MustCompile(`JSON=({.*})$`)
61+
m := re.FindStringSubmatch(matching[0])
62+
if len(m) < 2 {
63+
t.Fatalf("logging user-agent: no regexp match")
64+
}
65+
var ua struct {
66+
UA string
67+
}
68+
err = json.Unmarshal([]byte(m[1]), &ua)
69+
if err != nil {
70+
t.Fatal(err)
71+
}
72+
expected := "🪨aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa..."
73+
if ua.UA != expected {
74+
t.Errorf("logging user-agent: got %x, want %x", ua.UA, expected)
75+
}
76+
}
77+
4278
type codeHandler struct{}
4379

4480
func (ch codeHandler) ServeHTTP(e *RequestEvent, w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)