diff --git a/doc/reference/filters.md b/doc/reference/filters.md index f710b9e224..c4cfe913ed 100644 --- a/doc/reference/filters.md +++ b/doc/reference/filters.md @@ -52,6 +52,8 @@ - [HeaderToJSON](#headertojson) - [Configuration](#configuration-16) - [Results](#results-16) + - [CertExtractor](#certextractor) + - [Configuration](#configuration-17) - [Common Types](#common-types) - [apiaggregator.Pipeline](#apiaggregatorpipeline) - [pathadaptor.Spec](#pathadaptorspec) @@ -734,8 +736,7 @@ headerMap: | Name | Type | Description | Required | | ------------ | -------- | -------------------------------- | -------- | -| headerMap | [][HeaderToJSON.HeaderMap](#headertojsonheadermap) | headerMap defines a map between HTTP header name and corresponding JSON filed name - | Yes | +| headerMap | [][HeaderToJSON.HeaderMap](#headertojsonheadermap) | headerMap defines a map between HTTP header name and corresponding JSON field name | Yes | ### Results @@ -744,6 +745,31 @@ headerMap: | ----------------------- | ------------------------------------ | | jsonEncodeDecodeErr | Failed to convert HTTP headers to JSON. | +## CertExtractor + +CertExtractor extracts a value from requests TLS certificates Subject or Issuer metadata (https://pkg.go.dev/crypto/x509/pkix#Name) and adds the value to headers. Request can contain zero or multiple certificates so the position (first, second, last, etc) of the certificate in the chain is required. + +Here's an example configuration, that adds a new header `tls-cert-postalcode`, based on the PostalCode of the last TLS certificate's Subject: + +```yaml +kind: "CertExtractor" +name: "postalcode-extractor" +certIndex: -1 # take last certificate in chain +target: "subject" +field: "PostalCode" +headerKey: "tls-cert-postalcode" +``` + +### Configuration + +| Name | Type | Description | Required | +| ------------ | -------- | -------------------------------- | -------- | +| certIndex | int16 | The index of the certificate in the chain. Negative indexes from the end of the chain (-1 is the last index, -2 second last etc.) | Yes | +| target | string | Either `subject` or `issuer` of the [x509.Certificate](https://pkg.go.dev/crypto/x509#Certificate) | Yes | +| field | string | One of the string or string slice fields from https://pkg.go.dev/crypto/x509/pkix#Name | Yes | +| headerKey | string | Extracted value is added to this request header key. | Yes | + + ## Common Types ### apiaggregator.Pipeline diff --git a/pkg/filter/certextractor/certextractor.go b/pkg/filter/certextractor/certextractor.go new file mode 100644 index 0000000000..352d0e746e --- /dev/null +++ b/pkg/filter/certextractor/certextractor.go @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package certextractor + +import ( + "crypto/x509/pkix" + "fmt" + + httpcontext "github.com/megaease/easegress/pkg/context" + "github.com/megaease/easegress/pkg/object/httppipeline" +) + +const ( + // Kind is the kind of CertExtractor. + Kind = "CertExtractor" +) + +var results = []string{} + +func init() { + httppipeline.Register(&CertExtractor{}) +} + +type ( + // CertExtractor extracts given field from TLS certificates and sets it to request headers. + CertExtractor struct { + filterSpec *httppipeline.FilterSpec + spec *Spec + + headerKey string + } + + // Spec describes the CertExtractor. + Spec struct { + CertIndex int16 `yaml:"certIndex" jsonschema:"required"` + Target string `yaml:"target" jsonschema:"required,enum=subject,enum=issuer"` + // Different field options listed here https://pkg.go.dev/crypto/x509/pkix#Name + Field string `yaml:"field" jsonschema:"required,enum=Country,enum=Organization,enum=OrganizationalUnit,enum=Locality,enum=Province,enum=StreetAddress,enum=PostalCode,enum=SerialNumber,enum=CommonName"` + HeaderKey string `yaml:"headerKey" jsonschema:"required"` + } +) + +// Validate is dummy as yaml rules already validate Spec. +func (spec *Spec) Validate() error { return nil } + +// Kind returns the kind of CertExtractor. +func (ce *CertExtractor) Kind() string { + return Kind +} + +// DefaultSpec returns the default spec of CertExtractor. +func (ce *CertExtractor) DefaultSpec() interface{} { + return &Spec{} +} + +// Description returns the description of CertExtractor. +func (ce *CertExtractor) Description() string { + return "CertExtractor extracts given field from TLS certificates and sets it to request headers." +} + +// Results returns the results of CertExtractor. +func (ce *CertExtractor) Results() []string { + return results +} + +// Init initializes CertExtractor. +func (ce *CertExtractor) Init(filterSpec *httppipeline.FilterSpec) { + ce.filterSpec, ce.spec = filterSpec, filterSpec.FilterSpec().(*Spec) + + ce.headerKey = fmt.Sprintf("tls-%s-%s", ce.spec.Target, ce.spec.Field) + if ce.spec.HeaderKey != "" { + ce.headerKey = ce.spec.HeaderKey + } +} + +// Inherit inherits previous generation of CertExtractor. +func (ce *CertExtractor) Inherit(filterSpec *httppipeline.FilterSpec, previousGeneration httppipeline.Filter) { + previousGeneration.Close() + ce.Init(filterSpec) +} + +// Close closes CertExtractor. +func (ce *CertExtractor) Close() {} + +// Handle retrieves header values and sets request headers. +func (ce *CertExtractor) Handle(ctx httpcontext.HTTPContext) string { + result := ce.handle(ctx) + return ctx.CallNextHandler(result) +} + +// CertExtractor extracts given field from TLS certificates and sets it to request headers. +func (ce *CertExtractor) handle(ctx httpcontext.HTTPContext) string { + r := ctx.Request() + connectionState := r.Std().TLS + if connectionState == nil { + return "" + } + + certs := connectionState.PeerCertificates + if certs == nil || len(certs) < 1 { + return "" + } + + n := int16(len(certs)) + // positive ce.spec.CertIndex from the beginning, negative from the end + relativeIndex := ce.spec.CertIndex % n + index := (n + relativeIndex) % n + cert := certs[index] + + var target pkix.Name + if ce.spec.Target == "subject" { + target = cert.Subject + } else { + target = cert.Issuer + } + + var result []string + switch ce.spec.Field { + case "Country": + result = target.Country + case "Organization": + result = target.Organization + case "OrganizationalUnit": + result = target.OrganizationalUnit + case "Locality": + result = target.Locality + case "Province": + result = target.Province + case "StreetAddress": + result = target.StreetAddress + case "PostalCode": + result = target.PostalCode + case "SerialNumber": + result = append(result, target.SerialNumber) + case "CommonName": + result = append(result, target.CommonName) + } + for _, res := range result { + if res != "" { + r.Header().Add(ce.headerKey, res) + } + } + return "" +} + +// Status returns status. +func (ce *CertExtractor) Status() interface{} { return nil } diff --git a/pkg/filter/certextractor/certextractor_test.go b/pkg/filter/certextractor/certextractor_test.go new file mode 100644 index 0000000000..b6951ee169 --- /dev/null +++ b/pkg/filter/certextractor/certextractor_test.go @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package certextractor + +import ( + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "net/http" + "os" + "strings" + "testing" + + "github.com/megaease/easegress/pkg/context/contexttest" + "github.com/megaease/easegress/pkg/logger" + "github.com/megaease/easegress/pkg/object/httppipeline" + "github.com/megaease/easegress/pkg/supervisor" + "github.com/megaease/easegress/pkg/util/httpheader" + "github.com/megaease/easegress/pkg/util/yamltool" + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + logger.InitNop() + code := m.Run() + os.Exit(code) +} + +func createCertExtractor( + yamlSpec string, prev *CertExtractor, supervisor *supervisor.Supervisor) (*CertExtractor, error) { + rawSpec := make(map[string]interface{}) + yamltool.Unmarshal([]byte(yamlSpec), &rawSpec) + spec, err := httppipeline.NewFilterSpec(rawSpec, supervisor) + if err != nil { + return nil, err + } + hl := &CertExtractor{} + if prev == nil { + hl.Init(spec) + } else { + hl.Inherit(spec, prev) + } + return hl, nil +} + +const yaml = ` +kind: "CertExtractor" +name: "cn-extractor" +certIndex: -1 +target: "subject" +field: "CommonName" +headerKey: "key" +` + +func TestSpec(t *testing.T) { + assert := assert.New(t) + + const yamlNoKey = ` +kind: "CertExtractor" +name: "cn-extractor" +certIndex: -1 +target: "subject" +field: "CommonName" +` + ce, err := createCertExtractor(yaml, nil, nil) + assert.Nil(err) + assert.NotEqual(ce.Description(), 0) + assert.Nil(err) + assert.NotEqual(ce.Description(), 0) + assert.Nil(ce.Status()) + ce, err = createCertExtractor(yaml, ce, nil) + assert.Nil(err) + assert.Equal(ce.headerKey, "key") + + ce, err = createCertExtractor(yamlNoKey, ce, nil) + assert.Nil(err) + assert.Equal(ce.headerKey, "tls-subject-CommonName") +} + +func prepareCtxAndHeader(connState *tls.ConnectionState) (*contexttest.MockedHTTPContext, http.Header) { + ctx := &contexttest.MockedHTTPContext{} + header := http.Header{} + stdr := &http.Request{} + stdr.TLS = connState + ctx.MockedRequest.MockedStd = func() *http.Request { + return stdr + } + ctx.MockedRequest.MockedHeader = func() *httpheader.HTTPHeader { + return httpheader.New(header) + } + return ctx, header +} + +func TestHandle(t *testing.T) { + assert := assert.New(t) + + t.Run("no TLS", func(t *testing.T) { + ctx := &contexttest.MockedHTTPContext{} + ce, _ := createCertExtractor(yaml, nil, nil) + assert.Equal("", ce.Handle(ctx)) + + peerCertificates := make([]*x509.Certificate, 0) + connState := &tls.ConnectionState{PeerCertificates: peerCertificates} + ctx, _ = prepareCtxAndHeader(connState) + ce, _ = createCertExtractor(yaml, nil, nil) + assert.Equal("", ce.Handle(ctx)) + }) + + t.Run("TLS has no field", func(t *testing.T) { + peerCertificates := make([]*x509.Certificate, 0) + peerCertificates = append(peerCertificates, &x509.Certificate{ + Subject: pkix.Name{}, + Issuer: pkix.Name{}, + }) + connState := &tls.ConnectionState{PeerCertificates: peerCertificates} + ctx, _ := prepareCtxAndHeader(connState) + ce, _ := createCertExtractor(yaml, nil, nil) + assert.Equal("", ce.Handle(ctx)) + }) + + t.Run("TLS has field", func(t *testing.T) { + peerCertificates := make([]*x509.Certificate, 0) + peerCertificates = append(peerCertificates, &x509.Certificate{ + Subject: pkix.Name{ + Country: []string{"1"}, + Organization: []string{"2"}, + OrganizationalUnit: []string{"3"}, + Locality: []string{"4"}, + Province: []string{"5"}, + StreetAddress: []string{"6"}, + PostalCode: []string{"7"}, + SerialNumber: "8", + CommonName: "INFO-1", + }, + Issuer: pkix.Name{ + CommonName: "INFO-2", + }, + }) + connState := &tls.ConnectionState{PeerCertificates: peerCertificates} + t.Run("subject", func(t *testing.T) { + ctx, header := prepareCtxAndHeader(connState) + subjectYaml := yaml + ce, _ := createCertExtractor(subjectYaml, nil, nil) + assert.Equal("", ce.handle(ctx)) + assert.Equal("INFO-1", header.Get("key")) + }) + + t.Run("issuer", func(t *testing.T) { + ctx, header := prepareCtxAndHeader(connState) + issuerYaml := strings.ReplaceAll(yaml, `target: "subject"`, `target: "issuer"`) + ce, _ := createCertExtractor(issuerYaml, nil, nil) + assert.Equal("", ce.handle(ctx)) + assert.Equal("INFO-2", header.Get("key")) + }) + + t.Run("test all fields", func(t *testing.T) { + ctx, header := prepareCtxAndHeader(connState) + fields := []string{"Country", "Organization", "OrganizationalUnit", "Locality", + "Province", "StreetAddress", "PostalCode", "SerialNumber", + } + for _, fieldName := range fields { + ce, _ := createCertExtractor( + strings.ReplaceAll(yaml, `field: "CommonName"`, fmt.Sprintf(`field: "%s"`, fieldName)), + nil, + nil, + ) + assert.Equal("", ce.Handle(ctx)) + } + assert.Equal([]string{"1", "2", "3", "4", "5", "6", "7", "8"}, header["Key"]) + }) + t.Run("multiple certs", func(t *testing.T) { + for _, val := range []string{"second", "third", "fourth"} { + peerCertificates = append(peerCertificates, &x509.Certificate{ + Subject: pkix.Name{ + Province: []string{val}, + }, + }) + + } + connState := &tls.ConnectionState{PeerCertificates: peerCertificates} + ctx, header := prepareCtxAndHeader(connState) + yamlConfig := ` +kind: "CertExtractor" +name: "cn-extractor" +certIndex: -2 # second last certificate +target: "subject" +field: "Province" +` + ce, _ := createCertExtractor(yamlConfig, nil, nil) + assert.Equal("", ce.Handle(ctx)) + assert.Equal("third", header.Get("tls-subject-province")) + + ctx, header = prepareCtxAndHeader(connState) + yamlConfig2 := strings.ReplaceAll(yamlConfig, "certIndex: -2", "certIndex: -15") + ce, _ = createCertExtractor(yamlConfig2, nil, nil) + assert.Equal("", ce.Handle(ctx)) + assert.Equal("second", header.Get("tls-subject-province")) + }) + }) +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 094f47315f..547af05652 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -22,6 +22,7 @@ import ( // Filters _ "github.com/megaease/easegress/pkg/filter/apiaggregator" _ "github.com/megaease/easegress/pkg/filter/bridge" + _ "github.com/megaease/easegress/pkg/filter/certextractor" _ "github.com/megaease/easegress/pkg/filter/circuitbreaker" _ "github.com/megaease/easegress/pkg/filter/connectcontrol" _ "github.com/megaease/easegress/pkg/filter/corsadaptor"