Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NE-1141: Adds logic for setting and deleting headers via Ingress Operator CR and Route Object. #438

Merged
merged 8 commits into from
Aug 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/gocarina/gocsv v0.0.0-20190927101021-3ecffd272576
github.com/google/go-cmp v0.5.9
github.com/haproxytech/config-parser/v4 v4.0.0-rc1
github.com/openshift/api v0.0.0-20230120195050-6ba31fa438f2
github.com/openshift/api v0.0.0-20230807132801-600991d550ac
github.com/openshift/client-go v0.0.0-20230120202327-72f107311084
github.com/openshift/library-go v0.0.0-20230120202744-256994f916c4
github.com/prometheus/client_golang v1.14.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/onsi/ginkgo/v2 v2.9.1 h1:zie5Ly042PD3bsCvsSOPvRnFwyo3rKe64TJlD6nu0mk=
github.com/onsi/gomega v1.27.4 h1:Z2AnStgsdSayCMDiCU42qIz+HLqEPcgiOCXjAU/w+8E=
github.com/openshift/api v0.0.0-20230120195050-6ba31fa438f2 h1:+nw0/d4spq880W7S74Twi5YU2ulsl3/a9o4OEZptYp0=
github.com/openshift/api v0.0.0-20230120195050-6ba31fa438f2/go.mod h1:ctXNyWanKEjGj8sss1KjjHQ3ENKFm33FFnS5BKaIPh4=
github.com/openshift/api v0.0.0-20230807132801-600991d550ac h1:HqT8MmYGXiUGUW0BjygTGOOvqO2wIsTaG3q8nboJyPY=
github.com/openshift/api v0.0.0-20230807132801-600991d550ac/go.mod h1:yimSGmjsI+XF1mr+AKBs2//fSXIOhhetHGbMlBEfXbs=
github.com/openshift/client-go v0.0.0-20230120202327-72f107311084 h1:66uaqNwA+qYyQDwsMWUfjjau8ezmg1dzCqub13KZOcE=
github.com/openshift/client-go v0.0.0-20230120202327-72f107311084/go.mod h1:M3h9m001PWac3eAudGG3isUud6yBjr5XpzLYLLTlHKo=
github.com/openshift/library-go v0.0.0-20230120202744-256994f916c4 h1:cFYg18OROQMHlrGWL9HpV1elDKbnRFLz/ED5VvP3qvw=
Expand Down
63 changes: 63 additions & 0 deletions images/router/haproxy/conf/haproxy-config.template
Copy link
Contributor

@candita candita Jun 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't you set/delete the headers on frontend public_ssl ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The connection is still encrypted in public_ssl, so it is not possible to examine or modify the HTTP session there. The fe_sni and fe_no_sni frontends terminate TLS.

Here's my attempt at a diagram showing the transitions between cleartext HTTP and TLS:

graph LR;
  client-->|HTTP|public;
  public-->|HTTP|be_http["be_http:namespace:application<br/>(non-TLS route)"];
  client-->|TLS|public_ssl;
  fe_sni-->|HTTP|be_edge["be_edge_http:namespace:application<br/>(edge-terminated TLS)"];
  fe_no_sni-->|HTTP|be_edge;
  public_ssl-->|TLS|be_sni;
  public_ssl-->|TLS|be_no_sni;
  be_sni-->|TLS|fe_sni;
  be_no_sni-->|TLS|fe_no_sni;
  fe_sni-->|TLS|be_reencrypt["be_secure_http:namespace:application<br/>(reencrypt TLS)"];
  fe_sni-->|TLS|be_reencrypt;
  public_ssl-->|TLS|be_tcp["be_tcp:namespace:application<br/>(passthrough TLS)"];
  classDef cleartext fill:#1f1
  classDef encrypted fill:#f11,color:#fff;
  class public,fe_sni,fe_no_sni,be_http,be_edge cleartext;
  class public_ssl,be_sni,be_no_sni,be_reencrypt,be_tcp encrypted;

Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,22 @@ frontend public
acl secure_redirect base,map_reg_int(/var/lib/haproxy/conf/os_route_http_redirect.map) -m bool
redirect scheme https if secure_redirect

