Skip to content

Commit

Permalink
add regex matching for headers and query params
Browse files Browse the repository at this point in the history
  • Loading branch information
salonichf5 committed Feb 5, 2025
1 parent 6dc85d7 commit a29b3ee
Show file tree
Hide file tree
Showing 18 changed files with 577 additions and 111 deletions.
18 changes: 18 additions & 0 deletions examples/advanced-routing/cafe-routes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ spec:
backendRefs:
- name: coffee-v2-svc
port: 80
- matches:
- path:
type: PathPrefix
value: /coffee
headers:
- name: headerRegex
type: RegularExpression
value: "header-[a-z]{1}"
- path:
type: PathPrefix
value: /coffee
queryParams:
- name: queryRegex
type: RegularExpression
value: "query-[a-z]{1}"
backendRefs:
- name: coffee-v3-svc
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
Expand Down
33 changes: 33 additions & 0 deletions examples/advanced-routing/coffee.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,36 @@ spec:
name: http
selector:
app: coffee-v2
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: coffee-v3
spec:
replicas: 1
selector:
matchLabels:
app: coffee-v3
template:
metadata:
labels:
app: coffee-v3
spec:
containers:
- name: coffee-v3
image: nginxdemos/nginx-hello:plain-text
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: coffee-v3-svc
spec:
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: http
selector:
app: coffee-v3
14 changes: 14 additions & 0 deletions examples/grpc-routing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,20 @@ There are 3 options to configure gRPC routing. To access the application and tes
2024/04/29 09:32:46 Received: version two
```
We'll send a request with the header `headerRegex: grpc-header-a`
```shell
grpcurl -plaintext -proto grpc.proto -authority bar.com -d '{"name": "version two regex"}' -H 'headerRegex: grpc-header-a' ${GW_IP}:${GW_PORT} helloworld.Greeter/SayHello
```
```text
{
"message": "Hello version two regex"
}
```
Verify logs of `${POD_V2}` to ensure response is from the correct service.
Finally, we'll send a request with the headers `version: two` and `color: orange`
```shell
Expand Down
9 changes: 9 additions & 0 deletions examples/grpc-routing/headers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ spec:
backendRefs:
- name: grpc-infra-backend-v2
port: 8080
# Matches "headerRegex: grpc-header-[a-z]{1}"
- matches:
- headers:
- name: headerRegex
value: "grpc-header-[a-z]{1}"
type: RegularExpression
backendRefs:
- name: grpc-infra-backend-v2
port: 8080
# Matches "version: two" AND "color: orange"
- matches:
- headers:
Expand Down
20 changes: 16 additions & 4 deletions internal/mode/static/nginx/config/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -716,19 +716,31 @@ func createRouteMatch(match dataplane.Match, redirectPath string) routeMatch {
return hm
}

// The name and values are delimited by "=". A name and value can always be recovered using strings.SplitN(arg,"=", 2).
// The name, match type and values are delimited by "=".
// A name, match type and value can always be recovered using strings.SplitN(arg,"=", 3).
// Query Parameters are case-sensitive so case is preserved.
// The match type is optional and defaults to "Exact".
func createQueryParamKeyValString(p dataplane.HTTPQueryParamMatch) string {
return p.Name + "=" + p.Value
// this condition check is added for unit tests
if p.Type == "" {
p.Type = dataplane.MatchTypeExact
}
return p.Name + "=" + string(p.Type) + "=" + p.Value
}

// The name and values are delimited by ":". A name and value can always be recovered using strings.Split(arg, ":").
// The name, match type and values are delimited by ":".
// A name, match type and value can always be recovered using strings.Split(arg, ":").
// Header names are case-insensitive and header values are case-sensitive.
// The match type is optional and defaults to "Exact".
// Ex. foo:bar == FOO:bar, but foo:bar != foo:BAR,
// We preserve the case of the name here because NGINX allows us to look up the header names in a case-insensitive
// manner.
func createHeaderKeyValString(h dataplane.HTTPHeaderMatch) string {
return h.Name + HeaderMatchSeparator + h.Value
// this condition check is added for unit tests
if h.Type == "" {
h.Type = dataplane.MatchTypeExact
}
return h.Name + HeaderMatchSeparator + string(h.Type) + HeaderMatchSeparator + h.Value
}

