Skip to content

Commit efe325a

Browse files
tongossdmathieupellared
authored
Stop percent-encoding the header environment variables in otlplog exporters (#6392)
Bugfixes for #5623 Based on the conversation #5623 (comment), only OTLP log exporters need bugfixes. --------- Co-authored-by: Damien Mathieu <[email protected]> Co-authored-by: Robert Pająk <[email protected]>
1 parent fb89a38 commit efe325a

File tree

5 files changed

+312
-16
lines changed

5 files changed

+312
-16
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1212

1313
- Drop support for [Go 1.22]. (#6381, #6418)
1414

15+
### Fixes
16+
17+
- Stop percent encoding header environment variables in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc` and `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`. (#6392)
18+
1519
<!-- Released section -->
1620
<!-- Don't change this section unless doing release -->
1721

exporters/otlp/otlplog/otlploggrpc/config.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strconv"
1414
"strings"
1515
"time"
16+
"unicode"
1617

1718
"google.golang.org/grpc"
1819
"google.golang.org/grpc/credentials"
@@ -442,13 +443,15 @@ func convHeaders(s string) (map[string]string, error) {
442443
continue
443444
}
444445

445-
escKey, e := url.PathUnescape(rawKey)
446-
if e != nil {
446+
key := strings.TrimSpace(rawKey)
447+
448+
// Validate the key.
449+
if !isValidHeaderKey(key) {
447450
err = errors.Join(err, fmt.Errorf("invalid header key: %s", rawKey))
448451
continue
449452
}
450-
key := strings.TrimSpace(escKey)
451453

454+
// Only decode the value.
452455
escVal, e := url.PathUnescape(rawVal)
453456
if e != nil {
454457
err = errors.Join(err, fmt.Errorf("invalid header value: %s", rawVal))
@@ -651,3 +654,22 @@ func fallback[T any](val T) resolver[T] {
651654
return s
652655
}
653656
}
657+
658+
func isValidHeaderKey(key string) bool {
659+
if key == "" {
660+
return false
661+
}
662+
for _, c := range key {
663+
if !isTokenChar(c) {
664+
return false
665+
}
666+
}
667+
return true
668+
}
669+
670+
func isTokenChar(c rune) bool {
671+
return c <= unicode.MaxASCII && (unicode.IsLetter(c) ||
672+
unicode.IsDigit(c) ||
673+
c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' ||
674+
c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~')
675+
}

exporters/otlp/otlplog/otlploggrpc/config_test.go

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ func TestNewConfig(t *testing.T) {
338338
name: "InvalidEnvironmentVariables",
339339
envars: map[string]string{
340340
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "%invalid",
341-
"OTEL_EXPORTER_OTLP_LOGS_HEADERS": "a,%ZZ=valid,key=%ZZ",
341+
"OTEL_EXPORTER_OTLP_LOGS_HEADERS": "invalid key=value",
342342
"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION": "xz",
343343
"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT": "100 seconds",
344344
"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE": "invalid_cert",
@@ -355,10 +355,7 @@ func TestNewConfig(t *testing.T) {
355355
`failed to load TLS:`,
356356
`certificate not added`,
357357
`tls: failed to find any PEM data in certificate input`,
358-
`invalid OTEL_EXPORTER_OTLP_LOGS_HEADERS value a,%ZZ=valid,key=%ZZ:`,
359-
`invalid header: a`,
360-
`invalid header key: %ZZ`,
361-
`invalid header value: %ZZ`,
358+
`invalid OTEL_EXPORTER_OTLP_LOGS_HEADERS value invalid key=value: invalid header key: invalid key`,
362359
`invalid OTEL_EXPORTER_OTLP_LOGS_COMPRESSION value xz: unknown compression: xz`,
363360
`invalid OTEL_EXPORTER_OTLP_LOGS_TIMEOUT value 100 seconds: strconv.Atoi: parsing "100 seconds": invalid syntax`,
364361
},
@@ -440,6 +437,47 @@ func TestNewConfig(t *testing.T) {
440437
timeout: newSetting(defaultTimeout),
441438
},
442439
},
440+
{
441+
name: "with percent-encoded headers",
442+
envars: map[string]string{
443+
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "https://env.endpoint:8080/prefix",
444+
"OTEL_EXPORTER_OTLP_LOGS_HEADERS": "user%2Did=42,user%20name=alice%20smith",
445+
"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION": "gzip",
446+
"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT": "15000",
447+
"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE": "cert_path",
448+
"OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE": "cert_path",
449+
"OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY": "key_path",
450+
},
451+
want: config{
452+
endpoint: newSetting("env.endpoint:8080"),
453+
insecure: newSetting(false),
454+
tlsCfg: newSetting(tlsCfg),
455+
headers: newSetting(map[string]string{"user%2Did": "42", "user%20name": "alice smith"}),
456+
compression: newSetting(GzipCompression),
457+
timeout: newSetting(15 * time.Second),
458+
retryCfg: newSetting(defaultRetryCfg),
459+
},
460+
},
461+
{
462+
name: "with invalid header key",
463+
envars: map[string]string{
464+
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "https://env.endpoint:8080/prefix",
465+
"OTEL_EXPORTER_OTLP_LOGS_HEADERS": "valid-key=value,invalid key=value",
466+
"OTEL_EXPORTER_OTLP_LOGS_COMPRESSION": "gzip",
467+
"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT": "15000",
468+
"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE": "cert_path",
469+
"OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE": "cert_path",
470+
"OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY": "key_path",
471+
},
472+
want: config{
473+
endpoint: newSetting("env.endpoint:8080"),
474+
insecure: newSetting(false),
475+
tlsCfg: newSetting(tlsCfg),
476+
compression: newSetting(GzipCompression),
477+
timeout: newSetting(15 * time.Second),
478+
retryCfg: newSetting(defaultRetryCfg),
479+
},
480+
},
443481
}
444482

445483
for _, tc := range testcases {
@@ -494,3 +532,88 @@ func assertTLSConfig(t *testing.T, want, got setting[*tls.Config]) {
494532
}
495533
assert.Equal(t, want.Value.Certificates, got.Value.Certificates, "Certificates")
496534
}
535+
536+
func TestConvHeaders(t *testing.T) {
537+
tests := []struct {
538+
name string
539+
value string
540+
want map[string]string
541+
wantErr bool
542+
}{
543+
{
544+
name: "simple test",
545+
value: "userId=alice",
546+
want: map[string]string{"userId": "alice"},
547+
wantErr: false,
548+
},
549+
{
550+
name: "simple test with spaces",
551+
value: " userId = alice ",
552+
want: map[string]string{"userId": "alice"},
553+
wantErr: false,
554+
},
555+
{
556+
name: "simple header conforms to RFC 3986 spec",
557+
value: " userId = alice+test ",
558+
want: map[string]string{"userId": "alice+test"},
559+
wantErr: false,
560+
},
561+
{
562+
name: "multiple headers encoded",
563+
value: "userId=alice,serverNode=DF%3A28,isProduction=false",
564+
want: map[string]string{
565+
"userId": "alice",
566+
"serverNode": "DF:28",
567+
"isProduction": "false",
568+
},
569+
wantErr: false,
570+
},
571+
{
572+
name: "multiple headers encoded per RFC 3986 spec",
573+
value: "userId=alice+test,serverNode=DF%3A28,isProduction=false,namespace=localhost/test",
574+
want: map[string]string{
575+
"userId": "alice+test",
576+
"serverNode": "DF:28",
577+
"isProduction": "false",
578+
"namespace": "localhost/test",
579+
},
580+
wantErr: false,
581+
},
582+
{
583+
name: "invalid headers format",
584+
value: "userId:alice",
585+
want: map[string]string{},
586+
wantErr: true,
587+
},
588+
{
589+
name: "invalid key",
590+
value: "%XX=missing,userId=alice",
591+
want: map[string]string{
592+
"%XX": "missing",
593+
"userId": "alice",
594+
},
595+
wantErr: false,
596+
},
597+
{
598+
name: "invalid value",
599+
value: "missing=%XX,userId=alice",
600+
want: map[string]string{
601+
"userId": "alice",
602+
},
603+
wantErr: true,
604+
},
605+
}
606+
607+
for _, tt := range tests {
608+
t.Run(tt.name, func(t *testing.T) {
609+
keyValues, err := convHeaders(tt.value)
610+
assert.Equal(t, tt.want, keyValues)
611+
612+
if tt.wantErr {
613+
assert.Error(t, err, "expected an error but got nil")
614+
} else {
615+
assert.NoError(t, err, "expected no error but got one")
616+
}
617+
})
618+
}
619+
}

exporters/otlp/otlplog/otlploghttp/config.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"strconv"
1515
"strings"
1616
"time"
17+
"unicode"
1718

1819
"go.opentelemetry.io/otel"
1920
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp/internal/retry"
@@ -544,13 +545,15 @@ func convHeaders(s string) (map[string]string, error) {
544545
continue
545546
}
546547

547-
escKey, e := url.PathUnescape(rawKey)
548-
if e != nil {
548+
key := strings.TrimSpace(rawKey)
549+
550+
// Validate the key.
551+
if !isValidHeaderKey(key) {
549552
err = errors.Join(err, fmt.Errorf("invalid header key: %s", rawKey))
550553
continue
551554
}
552-
key := strings.TrimSpace(escKey)
553555

556+
// Only decode the value.
554557
escVal, e := url.PathUnescape(rawVal)
555558
if e != nil {
556559
err = errors.Join(err, fmt.Errorf("invalid header value: %s", rawVal))
@@ -600,3 +603,22 @@ func fallback[T any](val T) resolver[T] {
600603
return s
601604
}
602605
}
606+
607+
func isValidHeaderKey(key string) bool {
608+
if key == "" {
609+
return false
610+
}
611+
for _, c := range key {
612+
if !isTokenChar(c) {
613+
return false
614+
}
615+
}
616+
return true
617+
}
618+
619+
func isTokenChar(c rune) bool {
620+
return c <= unicode.MaxASCII && (unicode.IsLetter(c) ||
621+
unicode.IsDigit(c) ||
622+
c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' ||
623+
c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~')
624+
}

0 commit comments

Comments
 (0)