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

+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 {