Cover Image for Secure GitHub pages with HTTP Authentication using Lambda@Edge

Secure GitHub pages with HTTP Authentication using Lambda@Edge

GitHub Pages is a great way to serve a static website in a serverless pattern for free. Lots of people make use of it for documentation and personal projects.

I recently thought about using GitHub Pages to serve documentation for a project that I have been involved with at work. The issue that I ran into is that I can’t find a good way secure the site.

The project is private, the repository is private and the documentation needs to be private.

Requirements:

  1. Use Jekyll to produce the site
  2. Serverless architecture
  3. Authentication

I found Jekyll Auth pretty quickly but I didn’t want to host in Heroku. I also found a handful of other Jekyll authentication projects but so far as I could tell they were all based on client-side JavaScript authentication in one form or another which just ins’t very secure (in my opinion).

Then I stumbled across these two gems:

Serverless: password protecting a static website in an AWS S3 bucket

A Step-by-step Guide to Creating a Password Protected S3 bucket

If you can secure a S3 Bucket using this patten then I thought maybe I can secure a GitHub Page. Down the rabbit hole we go.

Overview

Diagram of the architecture

Prerequisites:

  1. A GitHub repo with a static site to publish
  2. A TLD that you have DNS control over
  3. An AWS account
  4. A CloudFlare account

For the following example I’m going to use:

origingithubwebhooks.willwright.tech

  • must not be publicly accessible

securegithubwebhooks.willwright.tech

  • should be publicly accessible
  • must be protected via HTTP Authentication

githubwebhooks.willwright.tech

  • must be publicly accessible

Create GitHub site

Follow the guide here to create a site on GitHub Pages: https://pages.github.com/

Once you have a site up you need to setup a custom domain name following the instructions: Configuring an apex domain

GitHub Pages setup

These DNS records need to go into CloudFlare because we’re eventually going to use the Firewall rules there.

Cloudflare DNS configuration

Test that your site resolves by going to the origin domain name that you’ve setup.

GET: https://origingithubwebhooks.willwright.tech/

GitHubWebhooks configuration

Create CloudFront Distribution

Go to your AWS and open the CloudFront service panel. Click “Create Distribution”

Origin Domain Name: Enter the domain of your origin. I’ve used “origingithubwebhooks.willwright.tech”

Origin Protocol Policy: HTTPS Only

View Protocol Policy: Redirect HTTP to HTTPS

Alternative Domain Names (CNAMES): Enter the domain name you want people to use to access your site. I’ve used “securegithubwebhooks.willwright.tech”

SSL Certificate: Choose Custom SSL Certificate and work through the Amazon help document to create an SSL. You need this in order to use a CNAME.

AWS Cloudfront configuration

Test that the domain name of your distribution returns your site.

GET: https://d2k5dsttl7gwtv.cloudfront.net

GitHubWebhooks resolves

Configure CloudFlare

Open your account in CloudFlare. Go to you DNS configuration. Create a CNAME record from the domain you want to be publicly accessible to the CloudFront distribution domain.

Set the Proxy status to “DNS only”

CloudFront DNS configuration

Test that your CNAME record returns your site.

GET: https://securegithubwebhooks.willwright.tech/

GitHubWebhooks resolves

Configure Firewall

We want origin to not be publicly accessible.

Goto: CloudFlare > Firewall > Firewall Rules

Click Create a Firewall rule

Set a name and then block your origin’s domain name.

Configure CloudFront firewall

Test that origin is no longer publicly accessible.

GET: https://origingithubwebhooks.willwright.tech/

Verify origin access denied

This good but we need CloudFront and only CloudFront to have access to our origin.

Add another Firewall Rule to allow origin when there is a specific X-Forwarded-For header.

I used a random password generator to generate my “key”.

Firewall cloudfront allow rules

Test that origin is available when the X-Forwarded-For header is set. I used postman so that I could set the header arbitrarily.

Postman test

Create Lambda@Edge Functions

CloudFront is now locked out of origin because it’s not passing along the correct X-Forwarded-For header value.

Go to your AWS account and open the IAM service console.

Select: Access management > Roles
Click “Create role”

