Scoped API Permissions

Currently, the Admin API key grants very broad permissions to manage everything on a Ghost site. While this is useful in some cases, it can be risky to give full access to third-party services.

It would be great if we could create scoped API keys to limit the access of each Custom Integration. This would be similar to how GitHub allows scopes for OAuth apps or personal access tokens. For example, I might want to limit an integration to only have read access to posts, or to only be able to create new users.

In my case, I’ve developed an integration (Scrib) which needs to be able to read the full post content of paid posts, which currently requires the Admin key. I’d love a way to limit my service’s access to improve user trust and decrease the blast radius in the event of a compromise.

2 Likes

I had the same thought. The Sample and other newsletter sites need the Admin API to add members. But I’d rather not grant near-total access to my site to every integration.

I wondered if I could block integrations from accessing any API endpoints other than the ones I wanted to give them access to, using a firewall rule (Cloudflare WAF, sadly) along the lines of:

(any(decode_base64(http.request.headers["*"][*])[*] contains "ID") and not http.request.uri contains "admin/members") or any(decode_base64(http.request.headers["*"][*])[*] contains "ID") and http.request.method ne "")

Where ID is the “kid” field from the base64 decoded JSON Web Token, which, as I understand, is the ID from the API key used to create the JWT, and doesn’t change. (You can inspect a valid JWT using a tool like Online JWT Decoder)

I haven’t figured out how to run the base64_decode function only the Authorization: Ghost $token part of the header, so my Cloudflare WAF rule’t isn’t working yet :thinking:

I almost figured out how to do this, but not quite. There were several issues with my approach. The main problem was that the full JSON Web Token isn’t valid base64, because it has punctuation characters. (Bash base64 --decode will still decode it, with some complaint; Cloudflare, not so much.) Therefore my attempts to do this failed:

any(decode_base64(http.request.headers["authorization"][*])[*])[*] contains "ID")

The other problem is that the authorization header value is Ghost $token, so it wouldn’t have been valid base 64 without a string slice like decode_base64(substring(http.request.headers["authorization"][*],6)[*])[*]).

The kid value is in the first segment of the JWT, and that part didn’t seem to change when I was testing; so it seemed like maybe there was no need to base64 decode at all.

I wrote a firewall rule like:

(any(lower(http.request.headers.values[*])[*] contains "JWTFRAGMENT") 
and http.request.uri.path contains "api/admin" 
and not http.request.uri.path contains "members") 
or (any(lower(http.request.headers.values[*])[*] contains "JWTFRAGMENT") and http.request.method ne "POST")

… replacing JWTFRAGMENT with a lowercase version of the first part (until the first dot) of a JWT token generated from my integration Admin API key.

This worked for bash + curl requests. However, the first part of the JWT does in fact change. Hitting the Admin API using https://github.com/TryGhost/api-demos produces a different JWT start.

So, it seems I do need to base64 decode the JWT.

But it’s unclear if this will even be possible in Cloudflare, because:

  1. the length of the JWT is not constant (~80 vs 86 chars), so I can’t use Cloudflare’s substring() function, and
  2. Cloudflare doesn’t allow its regex_replace() function in firewall rules.

I wonder if the right approach here is to use a Cloudflare worker instead of firewall rule? That’d give you a lot more flexibility on parsing headers…

1 Like

This is a great idea @Cathy_Sarisky. Thanks! I’d never used Cloudflare workers and assumed they weren’t available on the free plan.

I took a shot at it, like so:

export default {
	async fetch(request) { 
	  const isApiAdminPath = request.url.includes("ghost/api/") && request.url.includes("admin");
	  if (isApiAdminPath) {
		const HEADER_KEY = "Authorization";
	    const INTEGRATION_ID = "[put your kid value here]";
	    const HEADER_VALUE = request.headers.get(HEADER_KEY);
	    const JWT = HEADER_VALUE.slice(6,HEADER_VALUE.indexOf("."));
	    const KID = atob(JWT);

	    const isMembersEndpoint = request.url.includes("members");
	    const isPost = request.method.includes("POST");
	    if (!KID.includes(INTEGRATION_ID)) {
		  	return fetch(request);
		  } else {
			if (isMembersEndpoint && isPost) {
				return fetch(request);
			} else {
			return new Response("403 Forbidden", {
				status: 403,
			  });
			}
		  }
		}
    },
};

This seems to successfully block/allow requests whether they originate from the JS SDK, or bash + curl. The code is a mess, and I’m sure it’s leaky as a sieve, but it’s a start! I put it on the *example.com/ghost/api/* wildcard worker route. (Unfortunately you can’t have wildcards in the middle of a route, or I would have used *example.com/ghost/api/*/admin/* in order not to slow down the content API.)