NGINX cookie consent

Reading Time: 8 min / Published: 7/3/2020 /

Recently I've begun to self-host some of my everyday tools, such as GitLab, to gain more control over my data and be less dependent on 'free services'. But after moving my personal projects, and link to my services, a good friend of mine reminded me that those services all rely on cookies for identifying session by default and I missed a cookie consent banner.

By chance, I came across this SO post that provides a solution to a NGINX-based cookie consent page. Though this answer is only good at first glance and lacks a few important features for a fluent UX, such as the ability to redirect to original URL and a way to ignore some clients (e.g., ignore git).

To combat those problems I refined the original solution and will present an improved way including a simple web-page with an optional privacy policy. Below is a full NGINX template that we will later discuss line-by-line for the consent-page related parts. I will later introduce a simple script and web-page for using this config.

page.conf
1server {
2 # HTTPS will have the same setup but for the sake of simplicity only HTTP is shown here
3 listen 80;
4 server_name <domain>; # Replace this with your FQDN
5
6 location / {
7 # This checking mechanism relies on a varaible as marker to allow for optional skipping
8 set $redirect_trigger 0;
9
10 # When consent cookie is not found set trigger
11 if ($http_cookie !~* "consent=true") {
12 set $redirect_trigger 1;
13 }
14
15 # Ignore certain clients. This list the Git client (for git over http) but is extensible
16 if ($http_user_agent ~* "(git).*" ) {
17 # Reset trigger to bypass redirect
18 set $redirect_trigger 0;
19 }
20
21 if ($redirect_trigger = 1) {
22 # Remove any caching directives to ensure the consent page is serverd properly
23 add_header Last-Modified $date_gmt;
24 add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
25 expires off;
26
27 # Redirect to host of consent page with additional paramters supplied
28 return 301 <url>?host=$host&redirect=$uri;
29 }
30
31 <You original try or proxy directive>
32 }
33
34 location /revoke-consent {
35 # Remove cookie and redirect to some page
36 add_header Set-Cookie 'consent=false;Domain=$host;Path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;SameSite=strict;HTTPOnly;Secure';
37
38 # You could also clean addition cookies here
39
40 return 302 <Your goodbye page>;
41 }
42
43 location /set-consent {
44 # Add cookie header for one month
45 add_header Set-Cookie 'consent=true;Domain=$host;Path=/;Max-Age=7776000;SameSite=strict;HTTPOnly;Secure';
46
47 # The request may include a redirect GET arg.
48 # This might need to be secured additionally with a check for the urls validity to prevent CSRF
49 # Though this shouldn't be a problem (otherwise someones server will be burning soon)
50 # since a GET request could be made by any site this way
51 if ($arg_redirect) {
52 return 302 https://$host/$arg_redirect;
53 }
54
55 return 302 https://$host;
56 }
57}

Let's begin with the first location part of this config. The location / {} directive will catch all requests to / and, if not otherwise explicitly specified, /*. We hook into this location to check for our cookie with NGINX's if directive. This directive allows us, as the name implies, to do condition execution inside a location block. But before begging to check for the cookie we need to declare a trigger, in this case $redirect_trigger, to allow for multiple conditional checks. This helper variable is required, since the if directive has no way to be paired with or or and instructions for additional checks. For simplicity, I decided to go with the following values: 1 => redirect OR 0 => do nothing.

But what matters most is how we use the if directive in this case. In this use case we want to check if the consent cookie hasn't been set. For this check we can employ a Regex against the predefined $http_cookie variable to check for the cookie in the current request. Once this check passed and we knwo that the user needs to be redirected we can set the helper variable to 1.

The next if will check with the opposite target in mind. Namely, we will check if we can skip the redirect, if a certain request client is used though you may also check for any other criteria. In the example above we use a Regex against the client to catch all requests from git to allow for the git client to access e.g., http-hosted git repositories, but you may extend this regex too. A matching client will lead to the helping variable being reset to 0.

Last but not least we have the final evaluation of $redirect_trigger with the optional redirect. In the above example you are free to insert your own cookie consent page with the required GET parameters for a redirect to the original URL, as well as the original host, already supplied. This redirect will also disable all caching for the redirect, thus avoiding any problems with in-browser cache serving an incorrect consent page with e.g., an outdated EULA.

After the redirect check you need to include the original directive for serving the content. Never forget this closing directive since it may otherwise have nasty side effects with the if directive. With the redirect handled we can now take care of the cookie handling process. The example uses a distinctive location directive for removing (location /revoke-consent { … }) and adding (location /set-consent { … }) the HTTP cookie.