Select “AWS Service”
Click “Lambda”
Click “Next”

IAM console

Filter policies type: “Lambda”
Check “AWSLAmbdaBasicExecutionRole”
Click “Next”

Configure Lambda Permissions

Add a Role Name; I’ve chosen “Lambda@Edge”. Click “Create”.

Find your new Role and click it to get to the edit screen.

Configure IAM Role

Click “Trust relationships”
Click “Edit trust relationship”
Modify the “Service” list such that it includes “edgelambda.amazon.com”

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "edgelambda.amazonaws.com",
          "lambda.amazonaws.com"
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Click “Update Trust Policy”

Go to your AWS account and get to the Lambda service console.

You must chose US-East-1

You can add triggers only for functions in the US East (N. Virginia) Region.

Source: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html

Choose > Functions > Create Function

Select: Author from scratch
Enter a name. I’ve used; “http-forward-header”
Runtime chose: Python 3.7
Expand Permissions
Execution Role select: Use an existing role
Select the role your create. Mine is “Lambda@Edge”
Click “Create Function”

Create Function UI

Enter the following into the code editor

import base64
import json


def lambda_handler(event, context):
    # Forward Code to key off inside Firewall 
    forward_header = {
        "x-forwarded-for": [{"key": "X-Forwarded-For", "value": "REPLACEME"}]
    }

    # If the X-Forwarded-For header exists; delete it so that we can use our own custom header
    if "x-forwarded-for" in event["Records"][0]["cf"]["request"]["headers"]:
        del event["Records"][0]["cf"]["request"]["headers"]["x-forwarded-for"]

    # Add custom X-Forwarded-For header
    request = event["Records"][0]["cf"]["request"]
    request["origin"]["custom"]["customHeaders"].update(forward_header)

    return request

You must replace “REPLACEME” in the function above with your “key”.

Click “Save”
Select “Actions”
Select “Publish new Version”

Select “Version”
Verify that you have the latest published version selected, not “$LATEST”

Verify published Lambda version

Click “Add trigger”
Select “CloudFront”

Distribution: Select the distribution you setup earlier
CloudFront event: “Origin Request”

Click “Add”

Add Trigger UI

You should see the trigger attached to your function

Verify Trigger in worflow diagram

At this point your public domain should work and your origin domain should not work.

GET: https://securegithubwebhooks.willwright.tech/

Verify secure URL resolves

GET: https://origingithubwebhooks.willwright.tech/

Verify Access denied for origin

Now all we have to do is add HTTP Authentication and we’re done

Create HTTP Authentication

Go to your AWS account and get to the Lambda service console.

You must chose US-East-1

Click “Create Function”
Chose: “Author from scratch”
Function name: Set your function’s name (I chose “http-auth”)
Runtime: Python 3.7
Expand Permissions
Execution Role: Use an existing role
Function code: Enter the following

import base64
import json


def lambda_handler(event, context):
    username = "user"
    password = "password"
    auth_value_str = "{}:{}".format(username, password)
    auth_value_bytes = auth_value_str.encode('UTF-8')

    auth_string = "Basic " + str(base64.b64encode(auth_value_bytes), "UTF-8")

    # Require authentication
    headers = event["Records"][0]["cf"]["request"]["headers"]
    if "authorization" not in headers or headers["authorization"][0]["value"] != auth_string:

        body = "Unauthorized"
        response = {
            "status": "401",
            "statusDescription": "Unauthorized",
            "body": body,
            "headers": {
                "www-authenticate": [{"key": "WWW-Authenticate", "value": "Basic"}]
            }
        }

        return response

    # Continue request processing if authentication passed
    return event["Records"][0]["cf"]["request"]

Note: username and password are set at the top of the file. Set them to whatever you wish.

Publish the function
Select the latest published version
Click “Add trigger”

Select “CloudFront”
Distribution: Your distribution
CloudFront event: View request
Click “Add”

Add Lambda Trigger

Test that your public domain is now protected with HTTP Authentication

GET: https://securegithubwebhooks.willwright.tech/

Test HTTP Authentication

You’re done!

You can actually use this pattern for adding HTTP authentication to any site that you can set a CNAME for.