Skip to content

Commit 8645b18

Browse files
authored
feat(listener): Parse Runtime ID from Request Header (#664)
* feat(listener): Parse Runtime ID from Request Header * chore: Fix Lint * fix: Adjust Other Unit Test to Contain XFCC Header * fix: Lint
1 parent 6515407 commit 8645b18

File tree

6 files changed

+327
-12
lines changed

6 files changed

+327
-12
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package certificate
2+
3+
import (
4+
"crypto/x509"
5+
"encoding/pem"
6+
"errors"
7+
"fmt"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
)
12+
13+
const (
14+
XFCCHeader = "X-Forwarded-Client-Cert"
15+
CertificateKey = "Cert="
16+
Limit32KiB = 32 * 1024
17+
)
18+
19+
var (
20+
ErrPemDecode = errors.New("failed to decode PEM block")
21+
ErrEmptyCert = errors.New("empty certificate")
22+
ErrHeaderValueTooLong = errors.New(XFCCHeader + " header value too long (over 32KiB)")
23+
ErrHeaderMissing = fmt.Errorf("request does not contain '%s' header", XFCCHeader)
24+
)
25+
26+
// GetCertificateFromHeader extracts the XFCC header and pareses it into a valid x509 certificate.
27+
func GetCertificateFromHeader(r *http.Request) (*x509.Certificate, error) {
28+
// Fetch XFCC-Header data
29+
xfccValues, ok := r.Header[XFCCHeader]
30+
if !ok {
31+
return nil, ErrHeaderMissing
32+
}
33+
34+
xfccVal := xfccValues[0]
35+
36+
// Limit the length of the data (prevent resource exhaustion attack)
37+
if len(xfccVal) > Limit32KiB {
38+
return nil, ErrHeaderValueTooLong
39+
}
40+
41+
// Extract raw certificate from the first header value
42+
cert := getCertTokenFromXFCCHeader(xfccVal)
43+
if cert == "" {
44+
return nil, ErrEmptyCert
45+
}
46+
47+
// Decode URL-format
48+
decodedValue, err := url.QueryUnescape(cert)
49+
if err != nil {
50+
return nil, fmt.Errorf("could not decode certificate URL format: %w", err)
51+
}
52+
decodedValue = strings.Trim(decodedValue, "\"")
53+
54+
// Decode PEM block and parse certificate
55+
block, _ := pem.Decode([]byte(decodedValue))
56+
if block == nil {
57+
return nil, ErrPemDecode
58+
}
59+
certificate, err := x509.ParseCertificate(block.Bytes)
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to parse PEM block into x509 certificate: %w", err)
62+
}
63+
64+
return certificate, nil
65+
}
66+
67+
// getCertTokenFromXFCCHeader returns the first certificate embedded in the XFFC Header,
68+
// if exists. Otherwise an empty string is returned.
69+
func getCertTokenFromXFCCHeader(hVal string) string {
70+
certStartIdx := strings.Index(hVal, CertificateKey)
71+
if certStartIdx >= 0 {
72+
tokenWithCert := hVal[(certStartIdx + len(CertificateKey)):]
73+
// we shouldn't have "," here but it's safer to add it anyway
74+
certEndIdx := strings.IndexAny(tokenWithCert, ";,")
75+
if certEndIdx == -1 {
76+
// no suffix, the entire token is the cert value
77+
return tokenWithCert
78+
}
79+
80+
// there's some data after the cert value, return just the cert part
81+
return tokenWithCert[:certEndIdx]
82+
}
83+
return ""
84+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package certificate_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/url"
7+
"testing"
8+
9+
"github.com/kyma-project/runtime-watcher/listener/pkg/v2/certificate"
10+
"github.com/kyma-project/runtime-watcher/listener/pkg/v2/certificate/utils"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestGetCertificateFromHeader_Success(t *testing.T) {
15+
pemCert, err := utils.NewPemCertificateBuilder().Build()
16+
require.NoError(t, err)
17+
r, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil)
18+
r.Header.Set(certificate.XFCCHeader, certificate.CertificateKey+pemCert)
19+
cert, err := certificate.GetCertificateFromHeader(r)
20+
require.NoError(t, err)
21+
require.NotNil(t, cert)
22+
}
23+
24+
func TestGetCertificateFromHeader_MissingHeader(t *testing.T) {
25+
r, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil)
26+
cert, err := certificate.GetCertificateFromHeader(r)
27+
require.Error(t, err)
28+
require.Nil(t, cert)
29+
require.Equal(t, certificate.ErrHeaderMissing, err)
30+
}
31+
32+
func TestGetCertificateFromHeader_TooLong(t *testing.T) {
33+
longValue := make([]byte, certificate.Limit32KiB+1)
34+
for i := range longValue {
35+
longValue[i] = 'A'
36+
}
37+
r, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil)
38+
r.Header.Set(certificate.XFCCHeader, certificate.CertificateKey+string(longValue))
39+
cert, err := certificate.GetCertificateFromHeader(r)
40+
require.Error(t, err)
41+
require.Nil(t, cert)
42+
require.Equal(t, certificate.ErrHeaderValueTooLong, err)
43+
}
44+
45+
func TestGetCertificateFromHeader_EmptyCert(t *testing.T) {
46+
r, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil)
47+
r.Header.Set(certificate.XFCCHeader, "Cert=;Other=foo")
48+
cert, err := certificate.GetCertificateFromHeader(r)
49+
require.Error(t, err)
50+
require.Nil(t, cert)
51+
require.Equal(t, certificate.ErrEmptyCert, err)
52+
}
53+
54+
func TestGetCertificateFromHeader_NoCertToken(t *testing.T) {
55+
r, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil)
56+
r.Header.Set(certificate.XFCCHeader, "Other=foo;Stuff=bar")
57+
cert, err := certificate.GetCertificateFromHeader(r)
58+
require.Error(t, err)
59+
require.Nil(t, cert)
60+
require.Equal(t, certificate.ErrEmptyCert, err)
61+
}
62+
63+
func TestGetCertificateFromHeader_InvalidURLFormat(t *testing.T) {
64+
r, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil)
65+
r.Header.Set(certificate.XFCCHeader, "Cert=%ZZ;Other=foo")
66+
cert, err := certificate.GetCertificateFromHeader(r)
67+
require.Error(t, err)
68+
require.Nil(t, cert)
69+
require.Contains(t, err.Error(), "could not decode certificate URL format")
70+
}
71+
72+
func TestGetCertificateFromHeader_PEMDecodeError(t *testing.T) {
73+
invalid := url.QueryEscape("not a pem block")
74+
r, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil)
75+
r.Header.Set(certificate.XFCCHeader, certificate.CertificateKey+invalid)
76+
cert, err := certificate.GetCertificateFromHeader(r)
77+
require.Error(t, err)
78+
require.Nil(t, cert)
79+
require.Equal(t, certificate.ErrPemDecode, err)
80+
}
81+
82+
func TestGetCertificateFromHeader_CertificateParseError(t *testing.T) {
83+
pemInvalid := "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----"
84+
escaped := url.QueryEscape(pemInvalid)
85+
r, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil)
86+
r.Header.Set(certificate.XFCCHeader, certificate.CertificateKey+escaped)
87+
cert, err := certificate.GetCertificateFromHeader(r)
88+
require.Error(t, err)
89+
require.Nil(t, cert)
90+
require.Contains(t, err.Error(), "failed to parse PEM block into x509 certificate")
91+
}
92+
93+
func TestGetCertificateFromHeader_MultipleValuesFirstHasCert(t *testing.T) {
94+
pemCert, err := utils.NewPemCertificateBuilder().Build()
95+
require.NoError(t, err)
96+
r, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil)
97+
r.Header[certificate.XFCCHeader] = []string{certificate.CertificateKey + pemCert + ";Other=foo", "Cert=ignored"}
98+
cert, err := certificate.GetCertificateFromHeader(r)
99+
require.NoError(t, err)
100+
require.NotNil(t, cert)
101+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package utils
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"crypto/rand"
7+
"crypto/x509"
8+
"crypto/x509/pkix"
9+
"encoding/pem"
10+
"fmt"
11+
"math/big"
12+
"net/url"
13+
"time"
14+
)
15+
16+
type CertificateBuilder struct {
17+
commonName string
18+
serialNumber *big.Int
19+
notBefore time.Time
20+
notAfter time.Time
21+
dnsNames []string
22+
}
23+
24+
func NewPemCertificateBuilder() *CertificateBuilder {
25+
return &CertificateBuilder{
26+
commonName: "test-cert",
27+
serialNumber: big.NewInt(1),
28+
notBefore: time.Now().Add(-time.Hour),
29+
notAfter: time.Now().Add(time.Hour),
30+
dnsNames: []string{"example.com"},
31+
}
32+
}
33+
34+
func (builder *CertificateBuilder) WithCommonName(commonName string) *CertificateBuilder {
35+
builder.commonName = commonName
36+
return builder
37+
}
38+
39+
func (builder *CertificateBuilder) Build() (string, error) {
40+
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
41+
if err != nil {
42+
return "", fmt.Errorf("unable to generate ecdsa key: %w", err)
43+
}
44+
tmpl := &x509.Certificate{
45+
Subject: pkix.Name{
46+
CommonName: builder.commonName,
47+
},
48+
SerialNumber: builder.serialNumber,
49+
NotBefore: builder.notBefore,
50+
NotAfter: builder.notAfter,
51+
DNSNames: builder.dnsNames,
52+
BasicConstraintsValid: true,
53+
}
54+
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
55+
if err != nil {
56+
return "", fmt.Errorf("unable to create certificate: %w", err)
57+
}
58+
block := &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}
59+
return url.QueryEscape(string(pem.EncodeToMemory(block))), nil
60+
}

