Skip to content

Commit

Permalink
Adds logic to set and delete headers via Ingress Operator and Route.
Browse files Browse the repository at this point in the history
  • Loading branch information
miheer committed Jan 13, 2023
1 parent 2df9591 commit 975a0d9
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 9 deletions.
35 changes: 35 additions & 0 deletions images/router/haproxy/conf/haproxy-config.template
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,14 @@ frontend public
capture cookie {{ $captureCookie.Name }}{{ if eq $captureCookie.MatchType "exact" }}={{ end }} len {{ $captureCookie.MaxLength }}
{{- end }}

{{- range $idx, $http_response_header := .HTTPResponseHeaders }}
{{- if eq $http_response_header.Action "Set" }}
http-response set-header {{ $http_response_header.Name }} {{ $http_response_header.Value }}
{{- else if eq $http_response_header.Action "Delete" }}
http-response del-header {{ $http_response_header.Name }}
{{- end }}
{{- end }}

# Strip off Proxy headers to prevent HTTpoxy (https://httpoxy.org/)
http-request del-header Proxy

Expand Down Expand Up @@ -305,6 +313,15 @@ frontend fe_sni
capture cookie {{ $captureCookie.Name }}{{ if eq $captureCookie.MatchType "exact" }}={{ end }} len {{ $captureCookie.MaxLength }}
{{- end }}


{{- range $idx, $http_response_header := .HTTPResponseHeaders }}
{{- if eq $http_response_header.Action "Set" }}
http-response set-header {{ $http_response_header.Name }} {{ $http_response_header.Value }}
{{- else if eq $http_response_header.Action "Delete" }}
http-response del-header {{ $http_response_header.Name }}
{{- end }}
{{- end }}

# Strip off Proxy headers to prevent HTTpoxy (https://httpoxy.org/)
http-request del-header Proxy

Expand Down Expand Up @@ -391,6 +408,14 @@ frontend fe_no_sni
capture cookie {{ $captureCookie.Name }}{{ if eq $captureCookie.MatchType "exact" }}={{ end }} len {{ $captureCookie.MaxLength }}
{{- end }}

{{- range $idx, $http_response_header := .HTTPResponseHeaders }}
{{- if eq $http_response_header.Action "Set" }}
http-response set-header {{ $http_response_header.Name }} {{ $http_response_header.Value }}
{{- else if eq $http_response_header.Action "Delete" }}
http-response del-header {{ $http_response_header.Name }}
{{- end }}
{{- end }}

# Strip off Proxy headers to prevent HTTpoxy (https://httpoxy.org/)
http-request del-header Proxy

Expand Down Expand Up @@ -612,6 +637,16 @@ backend {{ genBackendNamePrefix $cfg.TLSTermination }}:{{ $cfgIdx }}
{{- end }}{{/* hsts header */}}
{{- end }}{{/* is "edge" or "reencrypt" */}}

{{- if matchValues (print $cfg.TLSTermination) "edge" "reencrypt" }}
{{- range $idx, $http_response_header := $cfg.HTTPResponseHeaders }}
{{- if eq $http_response_header.Action "Set" }}
http-response set-header {{ $http_response_header.Name }} {{ $http_response_header.Value }}
{{- else if eq $http_response_header.Action "Delete" }}
http-response del-header {{ $http_response_header.Name }}
{{- end }}
{{- end }}
{{- end }}{{/* is "edge" or "reencrypt" */}}

{{- range $serviceUnitName, $weight := $cfg.ServiceUnitNames }}
{{- if ge $weight 0 }}{{/* weight=0 is reasonable to keep existing connections to backends with cookies as we can see the HTTP headers */}}
{{- with $serviceUnit := index $.ServiceUnits $serviceUnitName }}
Expand Down
75 changes: 75 additions & 0 deletions pkg/cmd/infra/router/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ type TemplateRouter struct {
CaptureHTTPCookie *templateplugin.CaptureHTTPCookie
HTTPHeaderNameCaseAdjustmentsString string
HTTPHeaderNameCaseAdjustments []templateplugin.HTTPHeaderNameCaseAdjustment
HTTPResponseHeadersString string
HTTPResponseHeaders []templateplugin.HTTPResponseHeader

TemplateRouterConfigManager
}
Expand Down Expand Up @@ -182,6 +184,7 @@ func (o *TemplateRouter) Bind(flag *pflag.FlagSet) {
flag.StringVar(&o.CaptureHTTPResponseHeadersString, "capture-http-response-headers", env("ROUTER_CAPTURE_HTTP_RESPONSE_HEADERS", ""), "A comma-delimited list of HTTP response header names and maximum header value lengths that should be captured for logging. Each item must have the following form: name:maxLength")
flag.StringVar(&o.CaptureHTTPCookieString, "capture-http-cookie", env("ROUTER_CAPTURE_HTTP_COOKIE", ""), "Name and maximum length of HTTP cookie that should be captured for logging. The argument must have the following form: name:maxLength. Append '=' to the name to indicate that an exact match should be performed; otherwise a prefix match will be performed. The value of first cookie that matches the name is captured.")
flag.StringVar(&o.HTTPHeaderNameCaseAdjustmentsString, "http-header-name-case-adjustments", env("ROUTER_H1_CASE_ADJUST", ""), "A comma-delimited list of HTTP header names that should have their case adjusted. Each item must be a valid HTTP header name and should have the desired capitalization.")
flag.StringVar(&o.HTTPResponseHeadersString, "set-delete-http-response-header", env("ROUTER_HTTP_RESPONSE_HEADERS", ""), "A comma-delimited list of HTTP header names and values that should be set/deleted.")
}

