GitHub’s inconsistent access control

Published by Andrew Haines on May 19, 2022
image

I was recently working on publishing JSON schemas to a Google Cloud Storage bucket, and I needed to create a secret containing the bucket name for our GitHub Actions workflow. I’m a maintainer on our repository (not an admin), so although I can access some of the repository settings, I’m not allowed to see the secrets page - I get “404 Not Found” if I try.

I set up a Terraform module to create the secret (along with the bucket and related infrastructure), and I was surprised to find that it worked despite my lack of admin permissions. Even though I can’t access the secrets user interface, the GitHub API accepted my request to create a secret.

Probing further with the gh command-line tool, I found that I could also list, update, and delete existing secrets. Attempting the same actions as a user without write access to the repository gave me an error “Must have admin rights to Repository”, so I believed I had found a vulnerability.

I filed a report through GitHub’s security bug bounty program. Unfortunately, I didn’t get a reward – although it’s not (yet) documented anywhere, apparently it’s intended behaviour to allow anyone with write access to a repository to have full access to Actions secrets.

This seems like a classic example of an authorization requirement changing (the “Must have admin rights” error message would suggest that access used to be admin-only), but the rule only being updated in one place, leading to a broken user experience. GitHub is hardly the first and won’t be the last to run into this kind of issue – what’s going wrong?

Enforcing access control in different application layers

It’s common to need to make the same authorization decisions in different layers of your application. For example, if your blog system’s authorization policy dictates that only admins can delete published posts, then you need to enforce that policy in at least two places:

  • in the frontend user interface, where you’ll want to hide (or disable) the “delete” button if the user doesn’t have permission to delete the post; and
  • in the backend service, where you’ll need to reject any request to delete a post made by an unauthorized user.

In some cases you might be able to get away with only enforcing the policy in the backend, although this might be a frustrating experience for your users when they attempt to perform actions and only discover through error messages that they’re not allowed. Solely enforcing the policy in the frontend isn’t a viable strategy because it leads to security by obscurity; an attacker can fairly trivially circumvent the restriction using the developer tools built into every browser. Unfortunately, relying on the frontend to enforce policies is all too common, which has helped broken access control reach #1 in the Open Web Application Security Project’s top 10 web application security risks in 2021.

How not to do it

In the early stages of implementing access control, it’s all too easy to pass the user identity through to the frontend and drive user interface changes by checking the user’s details. This approach scales poorly as authorization requirements evolve, requiring care to ensure that the frontend checks stay in sync with the policy being enforced on the backend.

Trying to keep everything in sync is frustrating for your developers, and error-prone. Without a single source of truth for authorization rules, it’s pretty much inevitable that some feature in your application will wind up like GitHub’s repository settings page – the frontend and the backend will disagree on whether an action is permitted. This is frustrating and confusing for your users, and can undermine their confidence in the security of your product.

A better way

Instead duplicating authorization decisions in the frontend, a better approach is to pass permissions along from the backend. In our blog system example, we might do this by adding metadata to the /api/posts responses:

{
  "posts": [
    {
      "title": "Consistent authorization between application layers",
      "author": "Andrew Haines",
      "...": "...",
      "permissions": {
        "delete": true,
        "...": "..."
      }
    }
  ]
}

Our frontend can then show or hide elements of the user interface based on the permissions that were computed by the backend, using the same policy that will authorize the eventual API request.

This approach ensures that the authorization decisions stay in sync between frontend and backend, and has the added benefit of simplifying the frontend code because it doesn’t have to reimplement the access control rules.

Going further

Your application might have more layers that need to consistently enforce authorization policies. There are many approaches to access control in microservices, but an increasingly popular approach is to externalise authorization decisions to a third-party provider.

Just as taking authorization decisions out of the frontend reduces complexity and increases resiliency to changes in requirements, so too does offloading authorization decisions from backend services to a dedicated provider. Policies can become configuration rather than code, and be consistently enforced (and audited) across multiple services – even if those services are implemented using different technologies.

Cerbos is an open-source, cloud-native, and context-aware access control solution that helps you tackle changing authorization requirements across the layers of your application. To learn more, check out cerbos.dev.

Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team