func isPathOnlyMatch(match dataplane.Match) bool {
Expand Down
37 changes: 26 additions & 11 deletions internal/mode/static/nginx/config/servers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1071,23 +1071,23 @@ func TestCreateServers(t *testing.T) {
"1_1": {
{
Method: "GET",
Headers: []string{"Version:V1", "test:foo", "my-header:my-value"},
QueryParams: []string{"GrEat=EXAMPLE", "test=foo=bar"},
Headers: []string{"Version:Exact:V1", "test:Exact:foo", "my-header:Exact:my-value"},
QueryParams: []string{"GrEat=Exact=EXAMPLE", "test=Exact=foo=bar"},
RedirectPath: "/_ngf-internal-rule1-route0",
},
},
"1_6": {
{RedirectPath: "/_ngf-internal-rule6-route0", Headers: []string{"redirect:this"}},
{RedirectPath: "/_ngf-internal-rule6-route0", Headers: []string{"redirect:Exact:this"}},
},
"1_8": {
{
Headers: []string{"rewrite:this"},
Headers: []string{"rewrite:Exact:this"},
RedirectPath: "/_ngf-internal-rule8-route0",
},
},
"1_10": {
{
Headers: []string{"filter:this"},
Headers: []string{"filter:Exact:this"},
RedirectPath: "/_ngf-internal-rule10-route0",
},
},
Expand Down Expand Up @@ -2736,8 +2736,8 @@ func TestCreateRouteMatch(t *testing.T) {
},
}

expectedHeaders := []string{"header-1:val-1", "header-2:val-2", "header-3:val-3"}
expectedArgs := []string{"arg1=val1", "arg2=val2=another-val", "arg3===val3"}
expectedHeaders := []string{"header-1:Exact:val-1", "header-2:Exact:val-2", "header-3:Exact:val-3"}
expectedArgs := []string{"arg1=Exact=val1", "arg2=Exact=val2=another-val", "arg3=Exact===val3"}

tests := []struct {
match dataplane.Match
Expand Down Expand Up @@ -2858,23 +2858,25 @@ func TestCreateQueryParamKeyValString(t *testing.T) {
t.Parallel()
g := NewWithT(t)

expected := "key=value"
expected := "key=Exact=value"

result := createQueryParamKeyValString(
dataplane.HTTPQueryParamMatch{
Name: "key",
Value: "value",
Type: dataplane.MatchTypeExact,
},
)

g.Expect(result).To(Equal(expected))

expected = "KeY=vaLUe=="
expected = "KeY=RegularExpression=vaLUe-[a-z]=="

result = createQueryParamKeyValString(
dataplane.HTTPQueryParamMatch{
Name: "KeY",
Value: "vaLUe==",
Value: "vaLUe-[a-z]==",
Type: dataplane.MatchTypeRegularExpression,
},
)

Expand All @@ -2885,12 +2887,25 @@ func TestCreateHeaderKeyValString(t *testing.T) {
t.Parallel()
g := NewWithT(t)

expected := "kEy:vALUe"
expected := "kEy:Exact:vALUe"

result := createHeaderKeyValString(
dataplane.HTTPHeaderMatch{
Name: "kEy",
Value: "vALUe",
Type: dataplane.MatchTypeExact,
},
)

g.Expect(result).To(Equal(expected))

expected = "kEy:RegularExpression:vALUe-[0-9]"

result = createHeaderKeyValString(
dataplane.HTTPHeaderMatch{
Name: "kEy",
Value: "vALUe-[0-9]",
Type: dataplane.MatchTypeRegularExpression,
},
)

Expand Down
68 changes: 54 additions & 14 deletions internal/mode/static/nginx/modules/src/httpmatches.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ function headersMatch(requestHeaders, headers) {
const h = headers[i];
const kv = h.split(':');

if (kv.length !== 2) {
// header should be of the format "key:MatchType:value"
if (kv.length !== 3) {
throw Error(`invalid header match: ${h}`);
}
// Header names are compared in a case-insensitive manner, meaning header name "FOO" is equivalent to "foo".
Expand All @@ -168,8 +169,22 @@ function headersMatch(requestHeaders, headers) {

// split on comma because nginx uses commas to delimit multiple header values
const values = val.split(',');
if (!values.includes(kv[1])) {
return false;

let type = kv[1];
// verify the type of header match
if (!(type == 'Exact' || type == 'RegularExpression')) {
throw Error(`invalid header match type: ${type}`);
}

// match the value based on the type
if (type === 'Exact') {
if (!values.includes(kv[2])) {
return false;
}
} else if (type === 'RegularExpression') {
if (!values.some((v) => new RegExp(kv[2]).test(v))) {
return false;
}
}
}

Expand All @@ -179,20 +194,38 @@ function headersMatch(requestHeaders, headers) {
function paramsMatch(requestParams, params) {
for (let i = 0; i < params.length; i++) {
let p = params[i];
// We store query parameter matches as strings with the format "key=value"; however, there may be more than one
// We store query parameter matches as strings with the format "key=MatchType=value"; however, there may be more than one
// instance of "=" in the string.
// To recover the key and value, we need to find the first occurrence of "=" in the string.
const idx = params[i].indexOf('=');
// Check for an improperly constructed query parameter match. There are three possible error cases:
// (1) if the index is -1, then there are no "=" in the string (e.g. "keyvalue")
// (2) if the index is 0, then there is no value in the string (e.g. "key=").
// (3) if the index is equal to length -1, then there is no key in the string (e.g. "=value").
if (idx === -1 || (idx === 0) | (idx === p.length - 1)) {
// To recover the key, type and and value, we need to find the first occurrence of "=" in the string.
const firstIdx = p.indexOf('=');

// Check for an improperly constructed query parameter match. There are two possible error cases:
// (1) if the index is -1, then there are no "=" in the string (e.g. "keyExactvalue")
// (2) if the index is 0, then there is no key in the string (e.g. "=Exact=value").
if (firstIdx === -1 || firstIdx === 0) {
throw Error(`invalid query parameter: ${p}`);
}

// find the next occurence of "=" in the string
const idx = p.indexOf('=', firstIdx + 1);

// Three possible error cases for improperly constructed query parameter match:
// (1) if the index is -1, then there are no second occurence of "=" in the string (e.g. "Exactvalue")
// (2) if the index is 0, then there is no value in the string and has only one "=" (e.g. "key=Exact").
// (3) if the index is equal to length -1, then there is no key and type in the string (e.g. "=Exact=value").
if (idx === -1 || idx === 0 || idx === p.length - 1) {
throw Error(`invalid query parameter: ${p}`);
}

// extract the type match from the string
const type = p.slice(firstIdx + 1, idx);

if (!(type == 'Exact' || type == 'RegularExpression')) {
throw Error(`invalid header match type: ${type}`);
}

// Divide string into key value using the index.
let kv = [p.slice(0, idx), p.slice(idx + 1)];
let kv = [p.slice(0, firstIdx), p.slice(idx + 1)];

// val can either be a string or an array of strings.
// Also, the NGINX request's args object lookup is case-sensitive.
Expand All @@ -207,8 +240,15 @@ function paramsMatch(requestParams, params) {
val = val[0];
}

if (val !== kv[1]) {
return false;
// match the value based on the type
if (type === 'Exact') {
if (val !== kv[1]) {
return false;
}
} else if (type === 'RegularExpression') {
if (!new RegExp(kv[1]).test(val)) {
return false;
}
}
}

Expand Down
Loading

0 comments on commit a29b3ee

Please sign in to comment.