listener/pkg/v2/event/http_test.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import (
1212

1313
"github.com/go-logr/logr"
1414
"github.com/go-logr/zapr"
15+
"github.com/kyma-project/runtime-watcher/listener/pkg/v2/certificate"
16+
"github.com/kyma-project/runtime-watcher/listener/pkg/v2/certificate/utils"
1517
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
1619
"go.uber.org/zap"
1720
"go.uber.org/zap/zapcore"
1821
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -35,7 +38,11 @@ func setupLogger() logr.Logger {
3538
return zapr.NewLogger(zapLog)
3639
}
3740

38-
func newListenerRequest(t *testing.T, method, url string, watcherEvent *types.WatchEvent) *http.Request {
41+
func newListenerRequest(t *testing.T,
42+
method, url string,
43+
watcherEvent *types.WatchEvent,
44+
encodedCertificate string,
45+
) *http.Request {
3946
t.Helper()
4047

4148
var body io.Reader
@@ -47,11 +54,13 @@ func newListenerRequest(t *testing.T, method, url string, watcherEvent *types.Wa
4754
body = bytes.NewBuffer(jsonBody)
4855
}
4956

50-
r, err := http.NewRequestWithContext(t.Context(), method, url, body)
57+
httpRequest, err := http.NewRequestWithContext(t.Context(), method, url, body)
58+
httpRequest.Header.Set(certificate.XFCCHeader, certificate.CertificateKey+encodedCertificate)
59+
5160
if err != nil {
5261
t.Fatal(err)
5362
}
54-
return r
63+
return httpRequest
5564
}
5665

5766
type GenericTestEvt struct {
@@ -76,8 +85,12 @@ func TestHandler(t *testing.T) {
7685
Owner: types.ObjectKey{Name: "kyma", Namespace: v1.NamespaceDefault},
7786
Watched: types.ObjectKey{Name: "watched-resource", Namespace: v1.NamespaceDefault},
7887
WatchedGvk: v1.GroupVersionKind{Kind: "kyma", Group: "operator.kyma-project.io", Version: "v1alpha1"},
88+
SkrMeta: types.SkrMeta{RuntimeId: "test-cert"},
7989
}
80-
httpRequest := newListenerRequest(t, http.MethodPost, "http://localhost:8082/v1/kyma/event", testWatcherEvt)
90+
pemCert, err := utils.NewPemCertificateBuilder().Build()
91+
require.NoError(t, err)
92+
httpRequest := newListenerRequest(t, http.MethodPost, "http://localhost:8082/v1/kyma/event", testWatcherEvt,
93+
pemCert)
8194
testEvt := GenericTestEvt{}
8295
go func() {
8396
testEvt.mu.Lock()

listener/pkg/v2/event/watcher_event.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ import (
1010

1111
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1212

13+
"github.com/kyma-project/runtime-watcher/listener/pkg/v2/certificate"
1314
"github.com/kyma-project/runtime-watcher/listener/pkg/v2/types"
1415
)
1516

1617
const (
17-
contentMapCapacity = 3
18+
contentMapCapacity = 4
1819
)
1920

2021
type UnmarshalError struct {
@@ -44,13 +45,43 @@ func UnmarshalSKREvent(req *http.Request) (*types.WatchEvent, *UnmarshalError) {
4445
watcherEvent := &types.WatchEvent{}
4546
err = json.Unmarshal(body, watcherEvent)
4647
if err != nil {
47-
return nil, &UnmarshalError{fmt.Sprintf("could not unmarshal watcher event: Body{%s}",
48-
string(body)), http.StatusInternalServerError}
48+
return nil, &UnmarshalError{
49+
fmt.Sprintf("could not unmarshal watcher event: Body{%s}",
50+
string(body)), http.StatusInternalServerError,
51+
}
4952
}
5053

54+
skrMetaFromRequest, unmarshalError := getSkrMetaFromRequest(req)
55+
if unmarshalError != nil {
56+
return nil, unmarshalError
57+
}
58+
watcherEvent.SkrMeta = skrMetaFromRequest
59+
5160
return watcherEvent, nil
5261
}
5362

63+
func getSkrMetaFromRequest(req *http.Request) (types.SkrMeta, *UnmarshalError) {
64+
clientCertificate, err := certificate.GetCertificateFromHeader(req)
65+
if err != nil {
66+
return types.SkrMeta{}, &UnmarshalError{
67+
fmt.Sprintf("could not get client certificate from request: %v", err),
68+
http.StatusUnauthorized,
69+
}
70+
}
71+
72+
if clientCertificate.Subject.CommonName == "" {
73+
return types.SkrMeta{}, &UnmarshalError{
74+
"client certificate common name is empty",
75+
http.StatusBadRequest,
76+
}
77+
}
78+
79+
return types.SkrMeta{
80+
RuntimeId: clientCertificate.Subject.CommonName,
81+
SkrDomain: "", // this cannot be reliably extracted from the certificate.DNSNames slice
82+
}, nil
83+
}
84+
5485
func GenericEvent(watcherEvent *types.WatchEvent) *unstructured.Unstructured {
5586
genericEvtObject := &unstructured.Unstructured{}
5687
content := UnstructuredContent(watcherEvent)
@@ -65,5 +96,6 @@ func UnstructuredContent(watcherEvt *types.WatchEvent) map[string]interface{} {
6596
content["owner"] = watcherEvt.Owner
6697
content["watched"] = watcherEvt.Watched
6798
content["watched-gvk"] = watcherEvt.WatchedGvk
99+
content["runtime-id"] = watcherEvt.SkrMeta.RuntimeId
68100
return content
69101
}

0 commit comments

Comments
 (0)