type RouterStats struct {
Expand Down Expand Up @@ -287,6 +290,71 @@ func parseCaptureHeaders(in string) ([]templateplugin.CaptureHTTPHeader, error)
return captureHeaders, nil
}

func parseHeadersToBeSetOrDeleted(in string) ([]templateplugin.HTTPResponseHeader, error) {
var captureHeaders []templateplugin.HTTPResponseHeader
var capture templateplugin.HTTPResponseHeader
in, err := url.QueryUnescape(in)
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", in)
}
if len(in) > 0 {
for _, header := range strings.Split(in, ",") {
parts := strings.Split(header, ":")
if len(parts) < 2 {
return captureHeaders, fmt.Errorf("invalid HTTP header input specification: %v", header)
}
if len(parts) == 3 {
headerName, err := url.QueryUnescape(parts[0])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", headerName)
}
// RFC 2616, section 4.2, states that the header name
// must be a valid token.
if !validTokenRE.MatchString(headerName) {
return captureHeaders, fmt.Errorf("invalid HTTP header name: %v", headerName)
}
headerValue, err := url.QueryUnescape(parts[1])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", headerValue)
}
action, err := url.QueryUnescape(parts[2])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", action)
}
capture = templateplugin.HTTPResponseHeader{
Name: headerName,
Value: headerValue,
Action: action,
}
captureHeaders = append(captureHeaders, capture)
}
if len(parts) == 2 {
headerName, err := url.QueryUnescape(parts[0])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", headerName)
}
// RFC 2616, section 4.2, states that the header name
// must be a valid token.
if !validTokenRE.MatchString(headerName) {
return captureHeaders, fmt.Errorf("invalid HTTP header name: %v", headerName)
}

action, err := url.QueryUnescape(parts[2])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", action)
}
capture = templateplugin.HTTPResponseHeader{
Name: headerName,
Action: action,
}
captureHeaders = append(captureHeaders, capture)
}
}
}

return captureHeaders, nil
}