{{- range $idx, $http_request_header := .HTTPRequestHeaders }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really understand why we need this on frontend public. Can you help me understand?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, why don't we forbid adding "Proxy" header, which we explicitly delete in https://github.com/openshift/router/pull/438/files#diff-21bd64a4ad8a7927e9d59e99e09628efc884fe6d6291e50eb33c5deb3d2a79f0R221 ? We also make sure the "Host" is lower case, and we aren't disallowing the user to change that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you made changes elsewhere for this, and it would be helpful if you addressed the comments instead of leaving them open.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have addressed this in the "Risks and Mitigations" section of the EP openshift/enhancements#1296.

{{- if eq $http_request_header.Action "Set" }}
http-request set-header {{ $http_request_header.Name }} {{ $http_request_header.Value }}
{{- else if eq $http_request_header.Action "Delete" }}
http-request del-header {{ $http_request_header.Name }}
{{- end }}
{{- 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 }}

use_backend %[base,map_reg(/var/lib/haproxy/conf/os_http_be.map)]

default_backend openshift_default
Expand Down Expand Up @@ -355,6 +371,22 @@ frontend fe_sni
http-request set-header X-SSL-Client-DER %{+Q}[ssl_c_der,base64]
{{- end }}

{{- range $idx, $http_request_header := .HTTPRequestHeaders }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, there are so many headers we explicitly set here, why would we allow the user to change those?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section is set by the admin via ingress controller so I think this shall be his responsibility to set the headers correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{{- if eq $http_request_header.Action "Set" }}
http-request set-header {{ $http_request_header.Name }} {{ $http_request_header.Value }}
{{- else if eq $http_request_header.Action "Delete" }}
http-request del-header {{ $http_request_header.Name }}
{{- end }}
{{- 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 }}

# map to backend
# Search from most specific to general path (host case).
# Note: If no match, haproxy uses the default_backend, no other
Expand Down Expand Up @@ -439,6 +471,22 @@ frontend fe_no_sni
http-request set-header X-SSL-Client-DER %{+Q}[ssl_c_der,base64]
{{- end }}

{{- range $idx, $http_request_header := .HTTPRequestHeaders }}
{{- if eq $http_request_header.Action "Set" }}
http-request set-header {{ $http_request_header.Name }} {{ $http_request_header.Value }}
{{- else if eq $http_request_header.Action "Delete" }}
http-request del-header {{ $http_request_header.Name }}
{{- end }}
{{- 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 }}

# map to backend
# Search from most specific to general path (host case).
# Note: If no match, haproxy uses the default_backend, no other
Expand Down Expand Up @@ -624,6 +672,21 @@ backend {{ genBackendNamePrefix $cfg.TLSTermination }}:{{ $cfgIdx }}
{{- end }}{{/* hsts header */}}
{{- end }}{{/* is "edge" or "reencrypt" */}}

{{- range $idx, $http_request_header := $cfg.HTTPRequestHeaders }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a block above:
{{- if matchValues (print $cfg.TLSTermination) "edge" "reencrypt" }}, and I think the code that looks at headers needs to be within that block, not outside of it, since this does not apply to passthrough termination.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@candita line

{{- if matchValues (print $cfg.TLSTermination) "" "edge" "reencrypt" }}
already checks it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines 662 and 669 check it again, so it looks like a mistake that it isn't checked here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/openshift/router/pull/438/files#diff-21bd64a4ad8a7927e9d59e99e09628efc884fe6d6291e50eb33c5deb3d2a79f0L684

is the end of the block which allows the non-edge or non-re-encrypt, edge and re-encrypt route except passthrough.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see lines 662 and 669 are checking for edge, reencrypt, whereas line 528 checks for none, edge, reencrypt.

{{- if eq $http_request_header.Action "Set" }}
http-request set-header {{ $http_request_header.Name }} {{ $http_request_header.Value }}
{{- else if eq $http_request_header.Action "Delete" }}
http-request del-header {{ $http_request_header.Name }}
{{- end }}
{{- end }}
{{- 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 }}

{{- 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
124 changes: 124 additions & 0 deletions pkg/cmd/infra/router/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ type TemplateRouter struct {
CaptureHTTPCookie *templateplugin.CaptureHTTPCookie
HTTPHeaderNameCaseAdjustmentsString string
HTTPHeaderNameCaseAdjustments []templateplugin.HTTPHeaderNameCaseAdjustment
HTTPResponseHeadersString string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer if there were different names for the ingress controller headers vs the route headers, especially when you need to handle these and overrides in the template. Could you rename the variables to have prefixes Controller or Route where applicable?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These fields in TemplateRouter have configuration that ultimately came from the IngressController object. The route configuration is on the ServiceAliasConfig object. See https://github.com/openshift/enhancements/blob/master/enhancements/ingress/set-delete-http-headers.md#implementation-detailsnotesconstraints.

HTTPResponseHeaders []templateplugin.HTTPHeader
HTTPRequestHeadersString string
HTTPRequestHeaders []templateplugin.HTTPHeader

TemplateRouterConfigManager
}
Expand Down Expand Up @@ -182,6 +186,8 @@ 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 response header names and values that should be set/deleted.")
flag.StringVar(&o.HTTPRequestHeadersString, "set-delete-http-request-header", env("ROUTER_HTTP_REQUEST_HEADERS", ""), "A comma-delimited list of HTTP request header names and values that should be set/deleted.")
Comment on lines +189 to +190
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these flags are discussed in the EP. These flags would only be applicable to the controller headers, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ROUTER_HTTP_REQUEST_HEADERS and ROUTER_HTTP_RESPONSE_HEADERS are described under https://github.com/openshift/enhancements/blob/master/enhancements/ingress/set-delete-http-headers.md#implementation-detailsnotesconstraints. The corresponding flags are not explicitly mentioned on the EP, but we usually add a flag for each environment variable that is used by the controller itself (as opposed to variables that are used in the template).

}

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

func parseHeadersToBeSetOrDeleted(in string) ([]templateplugin.HTTPHeader, error) {
var captureHeaders []templateplugin.HTTPHeader
var capture templateplugin.HTTPHeader
var err error
if len(in) == 0 {
return captureHeaders, fmt.Errorf("encoded header string not present")
}
if len(in) > 0 {
for _, header := range strings.Split(in, ",") {
parts := strings.Split(header, ":")
num := len(parts)
switch num {
default:
return captureHeaders, fmt.Errorf("invalid HTTP header input specification: %v", header)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should have a unit test for anything that could produce an error. I think you need three more unit tests, as there is only one: TestPercentDecodingForHeaders.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't necessarily need to test all possible paths, but at least test for 1. token errors and 2. format errors that produce len(parts) < 2 and 3. error on decode.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes added.

case 3:
{
headerName, err := url.QueryUnescape(parts[0])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", parts[0])
}
err = checkValidHeaderName(headerName)
if err != nil {
return captureHeaders, err
}
sanitizedHeaderName := templateplugin.SanitizeHeaderValue(headerName)
headerValue, err := url.QueryUnescape(parts[1])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", parts[1])
}
sanitizedHeaderValue := templateplugin.SanitizeHeaderValue(headerValue)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the header value have to be sanitized? If it has a valid reason, why isn't the header name or action sanitized?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header name goes through a regex check so a user can't provide an invalid value.
Header action also only 2 values are allowed via api so this check happens at api level.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SanitizeHeaderValue is escaping single quotes, which we allow in the header name per the regex. Why don't we allow single quotes in the header value?

Copy link
Contributor

@Miciah Miciah Aug 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SanitizeHeaderValue doesn't actually sanitize, but rather it quotes the string (for this reason, I've suggested changing "sanitize" to "quote" a few times). I agree, it certainly seems like the header name can contain ' and therefore needs to be quoted in the configuration file, just as the value needs to be quoted.

action, err := url.QueryUnescape(parts[2])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", parts[2])
}
err = checkValidAction(action)
if err != nil {
return captureHeaders, err
}
capture = templateplugin.HTTPHeader{
Name: sanitizedHeaderName,
Value: sanitizedHeaderValue,
Action: routev1.RouteHTTPHeaderActionType(action),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this going to panic if action is not a true ActionType?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i have written a function checkValidAction

}
captureHeaders = append(captureHeaders, capture)
}
case 2:
{
headerName, err := url.QueryUnescape(parts[0])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", parts[0])
}
err = checkValidHeaderName(headerName)
if err != nil {
return captureHeaders, err
}
sanitizedHeaderName := templateplugin.SanitizeHeaderValue(headerName)
action, err := url.QueryUnescape(parts[1])
if err != nil {
return captureHeaders, fmt.Errorf("failed to decode percent encoding: %v", parts[1])
}
err = checkValidAction(action)
if err != nil {
return captureHeaders, err
}
capture = templateplugin.HTTPHeader{
Name: sanitizedHeaderName,
Action: routev1.RouteHTTPHeaderActionType(action),
}
captureHeaders = append(captureHeaders, capture)
}
}
}
}

