Using Nginx reverse-proxy to set cross-site cookies for your web-app
For a while now, Chrome and other modern browsers have shared their plans to bring in changes to their cookies policies in efforts to address security vulnerabilities caused by some third-party cookies. And in recent months, these changes have been rolled out. So what exactly has changed? This article sums it up nicely:
Chrome now requires the SameSite attribute to be set with both None and Secure labels. The Secure label forces the cookie to be set and read only over HTTPS connections. Third-party cookies must have both labels to avoid being rejected.
This rollout came to effect me recently. I develop web-apps through a commonly-used partitioned strategy, consisting of an isolated REST API backend application-server handling HTTP requests from a front-end web-server (usually serving through Nginx). I commonly containerize these two components into separate images with Docker and deploy on Cloud Services. I previously wrote an in-depth guide exploring a similar dev strategy.
Unlike in my previous guide however, in this problem scenario my web-app featured browser-cookie based user-authentication (via flask_login) that was handled by the application-server. I also wanted to establish a CI/CD pipeline which pulled code from my code repository, performed tests, built the Docker images and deployed onto Google Cloud Platform’s (GCP) Compute Engine VMs. GCP VM’s however have a limitation: a single VM instance may only deploy a single docker container. So under this deployment strategy, my application and web server Docker images would be deployed on separate VMs, which had different external IPs.
This is where I faced challenges with the new cookie policy rollout. Obviously a user accesses the web-app through the IP Y of the VM instance running the web-server container and as my application-server running on the VM with IP X attempts to set a cross-site cookie, it is blocked by the browser from doing so and I see the following message in my browser console:
There are a few ways to resolve this issue and why I did/didn’t pursue them:
- As indicated in the chrome message, I could get HTTPS certification for my application-server IP and explicitly specify SameSite=None and Secure for the cookies to be set by flask. I did not pursue this because frankly HTTPS certification would be overkill for this internal-use web-app.
- I could alter my deployment strategy to shift away from GCP Compute Engine VM instances and choose something else where both my application and web-server images could be deployed together under a common IP. In this case, my team preferred to maintain deployments on these VM instances as they were most familiar with them. I also particularly liked GCP’s CI/CD support through Google Cloud Builds (more on this in a future article).
- I could be adventurous and attempt to containerize my application and web-server into the same docker image (under different ports) and deploy that single image on a single VM instance. However, this would most certainly be considered bad-practice and go against central Docker principles like keeping containers light-weight and separating container responsibilities. It would also be unsustainable for scaling the app.
- I could ditch flask_login and instead only verify the user’s credentials through a flask endpoint and establish the actual cookies through the web-server, perhaps using an NPM module like react-cookie. However it will be quite the chore to replicate flask_login’s seamless sessional authentication capabilities.
- I could set the cross-site cookies through my application-server on the separate VM instance via an Nginx reverse-proxy. This is the route I chose. I chose this route as one, I have previously used a reverse-proxy on many occasions and two, using a proxy allows several added benefits.
To demo this procedure, I created a simple mock application. The application is a todo-list, where a user can login with credentials and add/delete items from the list which appears on their home page. For simplicity, my app has pre-set two users, and so will not feature a sign-up page. Also for brevity, I will not iterate through the details of the app development procedure, as aside from the integration of user-authentication via flask_login (a library which already has excellent documentation), I covered most of this development procedure in a previous guide. I will discuss the nginx-proxy and also the process of deploying the docker containers manually on GCP Compute Engine VM instances. All code for this mock app can be found on my GitHub.
This blog post does a fantastic and thorough job of explaining the nginx reverse proxy and various additional available options not covered here. You can also refer to nginx’s decent module documentation. Here is my very brief explanation of how the nginx proxy will work. The IP to which our web-server will make its HTTP requests (fetch calls in React) to will be its own IP, i.e. IP of the Compute Engine VM instance on which we deploy our web-server docker image. As our nginx server is already listening on this IP and port, it will receive the HTTP requests take the ones including “/api” tags and proxy (re-route) them to the IP of the Compute VM instance running the application-server (flask) container. This application-server IP will be set into the nginx config via a bash script process which reads in the environment variable REACT_APP_NGINXPROXY from the container itself. This script is run when the Docker image is built. Note: the correct value for REACT_APP_NGINXPROXY MUST be set as an environmental variable when running your docker image on the Compute Engine VM. Simply locally building your image with just build-arg tags will not work.
#!/bin/shexport REACT_APP_NGINXPROXYenvsubst '${REACT_APP_NGINXPROXY}' < /etc/nginx/conf.d/todo_app_container_nginx_server.conf.template > /etc/nginx/conf.d/todo_app_container_nginx_server.confexec "$@"
The application-server does its work, and sends its response (including cookies) back to the nginx server. In this manner, you can verify through your browser’s console that the cookies do indeed get set, and in-fact that too with your web-server VM’s IP as the cookie domain. Once again, this diagram from patricksoftwareblog.com accurately displays this process.
To finish, I will iterate through the process of deploying Docker containers manually onto GCP Compute Engine VM instances with the use of GCP Container Registry. We will complete the following steps:
- Enable Container Registry API, set-up & authenticate gcloud CLI, fix potential IAM/user permissions, build and push our Docker images to the registry
- Create our Compute Engine VM instances and deploy container-images from Container Registry
- Fix potential firewall rules and hold static external IP addresses
To start, first, enable the GCP Container Registry API by clicking on the top-left hamburger icon and navigating to Container Registry.
Before beginning, ensure you have set up the gcloud CLI tool on your machine and go ahead and authenticate your account, as well as your Docker account so that you may push your images to the registry. At times you can run into user/account permissions issues during this process, so refer to this page as well as your IAM permissions (navigate to IAM & Admin from options).
gcloud auth login <- if not authenticated login to your account
gcloud auth configure-docker <- configure docker// verify your current permissions
gcloud projects get-iam-policy PROJECT-ID \
--flatten="bindings[].members" \
--format='table(bindings.role)' \
--filter="bindings.members:service-PROJECT-NUMBER@containerregistry.iam.gserviceaccount.com"// set permissions
gcloud projects add-iam-policy-binding PROJECT-ID \
--member=serviceAccount:service-PROJECT-NUMBER@containerregistry.iam.gserviceaccount.com --role=roles/containerregistry.ServiceAgentdocker build -t gcr.io/<your-project-id>/flask:latest_flask ./flask_appdocker build -t gcr.io/<your-project-id>/react:latest_react --build-arg REACT_APP_NGINXPROXY=http://<app-server-ip>:<app-server-port> ./react_appdocker push gcr.io/<your-project-id>/flask:latest_flask
docker push gcr.io/<your-project-id>/react:latest_react
Now, navigate to Menu -> Compute Engine -> VM Instances and create a new instance. I usually stick to the E2 Series. Your defined machine specs in this case will vary depending on your computing/space needs. Also keep in mind the monthly cost of running the instance. In our case, we want to go ahead and deploy a docker image to the instance, so we can choose that option and link your docker image, for example: gcr.io/<your-project-id>/flask:latest_flask. Note that unlike a regular VM instance, when you deploy a container-image to an instance, your machine is initiated with a specific Container-Optimized OS from GCP. You also want to allow HTTP/HTTPS traffic.
You may also want to specify additional tags under Network Tags, as these will be useful for directing Firewall rules to your specific VM for example. BE SURE to set any necessary environmental variables for your docker container here under ‘Advanced container options’.
You can also go ahead and set a ‘start-up script’ for this VM. A startup-script runs automatically each time your VM re-starts. For our purpose, as we are looking to set a CI/CD system with docker containers automatically replaced in our VM, we will use a startup-script which simply rids of hanging containers or old image versions from our VM.
#!/bin/bashdocker system prune -f
Note: upon creation, your VM’s external IP is by default Empheral, I have covered a bit more on this below.
Next, you will want to navigate to Menu ->VPC Network -> Firewall. Here you will likely need to modify/add firewall rules to enable either incoming (ingress) or outgoing (egress) traffic from your respective VM. This page is well-explained/self-explanatory otherwise.
Lastly, under VPC Network, navigate to External IP Addresses. The external IP addresses of VM instances by default are Empheral, meaning they are non-static and WILL change if you stop and re-start your VM (say to re-deploy another container-image version). Be careful here, as if you have users, having a changing external IP will render the IP they have to be useless. So to reserve a VM’s current external IP, change the “type” to Static. Note: you will be charged for reserving IP addresses, in my experience at around $17/month for two reserves.
That’s it for this one. I hope this guide can be helpful for some of your use-cases. So what’s next? I plan to cover topics like react-app testing using Jest & Enzyme (check out my new linked article!) as well as my take on how to set up a productive CI/CD pipeline through Google Cloud Builds (check out my new linked article!) using Cloud Build Triggers based on this same base app in future articles.