[Tutorial] Crush CSRF Attacks with Gloo Edge
Shield your applications from session-riding, Cross-Site Request Forgery attacks.
According to OWASP: “Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated. With a little help of social engineering (such as sending a link via email or chat), an attacker may trick the users of a web application into executing actions of the attacker’s choosing. If the victim is a normal user, a successful CSRF attack can force the user to perform state changing requests like transferring funds, changing their email address, and so forth. If the victim is an administrative account, CSRF can compromise the entire web application.”
Alternatively, the Envoy proxy provides a simple CSRF filter that was integrated beginning with Gloo Edge v1.6. This filter may be applied to an entire Gloo Gateway
, a VirtualService
, or even individual Routes
within a VirtualService
. To understand more about how this filter works in Envoy, we recommend playing in their CSRF sandbox.
VirtualService
.Prerequisites
This tutorial assumes that you are using Gloo Edge version 1.6.2 or higher, running on a Kubernetes cluster in gateway mode. The CSRF feature was added in 1.6, so older versions will definitely fail. It is present in both the open source and enterprise flavors of Gloo Edge, so no license key is required if you’re installing from scratch. Any modern version of Kubernetes should work for this exercise. This example has been tested with both GKE (k8s v1.16) and Kind (k8s v1.18). If you’re new to Gloo Edge itself, we recommend this Getting Started guide.
Deploy the Httpbin Service
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
name: httpbin
---
apiVersion: v1
kind: Service
metadata:
name: httpbin
labels:
app: httpbin
spec:
ports:
- name: http
port: 8000
targetPort: 80
selector:
app: httpbin
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: httpbin
spec:
replicas: 1
selector:
matchLabels:
app: httpbin
version: v1
template:
metadata:
labels:
app: httpbin
version: v1
spec:
serviceAccountName: httpbin
containers:
- image: docker.io/kennethreitz/httpbin
imagePullPolicy: IfNotPresent
name: httpbin
ports:
- containerPort: 80
EOF
Verify the Upstream
glooctl get upstreams
command, you should be able to see a new Gloo Edge Upstream
default-httpbin-8000
with an Accepted
status. The name of the discovered Upstream
was generated automatically by Gloo Edge based on the naming convention namespace-serviceName-portNumber
:% glooctl get upstreams default-httpbin-8000
+----------------------+------------+----------+------------------------+
| UPSTREAM | TYPE | STATUS | DETAILS |
+----------------------+------------+----------+------------------------+
| default-httpbin-8000 | Kubernetes | Accepted | svc name: httpbin |
| | | | svc namespace: default |
| | | | port: 8000 |
| | | | |
+----------------------+------------+----------+------------------------+
Create the Virtual Service
VirtualService
that will route all its requests to the new Upstream
.cat <<EOF | kubectl apply -f -
apiVersion: gateway.solo.io/v1
kind: VirtualService
metadata:
name: httpbin
namespace: gloo-system
spec:
virtualHost:
domains:
- '*'
routes:
- matchers:
- prefix: /
routeAction:
single:
upstream:
name: default-httpbin-8000
namespace: gloo-system
EOF
Run the following glooctl
command to confirm that the new Route
was accepted by Gloo Edge.
% glooctl get virtualservice httpbin
+-----------------+--------------+---------+------+----------+-----------------+----------------------------------+
| VIRTUAL SERVICE | DISPLAY NAME | DOMAINS | SSL | STATUS | LISTENERPLUGINS | ROUTES |
+-----------------+--------------+---------+------+----------+-----------------+----------------------------------+
| httpbin | | * | none | Accepted | | / -> |
| | | | | | | gloo-system.default-httpbin-8000 |
| | | | | | | (upstream) |
+-----------------+--------------+---------+------+----------+-----------------+----------------------------------+
Test the Service
curl
to exercise the service in a few different ways. First, we’ll issue a proper invocation with an origin
header that matches our target host. Second, we’ll mimic an improper request by issuing a mismatched origin
header. Third, we’ll mimic a different type of improper request by eliminating the origin
header altogether.# Matching Origin Header
% root_url=${$(glooctl proxy url)%:*} # trim port from proxy url
% curl -X POST "$(glooctl proxy url)/post" -H "origin: $root_url" -i
HTTP/1.1 200 OK
server: envoy
date: Tue, 05 Jan 2021 20:46:13 GMT
content-type: application/json
content-length: 362
access-control-allow-origin: http://34.74.14.50:80
access-control-allow-credentials: true
x-envoy-upstream-service-time: 5
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Content-Length": "0",
"Host": "34.74.14.50",
"Origin": "http://34.74.14.50:80",
"User-Agent": "curl/7.64.1",
"X-Envoy-Expected-Rq-Timeout-Ms": "15000"
},
"json": null,
"origin": "10.68.2.3",
"url": "http://34.74.14.50/post"
}
Note that the httpbin
service sends back information mirroring the proper request that we issued. If you issue requests with mismatched headers and missing headers, like these:
# Mismatched Origin Header
curl -X POST "$(glooctl proxy url)/post" -H "origin: http://example.com" -i
# Missing Origin header
curl -X POST "$(glooctl proxy url)/post" -i
You’ll notice that they also succeed. Why? Because we have not yet applied any CSRF policies. We’ll get to that shortly.
Review the Envoy CSRF Metrics
port-forward
from the Envoy gateway proxy pod to your local workstation, and then curl
that endpoint for the CSRF metrics.% kubectl port-forward deployment/gateway-proxy -n gloo-system 19000
Forwarding from 127.0.0.1:19000 -> 19000
Forwarding from [::1]:19000 -> 19000
Handling connection for 19000
% curl -s http://localhost:19000/stats | grep csrf
http.http.csrf.missing_source_origin: 0
http.http.csrf.request_invalid: 0
http.http.csrf.request_valid: 0
At this point, note that the metrics are all zero-valued since we haven’t yet activated the CSRF filter in Envoy.
Apply a Shadow CSRF Policy
In this section, we will modify our VirtualService
to apply the filter to all requests and report evaluation results using the Envoy metrics. Note that we have added a shadowEnabled
policy that evaluates 100% of the traffic and reports — but does not block — violations.
cat <<EOF | kubectl apply -f -
apiVersion: gateway.solo.io/v1
kind: VirtualService
metadata:
name: httpbin
namespace: gloo-system
spec:
displayName: httpbin
virtualHost:
domains:
- '*'
routes:
- matchers:
- prefix: /
routeAction:
single:
upstream:
name: default-httpbin-8000
namespace: gloo-system
options:
csrf:
shadowEnabled:
defaultValue:
numerator: 100
denominator: HUNDRED
EOF
Issuing the same three curl
requests against the httpbin
endpoint yields nearly the same results as before. The requests all succeed, but only one of them was valid according to the policy. Two of the three are flagged as invalid, and one of those invalid requests is flagged as having a missing origin
header. You can verify this from the published CSRF metrics.
% curl -s http://localhost:19000/stats | grep csrf
http.http.csrf.missing_source_origin: 1
http.http.csrf.request_invalid: 2
http.http.csrf.request_valid: 1
CSRF Policy Scoping
Note that CSRF policies may be scoped at different levels of the Gloo Edge hierarchy. In this example, we are applying the policy at the virtual host level. In addition, we may scope them more broadly, at the listener level for an entire gateway. Or we may scope these policies more narrowly, even down to the individual route level.
Enforce the CSRF Policy
VirtualService
. This requires only a single-line change to our policy from shadowEnabled
to filterEnabled
.cat <<EOF | kubectl apply -f -
apiVersion: gateway.solo.io/v1
kind: VirtualService
metadata:
name: httpbin
namespace: gloo-system
spec:
displayName: httpbin
virtualHost:
domains:
- '*'
routes:
- matchers:
- prefix: /
routeAction:
single:
upstream:
name: default-httpbin-8000
namespace: gloo-system
options:
csrf:
filterEnabled:
defaultValue:
numerator: 100
denominator: HUNDRED
EOF
The original valid request with the matching `origin` header will work exactly as before, and the valid request metric is incremented.
# Matching Origin Header
% root_url=${$(glooctl proxy url)%:*} # trim port from proxy url
% curl -X POST "$(glooctl proxy url)/post" -H "origin: $root_url" -i
HTTP/1.1 200 OK
server: envoy
date: Tue, 05 Jan 2021 23:02:40 GMT
content-type: application/json
content-length: 362
access-control-allow-origin: http://34.74.14.50:80
access-control-allow-credentials: true
x-envoy-upstream-service-time: 5
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Content-Length": "0",
"Host": "34.74.14.50",
"Origin": "http://34.74.14.50:80",
"User-Agent": "curl/7.64.1",
"X-Envoy-Expected-Rq-Timeout-Ms": "15000"
},
"json": null,
"origin": "10.68.2.3",
"url": "http://34.74.14.50/post"
}
% curl -s http://localhost:19000/stats | grep csrf
http.http.csrf.missing_source_origin: 1
http.http.csrf.request_invalid: 2
http.http.csrf.request_valid: 2
However, applying a mismatched origin
header now causes Envoy to reject the request with a 403 Forbidden
error.
# Mismatched Origin Header
curl -X POST "$(glooctl proxy url)/post" -H "origin: http://example.com" -i
HTTP/1.1 403 Forbidden
content-length: 14
content-type: text/plain
date: Tue, 05 Jan 2021 23:08:09 GMT
server: envoy
Invalid origin
And the invalid request metric increments by 1:
% curl -s http://localhost:19000/stats | grep csrf
http.http.csrf.missing_source_origin: 1
http.http.csrf.request_invalid: 3
http.http.csrf.request_valid: 2
Finally, a request with a missing origin
header similarly fails, but increments both the invalid request and missing source origin metrics:
# Missing Origin header
curl -X POST "$(glooctl proxy url)/post" -i
HTTP/1.1 403 Forbidden
content-length: 14
content-type: text/plain
date: Tue, 05 Jan 2021 23:11:13 GMT
server: envoy
Invalid origin
% curl -s http://localhost:19000/stats | grep csrf
http.http.csrf.missing_source_origin: 2
http.http.csrf.request_invalid: 4
http.http.csrf.request_valid: 2
Allow Additional Origins
origin
header and target host require a precise match by default. However, you may configure additionalOrigins
as a filter option to allow alternative request sources. For example, in our case assume we want to allow requests that originate from any derivative of example.com
. We could modify our VirtualService
to supply a single additionalOrigins
entry as follows.cat <<EOF | kubectl apply -f -
apiVersion: gateway.solo.io/v1
kind: VirtualService
metadata:
name: httpbin
namespace: gloo-system
spec:
displayName: httpbin
virtualHost:
domains:
- '*'
routes:
- matchers:
- prefix: /
routeAction:
single:
upstream:
name: default-httpbin-8000
namespace: gloo-system
options:
csrf:
filterEnabled:
defaultValue:
numerator: 100
denominator: HUNDRED
additionalOrigins:
- suffix: example.com
EOF
Then the previous “mismatched” origin request will work as expected in our new configuration.
# Mismatched Origin Header NO MORE
% curl -X POST "$(glooctl proxy url)/post" -H "origin: http://anyapp.api.example.com" -i
HTTP/1.1 200 OK
server: envoy
date: Tue, 05 Jan 2021 23:27:33 GMT
content-type: application/json
content-length: 370
access-control-allow-origin: http://anyapp.api.example.com
access-control-allow-credentials: true
x-envoy-upstream-service-time: 2
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Content-Length": "0",
"Host": "34.74.14.50",
"Origin": "http://anyapp.api.example.com",
"User-Agent": "curl/7.64.1",
"X-Envoy-Expected-Rq-Timeout-Ms": "15000"
},
"json": null,
"origin": "10.68.2.3",
"url": "http://34.74.14.50/post"
}
Learn More
- Visit the website and read the docs
- Request a live demo or trial
- See video content on the solo.io YouTube channel
- Questions? Join the Solo.io Slack community