return captureHeaders, err
}

func checkValidAction(action string) error {
if action != string(routev1.Set) && action != string(routev1.Delete) {
return fmt.Errorf("invalid action: %s", action)
} else {
return nil
}
}

// permittedHeaderNameRE is a compiled regexp to match an HTTP header name
// as specified in RFC 2616, section 4.2.
// Any changes made to regex of header name in route type in openshift/api and `validation.go` file in library-go must be reflected here and
// vice versa.
var permittedHeaderNameRE = regexp.MustCompile("^[-!#$%&'*+.0-9A-Z^_`a-z|~]+$")

// checkValidHeaderName verifies that the given HTTP header name is valid.
func checkValidHeaderName(headerName string) error {
if !permittedHeaderNameRE.MatchString(headerName) {
return fmt.Errorf("invalid HTTP header name: %s", headerName)
} else {
return nil
}
}

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

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

if len(o.HTTPRequestHeadersString) != 0 {
httpRequestHeaders, err := parseHeadersToBeSetOrDeleted(o.HTTPRequestHeadersString)
if err != nil {
return err
}
o.HTTPRequestHeaders = httpRequestHeaders
}

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

svcFetcher := templateplugin.NewListWatchServiceLookup(kc.CoreV1(), o.ResyncInterval, o.Namespace)
Expand Down