Add parsing logic for User-Agent headers (#5645)

* feat(ts_telemetry): add parsing logic for user agent headers

* feat(ts_telemetry): update gRPC observability for user-agent headers

* feat(ts_telemetry): add observation tags

* feat(ts_telemetry): Resolved comments

* feat(ts_telemetry): Removed loop variable capture

* feat(ts_telemetry): Updated userAgentKey to be inline

* feat(ts_telemetry): Add support for userAgent as per RFC 9110

* feat(ts_telemetry): Resolved comments

* Fix newline

* feat(ts_telemetry): Moved user-agent parsing to interceptor

* feat(ts_telemetry): Added user-agent parsing complex test case

* Update userAgentsKey comment

Co-authored-by: Johan Brandhorst-Satzkorn <johan.brandhorst@gmail.com>

* feat(ts_telemetry): Updated changelog

* feat(ts_telemetry): Resolved comments

---------

Co-authored-by: Johan Brandhorst-Satzkorn <johan.brandhorst@gmail.com>
This commit is contained in:
Aditya SharmaandJohan Brandhorst-Satzkorn authored and GitHub committed 2025-04-17 23:46:25 +05:30
1 parent a833981eae
commit 30e3116156
14 files changed
+279 -8

No files matched your search

+17 -1
View File
@@ -29,7 +29,12 @@ import (
"google.golang.org/grpc/test/bufconn"
)
const gatewayTarget = ""
const (
gatewayTarget = ""
// userAgentsKey defines the gRPC metadata key used to forward the User-Agent header to the gRPC server.
userAgentsKey = "userAgents"
)
type grpcServerListener interface {
net.Listener
@@ -58,6 +63,7 @@ func (noDelimiterStreamingMarshaler) Delimiter() []byte {
func newGrpcGatewayMux() *runtime.ServeMux {
return runtime.NewServeMux(
runtime.WithMetadata(correlationIdAnnotator),
runtime.WithMetadata(userAgentHeadersAnnotator),
runtime.WithMarshalerOption(runtime.MIMEWildcard, &noDelimiterStreamingMarshaler{
&runtime.HTTPBodyMarshaler{
Marshaler: handlers.JSONMarshaler(),
@@ -93,6 +99,16 @@ func correlationIdAnnotator(_ context.Context, req *http.Request) metadata.MD {
})
}
func userAgentHeadersAnnotator(_ context.Context, req *http.Request) metadata.MD {
userAgent := req.Header.Get("User-Agent")
if userAgent == "" {
return metadata.MD{}
}
return metadata.New(map[string]string{
userAgentsKey: userAgent,
})
}
// newGrpcServerListener will create an in-memory listener for the gRPC server.
func newGrpcServerListener() grpcServerListener {
buffer := globals.DefaultMaxRequestSize // seems like a reasonable size for the ring buffer, but then happily change the size if more info becomes available
@@ -95,6 +95,28 @@ func Test_correlationIdAnnotator(t *testing.T) {
assert.Equal(t, corId, corIds[0])
}
func Test_clientAgentHeadersAnnotator(t *testing.T) {
t.Parallel()
t.Run("returns metadata with user-agent", func(t *testing.T) {
t.Parallel()
req := &http.Request{
Header: map[string][]string{
"User-Agent": {"Boundary-client-agent/0.1.4"},
},
}
md := userAgentHeadersAnnotator(context.Background(), req)
require.NotNil(t, md)
assert.Equal(t, []string{"Boundary-client-agent/0.1.4"}, md.Get("userAgents"))
})
t.Run("returns empty metadata if no user-agent header", func(t *testing.T) {
t.Parallel()
req := &http.Request{Header: map[string][]string{}}
md := userAgentHeadersAnnotator(context.Background(), req)
assert.Empty(t, md)
})
}
func Test_WithDisablePathLengthFallback(t *testing.T) {
ctx := context.Background()
reqPath := "/v1/example"
+69 -1
View File
@@ -9,7 +9,9 @@ import (
"fmt"
"net/http"
"reflect"
"regexp"
"runtime/debug"
"strings"
"time"
grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery"
@@ -27,6 +29,7 @@ import (
"github.com/hashicorp/boundary/internal/kms"
"github.com/hashicorp/boundary/internal/requests"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/go-version"
"github.com/mr-tron/base58"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
@@ -45,8 +48,16 @@ const (
// apiErrHeader defines an http header for encoded api errors from the
// grpc server.
apiErrHeader = "x-api-err"
// boundaryClientAgentProduct defines the product name used to identify the
// Boundary client agent in user-agent parsing and validation logic.
boundaryClientAgentProduct = "Boundary-client-agent"
)
// Regular expression to parse user-agent product, version, and comments
// Follows the structure defined in RFC 9110: https://datatracker.ietf.org/doc/html/rfc9110#name-user-agent
var userAgentRegex = regexp.MustCompile(`(?P<product>[^\s/()]+)/(?P<version>[^\s()]+)(?: \((?P<comments>[^)]+)\))?`)
// customContextServerStream wraps the grpc.ServerStream interface and lets us
// set a custom context
type customContextServerStream struct {
@@ -448,14 +459,24 @@ func eventsRequestInterceptor(
_ *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error,
) {
var userAgents []*event.UserAgent
if md, ok := metadata.FromIncomingContext(interceptorCtx); ok {
if values := md.Get(userAgentsKey); len(values) > 0 {
userAgents = parseUserAgents(values[0])
}
}
if msg, ok := req.(proto.Message); ok {
// Clone the request before writing it to the audit log,
// in case downstream interceptors modify it.
clonedMsg := proto.Clone(msg)
request := &event.Request{
Details: clonedMsg,
UserAgents: userAgents,
}
if err := event.WriteAudit(interceptorCtx, op, event.WithRequest(&event.Request{Details: clonedMsg})); err != nil {
return req, status.Errorf(codes.Internal, "unable to write request msg audit: %s", err)
}
if err := event.WriteObservation(interceptorCtx, op, event.WithRequest(&event.Request{Details: clonedMsg})); err != nil {
if err := event.WriteObservation(interceptorCtx, op, event.WithRequest(request)); err != nil {
return req, status.Errorf(codes.Internal, "unable to write request msg observation: %s", err)
}
}
@@ -464,6 +485,53 @@ func eventsRequestInterceptor(
}
}
// parseUserAgents extracts structured UserAgent data from a raw User-Agent header string.
// Version validation is applied only to Boundary-client-agent entries, which are excluded
// if the version starts with 'v' or is not a valid semantic version.
// Comments are split and normalized into a slice of strings.
func parseUserAgents(rawUserAgent string) []*event.UserAgent {
var userAgents []*event.UserAgent
matches := userAgentRegex.FindAllStringSubmatch(rawUserAgent, -1)
for _, match := range matches {
product := strings.TrimSpace(match[1])
agentVersion := strings.TrimSpace(match[2])
// Only apply version validation for Boundary-client-agent
if product == boundaryClientAgentProduct {
if strings.HasPrefix(agentVersion, "v") {
// Invalid version format (starting with 'v')
continue
}
if _, err := version.NewSemver(agentVersion); err != nil {
// Invalid version
continue
}
}
agentData := &event.UserAgent{
Product: product,
ProductVersion: agentVersion,
}
if len(match) > 3 && match[3] != "" {
// Clean up and split comments
commentsRaw := strings.Split(match[3], ";")
var comments []string
for _, c := range commentsRaw {
if trimmed := strings.TrimSpace(c); trimmed != "" {
comments = append(comments, trimmed)
}
}
if len(comments) > 0 {
agentData.Comments = comments
}
}
userAgents = append(userAgents, agentData)
}
return userAgents
}
func eventsResponseInterceptor(
_ context.Context,
) grpc.UnaryServerInterceptor {
@@ -874,6 +874,114 @@ func Test_statusCodeInterceptor(t *testing.T) {
}
}
func Test_parseUserAgents(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawUserAgent string
expected []*event.UserAgent
}{
{
name: "valid single user-agent",
rawUserAgent: "Boundary-client-agent/0.1.4",
expected: []*event.UserAgent{
{
Product: "Boundary-client-agent",
ProductVersion: "0.1.4",
},
},
},
{
name: "multiple valid agents with comments",
rawUserAgent: "Boundary-client-agent/0.1.4 (foo; bar); AnotherApp/2.0.0 (baz )",
expected: []*event.UserAgent{
{
Product: "Boundary-client-agent",
ProductVersion: "0.1.4",
Comments: []string{"foo", "bar"},
},
{
Product: "AnotherApp",
ProductVersion: "2.0.0",
Comments: []string{"baz"},
},
},
},
{
name: "complex but valid user agents",
rawUserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 surveyon/2.9.5 (iPhone; CPU iPhone OS 12_5_7 like Mac OS X)",
expected: []*event.UserAgent{
{
Product: "Mozilla",
ProductVersion: "5.0",
Comments: []string{"Macintosh", "Intel Mac OS X 10_15_7"},
},
{
Product: "AppleWebKit",
ProductVersion: "537.36",
Comments: []string{"KHTML, like Gecko"},
},
{
Product: "Chrome",
ProductVersion: "87.0.4280.88",
},
{
Product: "Safari",
ProductVersion: "537.36",
},
{
Product: "surveyon",
ProductVersion: "2.9.5",
Comments: []string{"iPhone", "CPU iPhone OS 12_5_7 like Mac OS X"},
},
},
},
{
name: "invalid client-agent version format (starts with 'v')",
rawUserAgent: "Boundary-client-agent/v0.1.4",
expected: nil,
},
{
name: "invalid client-agent version format (non-semver)",
rawUserAgent: "Boundary-client-agent/0.1.x",
expected: nil,
},
{
name: "empty user-agent",
rawUserAgent: "",
expected: nil,
},
{
name: "valid non client-agent user-agent",
rawUserAgent: "SomeOtherApp/v1.2.3",
expected: []*event.UserAgent{
{
Product: "SomeOtherApp",
ProductVersion: "v1.2.3",
},
},
},
{
name: "mixed valid and invalid agents",
rawUserAgent: "Boundary-client-agent/0.1.4 NoVersionApp SomeOtherApp/",
expected: []*event.UserAgent{
{
Product: "Boundary-client-agent",
ProductVersion: "0.1.4",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := parseUserAgents(tt.rawUserAgent)
assert.ElementsMatch(t, tt.expected, result)
})
}
}
func Test_workerRequestInfoInterceptor(t *testing.T) {
factoryCtx := context.Background()
requestCtx := context.Background()