My colleague Baptiste previously published an article on how to protect cookies while offloading SSL. I recently encountered a customer who wanted to achieve a very similar goal but using a more recent HAProxy Enterprise version. This post will explain the best practices for how to secure your cookies using HAProxy Enterprise.
How do Cookies Work?
HTTP is a stateless protocol meaning each new connection is completely independent from the previous one. The workaround for this is to use session cookies, enabling modern applications to allow long-running user sessions. This means once you are logged in you do not need to sign in for a period of time.
With cookies, information is sent from the server to the client using the set-cookie
in the response header. The client (the web browser) sends it back to the server on the subsequent requests using the cookie
request header. The server is now aware that the client is already known.
Cookies have many usages, most notably user authentication and settings. HAProxy can even be configured to use cookies to route clients among several backend servers. This ensures one client always gets routed to the same server and can be accomplished by enabling sticky sessions in HAProxy.
Cookie Dissection
Web applications hosted over HTTPS are very common and cookies have to be secured in the same way. For that purpose, some attributes can be added to the set-cookie
header. Here is a basic example:
Set-Cookie: User=Seb; path=/; Secure; HttpOnly |
As you can see, cookie options are a semicolon delimited list. The flags can be defined in any order making their processing complex. Let’s have a closer look at the cookie:
User=Seb
defines the cookie namedUser
and its valueSeb
.path=/
defines scope under which the cookie is considered to be valid.Secure
instructs the browser to consider this cookie be exclusively used over an HTTPS connection.HttpOnly
instructs the browser to use this cookie exclusively for HTTP. JavaScript code is denied to access this cookie.
(For more information about cookies and their attributes you can consult the Set-Cookie directive from RFC 6265.)
A set-cookie
header can be repeated several times. To add more complexity a set-cookie
header may also be used to set many cookies. Please note that folding set-cookie
into one header is not recommended (should not be used) by RFC 6265. It is tolerated in some cases but should be avoided in favor of having each cookie in its own header, we will see why later in this article. Below, I assume the application uses header folding. In this case, all cookies are sent using a comma delimited list. The following examples are equivalent:
set-cookie: Cookie1=Value1 | |
set-cookie: Cookie2=Value-of-cookie2 | |
set-cookie: Cookie3=Other-value; path=/ | |
# Same result, but using header folding | |
set-cookie: Cookie1=Value1 | |
set-cookie: Cookie2=Value-of-cookie2, Cookie3=Other-value; path=/ |
HAProxy Session Cookies
As mentioned, cookies can be used in HAProxy for session persistence in a backend by using both a cookie
directive in the backend definition and a cookie
value in the server definition.
backend webservers | |
[...] | |
cookie SRV insert indirect httponly secure | |
server s1 192.168.0.101:80 check cookie s1 | |
server s2 192.168.0.102:80 check cookie s2 |
We use HAProxy as an SSL offloader and we want our session cookies to be secured both locally on the client and on the connection itself. That is why we add httponly secure
in the backend’s cookie
directive.
By default, cookies are accessible via the JavaScript API (such as the Document.cookie
property). Adding the HttpOnly
flag will deny their access to the JavaScript API and prevent XSS attacks.
When the client sends back the cookie to the server, and the connection is not encrypted, an attacker can dump the network traffic and collect sensitive data. Adding Secure
instructs the browser to not send the cookie over an unencrypted connection.
Application Cookies
Our session cookie is now protected, however, the application behind the proxy may not be aware that the connection with the client is encrypted. The client may receive these headers, the first two of which define cookies sent from the application itself, while the third is the HAProxy controlled cookie that we secured:
set-cookie: Cookie1=Value1 | |
set-cookie: Cookie2=Value-of-cookie2, Cookie3=Other-value; path=/ | |
set-cookie: SRV=s1; path=/; HttpOnly; Secure |
How can we protect these cookies?
Some applications can understand the x-forwarded-proto
header and send secured cookies when it is set to https
, but that requires the application to be compatible with this feature. We show an example of setting that header in our blog post Redirect HTTP to HTTPS with HAProxy.
With an older HAProxy version, we added the following snippet in the frontend definition to secure all cookies:
acl https ssl_fc | |
acl secured_cookie res.hdr(Set-Cookie),lower -m sub secure | |
rspirep ^(set-cookie:.*) \1;\ Secure if https !secured_cookie |
But rspirep
has been deprecated (HAProxy 2.0 is the latest to allow this syntax and will be discontinued in February 2024) and now the http-response replace-header
action should be used instead. Many examples can be found on the web. Let’s try some of them. For the sake of these examples, let’s assume a few things:
We focus only on the
HttpOnly
attribute.We want the frontend to add the attributes to all cookies (our session cookie is inserted using
cookie SRV insert indirect
, leaving off thesecure
attribute, because maybe we simply forgot to secure session cookies).We want something generic that can be used everywhere without specifying the cookie name.
The application is configured to send the following cookies defined exactly as shown:
set-cookie: Cookie1=Value1, Cookie2=Value-of-cookie2; HttpOnly | |
set-cookie: Cookie3=Other-value; path=/ |
Yes, Cookie2
already has the HttpOnly
attribute, but Cookie1
and Cookie3
do not.
1st Try
Here is how the frontend is configured to set the HttpOnly
attribute:
acl http_cookie res.hdr(Set-Cookie),lower -m sub httponly | |
http-response replace-header Set-Cookie "(.*)" "\1; HttpOnly" if !http_cookie |
Now let’s have a look at the response sent to the client:
set-cookie: Cookie1=Value1, Cookie2=Value-of-cookie2; HttpOnly | |
set-cookie: Cookie3=Other-value; path=/ | |
set-cookie: SRV=s1; path=/ |
This configuration does not work, only Cookie1
is correctly defined. The problem comes from res.hdr
which returns the value of the last entry (SRV=s1; path=/
in our example).
You may think SRV=s1; path=/
does not have HttpOnly
attribute, and you are right. When used in an ACL res.hdr
loops over all occurrences until a match is found. In other words, if one cookie has the HttpOnly
attribute, we are unable to add it to other cookies.
2nd Try
http-response replace-header
uses a regular expression to match the value to be replaced. If your HAProxy instance is compiled against PCRE (or PCRE2) regular expression libraries, you can benefit from the PCRE power. HAProxy Enterprise is compiled against the PCRE library. You can check if your HAProxy version is able to use PCRE with the following commands:
# Enterprise edition | |
/opt/hapee-2.6/sbin/hapee-lb -vv | grep 'Built with PCRE' | |
# Community edition | |
haproxy -vv | grep 'Built with PCRE' |
You should see a line similar to
Built with PCRE2 version : 10.32 2018-09-10 |
Now the frontend should be defined as:
http-response replace-header Set-Cookie '(^((?!(?i)httponly).)*$)' "\1; HttpOnly" |
The regular expression is a bit more complex than the previous one, let’s analyze it:
(?i)httponly
matches the stringhttponly
in a case insensitive manner. It matcheshttponly
and alsoHttpOnly
.(?!(?i)httponly)
inverts that match in a look ahead. It matches everything buthttponly
and its variants.(^((?!(?i)httponly).)*$)
matches at least one character in a string that does not containhttponly
(and all its variants) and is captured into the\1
placeholder.
(For further information on PCRE you can check the official pcrepattern man page.)
You will also notice now that since we got rid of the ACL, we have a less complex configuration.
Please also note the single quotes around the regular expression. This prevents the dollar sign from being considered as a variable prefix. In HAProxy, a dollar sign within double quoted strings is a variable name prefix.
Let’s try it:
set-cookie: Cookie1=Value1, Cookie2=Value-of-cookie2; HttpOnly | |
set-cookie: Cookie3=Other-value; path=/; HttpOnly | |
set-cookie: SRV=s1; path=/ |
It’s better but this is not what we want. You can spot some issues: neither Cookie1
nor SRV
are modified.
This is because replace-header
will replace a whole header line no matter how many values are set.
3rd Try
Now let’s use replace-value
instead of replace-header
. It will act on all values, not only on a header line:
http-response replace-value Set-Cookie '(^((?!(?i)httponly).)*$)' "\1; HttpOnly" |
Let’s check the result:
set-cookie: Cookie1=Value1; HttpOnly, Cookie2=Value-of-cookie2; HttpOnly | |
set-cookie: Cookie3=Other-value; path=/; HttpOnly | |
set-cookie: SRV=s1; path=/ |
We are almost there. All cookies now have a HttpOnly
attribute but the SRV
(the session affinity cookie). This cookie is a bit special because it is set by the proxy after the http-response
rules are processed. We can fix it using http-after-response
to modify proxy-generated headers.
Almost Final Version
Now the frontend setup is:
http-after-response replace-value Set-Cookie '(^((?!(?i)httponly).)*$)' "\1; HttpOnly" |
Let’s check the result:
set-cookie: Cookie1=Value1; HttpOnly, Cookie2=Value-of-cookie2; HttpOnly | |
set-cookie: Cookie3=Other-value; path=/; HttpOnly | |
set-cookie: SRV=s1; path=/; HttpOnly |
And voilà, this is what we want. But wait for it…
Final Version
Please note again that folding set-cookie
headers, which lists multiple comma-separated cookies in a single header, should be avoided. A typical example is when an expires
attribute comes in:
Set-Cookie: Cookie1=Value1; expires=Tue, 27-Sept-2023 09:14:05 GMT |
The comma after Sun
is considered a value delimiter and the replace-value
will generate some invalid set-cookie
header:
Set-Cookie: Cookie1=Value1; expires=Tue; HttpOnly, 27-Sept-2023 09:14:05 GMT; HttpOnly |
In most cases, you want to use http-after-response replace-header
action to secure your cookies:
http-after-response replace-header Set-Cookie '(^((?!(?i)httponly).)*$)' "\1; HttpOnly" |
Conclusion
Usually, regular expressions should be avoided at all costs, especially case insensitive ones. They can become tedious to maintain and a real performance killer. In some other cases, it might be worth unleashing the full power of regular expressions to simplify the request processing logic.
Learn more about HAProxy Enterprise.
Full Proxy Setup
The full proxy setup is:
frontend www_fe | |
bind :80 | |
bind :443 ssl crt my-cert.pem | |
mode http | |
use_backend www_be | |
http-after-response replace-header Set-Cookie '(^((?!(?i)httponly).)*$)' "\1; HttpOnly" | |
http-after-response replace-header Set-Cookie '(^((?!(?i)secure).)*$)' "\1; Secure" if { ssl_fc } | |
backend webservers | |
mode http | |
cookie SRV insert indirect | |
server s1 192.168.10.101:8000 check cookie s1 | |
server s2 192.168.10.102:8000 check cookie s2 |