The /set-consent location relies on the add_header directive to set the Set-Cookie header with our new consent cookie. In the example we also explicitly limit the cookie to our current (sub)domain with a lifetime of 20 days and also ensure it can only be manipulated by the server with the HTTPOnly attribute. Additionally, this location has the option to redirect a user to a given path by checking for the redirect GET arg. This may be used in conjunction with the previously provided redirect GET arg by a script on the consent page for a seamless UX.

The /revoke-consent location works analog but sets the cookie to an expired date, which will led to removal of the cookie by the browser. This location doesn't check the redirect arg and instead should be used with a redirect to some other page. You may also need to clear all other cookies in the original application before redirecting the user to this page. There's no direct solutions for such cookies, and you may need to check for other cookies to clean in addition to consent for your use case.

Now we've all the server-side pieces in place to have a consent cookie based redirect. We can continue to go over to the user interface. For simplicity the following HTML example doesn't rely on style:

consent.html
1<!doctype html>
2
3<html lang="en">
4<head>
5 <meta charset="utf-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1">
7
8 <title>Cookie Consent</title>
9</head>
10
11<body>
12 <!-- Dummy content -->
13 <h1>This site required cookies. Please consent to the usage of cookies before continuing!</h1>
14
15 <p>
16 We take you privacy very seriously. Nah, just joking.
17 </p>
18
19 <button id="cleanse">Revoke Consent and Clean Cookies</button>
20 <button id="accept">Accept</button>
21
22 <!-- Script for button callbacks -->
23 <script>
24 /**
25 * @author Cobalt <https://cobalt.rocks>
26 * @license GPLv3
27 * cleanse button callback is a derived version from https://stackoverflow.com/a/33366171/12648982
28 * under CC-SA-BY 3.0 @ Jan <https://stackoverflow.com/users/78639/jan>
29 * @see https://stackoverflow.com/users/78639/jan
30 */
31
32 document.addEventListener('DOMContentLoaded', () => {
33 document.getElementById('cleanse').addEventListener('click', () => {
34 const cookies = document.cookie.split('; ');
35 for (let c = 0; c < cookies.length; c++) {
36 const d = window.location.hostname.split('.');
37 while (d.length > 0) {
38 const cookieBase =
39 encodeURIComponent(cookies[c].split(';')[0].split('=')[0]) +
40 '=; expires=Thu, 01-Jan-1970 00:00:01 GMT; domain=' +
41 d.join('.') +
42 ' ;path=';
43 const p = location.pathname.split('/');
44 document.cookie = cookieBase + '/';
45 while (p.length > 0) {
46 document.cookie = cookieBase + p.join('/');
47 p.pop();
48 }
49 d.shift();
50 }
51 }
52
53 // clear localStorage
54 // Don't know anything that uses it but better safe than sorry
55 localStorage.clear();
56
57 // redirect to revoked page
58 window.location.href = '<<our goodbye page>';
59 });
60
61 document.getElementById('accept').addEventListener('click', () => {
62 // fetch params from url (supplied by web server on redirect)
63 const GETParams = new URLSearchParams(window.location.search);
64 const redirect = GETParams.get('redirect');
65 const host = GETParams.get('host');
66
67 // only redirect when subdomain of <your domain>
68 if (
69 host !== undefined &&
70 host !== null &&
71 /[a-zA-Z-\.]*<your domain>/.test(host)
72 ) {
73 console.log(`https://${host}/set-consent?redirect=${redirect}`);
74 window.location.href = `https://${host}/set-consent?redirect=${redirect}`;
75 } else {
76 // if not part of your current domain ask user before redirecting
77 if (
78 confirm(`Do you want to be redirected to ${host}/${redirect}?`)
79 ) {
80 window.location.href = `https://${host}/set-consent?redirect=${redirect}`;
81 }
82 }
83 });
84 });
85 </script>
86</body>
87</html>

The above presented HTML file provides the option for the user to consent to the cookie policy and otherwise also revoke their consent. With a bit of JS the accept button listener will also conditionally redirect to the URL passed along with the redirect GET parameter. On the contrary the listener to the revoke consent button will remove all client side cookies and redirect to the revoke-consent location. Feel free to extend and build upon the above presented examples and I would be happy to hear if theirs anything to improve on this post. I hope you could learn something, and I'll see you again in the next post.W