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:
1 parent
a833981eae
commit
30e3116156
14 files changed
+279
-8
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user