func parseCaptureCookie(in string) (*templateplugin.CaptureHTTPCookie, error) {
if len(in) == 0 {
return nil, nil
Expand Down Expand Up @@ -393,6 +461,12 @@ func (o *TemplateRouterOptions) Complete() error {
}
o.CaptureHTTPResponseHeaders = captureHTTPResponseHeaders

httpResponseHeaders, err := parseHeadersToBeSetOrDeleted(o.HTTPResponseHeadersString)
if err != nil {
return err
}
o.HTTPResponseHeaders = httpResponseHeaders

captureHTTPCookie, err := parseCaptureCookie(o.CaptureHTTPCookieString)
if err != nil {
return err
Expand Down Expand Up @@ -648,6 +722,7 @@ func (o *TemplateRouterOptions) Run(stopCh <-chan struct{}) error {
CaptureHTTPResponseHeaders: o.CaptureHTTPResponseHeaders,
CaptureHTTPCookie: o.CaptureHTTPCookie,
HTTPHeaderNameCaseAdjustments: o.HTTPHeaderNameCaseAdjustments,
HTTPResponseHeaders: o.HTTPResponseHeaders,
}

svcFetcher := templateplugin.NewListWatchServiceLookup(kc.CoreV1(), o.ResyncInterval, o.Namespace)
Expand Down
2 changes: 2 additions & 0 deletions pkg/router/template/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type TemplatePluginConfig struct {
CaptureHTTPResponseHeaders []CaptureHTTPHeader
CaptureHTTPCookie *CaptureHTTPCookie
HTTPHeaderNameCaseAdjustments []HTTPHeaderNameCaseAdjustment
HTTPResponseHeaders []HTTPResponseHeader
}

// RouterInterface controls the interaction of the plugin with the underlying router implementation
Expand Down Expand Up @@ -160,6 +161,7 @@ func NewTemplatePlugin(cfg TemplatePluginConfig, lookupSvc ServiceLookup) (*Temp
captureHTTPResponseHeaders: cfg.CaptureHTTPResponseHeaders,
captureHTTPCookie: cfg.CaptureHTTPCookie,
httpHeaderNameCaseAdjustments: cfg.HTTPHeaderNameCaseAdjustments,
httpResponseHeaders: cfg.HTTPResponseHeaders,
}
router, err := newTemplateRouter(templateRouterCfg)
return newDefaultTemplatePlugin(router, cfg.IncludeUDP, lookupSvc), err
Expand Down
46 changes: 38 additions & 8 deletions pkg/router/template/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ type templateRouter struct {
captureHTTPCookie *CaptureHTTPCookie
// httpHeaderNameCaseAdjustments specifies HTTP header name case adjustments.
httpHeaderNameCaseAdjustments []HTTPHeaderNameCaseAdjustment

httpResponseHeaders []HTTPResponseHeader
}

// templateRouterCfg holds all configuration items required to initialize the template router
Expand All @@ -144,6 +146,7 @@ type templateRouterCfg struct {
captureHTTPResponseHeaders []CaptureHTTPHeader
captureHTTPCookie *CaptureHTTPCookie
httpHeaderNameCaseAdjustments []HTTPHeaderNameCaseAdjustment
httpResponseHeaders []HTTPResponseHeader
}

// templateConfig is a subset of the templateRouter information that should be passed to the template for generating
Expand Down Expand Up @@ -183,6 +186,7 @@ type templateData struct {
// HTTPHeaderNameCaseAdjustments specifies HTTP header name adjustments
// performed on HTTP headers.
HTTPHeaderNameCaseAdjustments []HTTPHeaderNameCaseAdjustment
HTTPResponseHeaders []HTTPResponseHeader
}

func newTemplateRouter(cfg templateRouterCfg) (*templateRouter, error) {
Expand Down Expand Up @@ -244,6 +248,7 @@ func newTemplateRouter(cfg templateRouterCfg) (*templateRouter, error) {
captureHTTPResponseHeaders: cfg.captureHTTPResponseHeaders,
captureHTTPCookie: cfg.captureHTTPCookie,
httpHeaderNameCaseAdjustments: cfg.httpHeaderNameCaseAdjustments,
httpResponseHeaders: cfg.httpResponseHeaders,

metricReload: metricsReload,
metricReloadFailure: metricReloadFailure,
Expand Down Expand Up @@ -582,6 +587,7 @@ func (r *templateRouter) writeConfig() error {
CaptureHTTPResponseHeaders: r.captureHTTPResponseHeaders,
CaptureHTTPCookie: r.captureHTTPCookie,
HTTPHeaderNameCaseAdjustments: r.httpHeaderNameCaseAdjustments,
HTTPResponseHeaders: r.httpResponseHeaders,
}
if err := template.Execute(file, data); err != nil {
file.Close()
Expand Down Expand Up @@ -922,16 +928,39 @@ func (r *templateRouter) createServiceAliasConfig(route *routev1.Route, backendK
activeServiceUnits++
}
}
var httpResponseHeadersList []HTTPResponseHeader

if route.Spec.HTTPHeaders != nil && route.Spec.HTTPHeaders.HeadersManipulation.Response != nil && len(route.Spec.HTTPHeaders.HeadersManipulation.Response) != 0 {
for _, value := range route.Spec.HTTPHeaders.HeadersManipulation.Response {
if value.Value.Action == routev1.Set && value.Value.Set != nil && len(value.Name) != 0 {

setValue := HTTPResponseHeader{
Name: value.Name,
Value: value.Value.Set.Value,
Action: string(value.Value.Action),
}
httpResponseHeadersList = append(httpResponseHeadersList, setValue)
}
if value.Value.Action == routev1.Delete && len(value.Name) != 0 {
deleteValue := HTTPResponseHeader{
Name: value.Name,
Action: string(value.Value.Action),
}
httpResponseHeadersList = append(httpResponseHeadersList, deleteValue)
}
}
}

config := ServiceAliasConfig{
Name: route.Name,
Namespace: route.Namespace,
Host: route.Spec.Host,
Path: route.Spec.Path,
IsWildcard: wildcard,
Annotations: route.Annotations,
ServiceUnits: serviceUnits,
ActiveServiceUnits: activeServiceUnits,
Name: route.Name,
Namespace: route.Namespace,
Host: route.Spec.Host,
Path: route.Spec.Path,
IsWildcard: wildcard,
Annotations: route.Annotations,
ServiceUnits: serviceUnits,
ActiveServiceUnits: activeServiceUnits,
HTTPResponseHeaders: httpResponseHeadersList,
}

if route.Spec.Port != nil {
Expand Down Expand Up @@ -1365,6 +1394,7 @@ func configsAreEqual(config1, config2 *ServiceAliasConfig) bool {
config1.RoutingKeyName == config2.RoutingKeyName &&
config1.IsWildcard == config2.IsWildcard &&
config1.VerifyServiceHostname == config2.VerifyServiceHostname &&
reflect.DeepEqual(config1.HTTPResponseHeaders, config2.HTTPResponseHeaders) &&
reflect.DeepEqual(config1.Annotations, config2.Annotations) &&
reflect.DeepEqual(config1.ServiceUnits, config2.ServiceUnits)
}
Expand Down
46 changes: 45 additions & 1 deletion pkg/router/template/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,9 @@ func TestCreateServiceAliasConfig(t *testing.T) {
serviceName := "TestService"
serviceWeight := int32(0)

var headerNameXFrame string = "X-Frame-Options"
var headerNameXSS string = "X-XSS-Protection"

route := &routev1.Route{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Expand All @@ -382,6 +385,45 @@ func TestCreateServiceAliasConfig(t *testing.T) {
CACertificate: "ghi",
DestinationCACertificate: "jkl",
},
HTTPHeaders: &routev1.RouteHTTPHeaders{

HeadersManipulation: routev1.RouteHTTPHeadersManipulation{
Response: []routev1.RouteSetOrDeleteHTTPHeader{
{
Name: headerNameXFrame,
Value: routev1.RouteSetOrDeleteHTTPHeaderUnion{
Action: routev1.Set,
Set: &routev1.RouteSetHTTPHeader{
Value: "DENY",
},
},
},
{
Name: headerNameXSS,
Value: routev1.RouteSetOrDeleteHTTPHeaderUnion{
Action: routev1.Set,
Set: &routev1.RouteSetHTTPHeader{
Value: "1;mode=block",
},
},
},
{

Name: headerNameXFrame,
Value: routev1.RouteSetOrDeleteHTTPHeaderUnion{
Action: "Delete",
},
},
{

Name: headerNameXSS,
Value: routev1.RouteSetOrDeleteHTTPHeaderUnion{
Action: "Delete",
},
},
},
},
},
},
}

Expand All @@ -392,10 +434,12 @@ func TestCreateServiceAliasConfig(t *testing.T) {
suName: serviceWeight,
}

httpResponseHeadersList := []HTTPResponseHeader{{Name: "X-Frame-Options", Value: "DENY", Action: "Set"}, {Name: "X-XSS-Protection", Value: "1;mode=block", Action: "Set"},
{Name: "X-Frame-Options", Action: "Delete"}, {Name: "X-XSS-Protection", Action: "Delete"}}
// Basic sanity, validate more fields as necessary
if config.Host != route.Spec.Host || config.Path != route.Spec.Path || !compareTLS(route, config, t) ||
config.PreferPort != route.Spec.Port.TargetPort.String() || !reflect.DeepEqual(expectedSUs, config.ServiceUnits) ||
config.ActiveServiceUnits != 0 {
!reflect.DeepEqual(config.HTTPResponseHeaders, httpResponseHeadersList) || config.ActiveServiceUnits != 0 {
t.Errorf("Route %v did not match service alias config %v", route, config)
}

Expand Down
15 changes: 15 additions & 0 deletions pkg/router/template/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ type ServiceAliasConfig struct {

// ActiveEndpoints is a count of the route endpoints that are part of a service unit with a non-zero weight
ActiveEndpoints int

// For route based setting of headers
HTTPResponseHeaders []HTTPResponseHeader
}

type ServiceAliasConfigStatus string
Expand Down Expand Up @@ -236,6 +239,18 @@ type CaptureHTTPHeader struct {
MaxLength int
}

// HTTPResponseHeader specifies an HTTP header that should be set/replace/append
type HTTPResponseHeader struct {
// Name specifies an HTTP header name.
Name string

// Value specifies the header value.
Value string

// Action specifies the action to be performed.
Action string
}

// CaptureHTTPCookie specifies an HTTP cookie that should be captured
// for access logs.
type CaptureHTTPCookie struct {
Expand Down

0 comments on commit 975a0d9

Please sign in to comment.