Dodging a Critical CVE with ZTA
•
Wed Apr 01 2026
(updated
Tue Oct 06 2026)
#awk #cve #go #grpc #zta
Present

Dodging the Flyer

I’m going to start this by acknowledging that while CVE-2026-33186 was assigned Critical CVSSv3 score of 9.1, I feel this post may dramatize the severity. My aim is not to cast blame at the bug, nor the consumers, but to discuss tenets that hopefully lead most to avoid this vulnerability to begin with.

Authorization Bypass

This CVE concerns grpc-go and its authz package, a library for enforcing authorization of gRPC servers. gRPC is primarily used with Protocol Buffers, which provide a “language-neutral, platform-neutral, extensible mechanism for serializing structured data”. In other words, the two can be used to generate “glue” for remote procedure calls in the language of your choice, leaving the developer to focus on business logic. The vulnerability here concerns the interceptor used to authorize gRPC requests before they reach the handler to fulfill them. That is quite a serious vulnerability, but let me explain why I am hopeful most were not affected by this.

So what actually happened? Before grpc-go 1.79.3, the server accepted an HTTP/2 :path that omitted the mandatory leading slash, Service/Method instead of /Service/Method, and still routed it to the correct handler. Path-based authorization interceptors, the official authz package and any custom interceptor keyed on info.FullMethod, compared the raw, non-canonical string, so a deny rule written for /Service/Method never matched. If your policy then fell back to allow, the request sailed through; see the GitHub advisory for the details. Hold onto that last condition, we will come back to it.

EXPERIMENTAL

Notice: This package is EXPERIMENTAL and may be changed or removed in a later release.

Documentation

This should have been your first sign. It doesn’t necessarily indicate unstable or insecure, but when a developer reports a project as experimental, believe them the first time, especially if that project concerns auth. OK, I am being a bit facetious; we shouldn’t blame bugs on consumers. It is important to seek requests-for-comments before going too far down a path, and as such, software may be released without any guarantee. As a consumer, you should be vetting your dependencies the best you can.

The Middleware Bypass Pattern

Forward-thinking developers wish to write code that is flexible and easy to maintain. Separating logic into domains with pluggable interfaces helps us focus on a single aspect of the design, allows us to rearrange the parts, discard and add new ones, more easily test and assert their logic.

The middleware pattern is often a simple function chain, kind of like an onion of wrapped closure functions, each layer calling the next. A more naive approach is dropping the request directly into a large function responsible for auth, request tagging, logging, rate-limiting, transport logic, API logic, business logic, etc. Grasping the logic of such a function or altering one aspect could bring the whole house down.

So most developers have pushed auth to a middleware, which should bail before ever reaching a handler to fulfill the request. Perhaps a zealous adherence to this Separation of Concerns has convinced developers that it’s poor design for handlers to concern themselves with auth. There has been a litany of auth bypass vulnerabilities in these middlewares, probably most notably, Next.js CVE-2025-29927 via header “x-middleware-subrequest”, an actual feature of the framework.

Indiana Jones - No Ticket

As such, when we use a framework or language that uses a middleware pattern for auth, we must still require the handlers to check everyone’s ticket, even if they’ve somehow managed to make it onto the Zeppelin.

Zero Trust Architecture

The X-Files

“Trust No 1” - The X-Files Season 9 Episode 6

NIST SP 800-207 and OWASP’s ZTA Cheat Sheet go into great detail about ZTA, but this security principle covers such a large swath of technology, it can be easy to get lost. From the perspective of the software developer, especially regarding middleware auth patterns, ZTA is simply requiring all requests to pass through a Policy Enforcement Point.

The footgun that developers often pick up, “not all my requests require auth” or “some are public”; which can further validate the fallacy of Separation of Concerns between auth and handlers. This thought process normalizes auth bypass; it’s a feature. But following ZTA principles for requests that require no auth seems like an oxymoron. How could we possibly do both?

Trust But Verify

Quite simply, you must create a role for the un-authenticated, and like any other request, your handler must check the ticket stub issued by your auth middleware. So we end up with an authentication token for un-authenticated requests, which sounds silly, but it allows us to enshrine in stone the requirement that all requests must pass a PEP.

Deny by Default

ElectroBOOM

ElectroBOOM

Perhaps the most egregious fault exposed by this CVE is not following the ZTA tenet to Deny by Default. If a route is unrecognized, your middleware should not allow the request to proceed to the handlers. Remember the condition for the bypass: a deny rule that failed to match, followed by a fallback allow. Flip that default and a malformed route becomes a loud failure rather than an open door.

Just In Time

If we follow ZTA, we’ve essentially established a contract between our auth and our downstream consumers. Bypasses of auth middleware make it obvious why handlers should have the ability to assert a request’s access; however, by accepting this design pattern, we open the door to Just-In-Time access. By leveraging a simple interface to assert auth, we can now perform access checks potentially at any level of our stack. Where should we begin and where should we end?

You should aim to assert auth at the entry point, our handlers, and at the Point of Use of that identity; just-in-time before the underlying action takes place. Where you draw the line depends on your project and security needs, but often this means, in the repository, at the database layer, or perhaps just before decryption of records.

Go gRPC Middleware

OK, we’re back to the real vulnerability here. You dodged CVE-2026-33186 not because you “noped out” at the word experimental, a hand-rolled interceptor with a fallback allow was just as exposed, but because every route your middleware didn’t recognize was denied. So how do we perform auth for gRPC services?

You Wouldn’t Download A Car

After all, you generated this gRPC service from Protobuf, why shouldn’t you use Protobuf to generate a service that has RBAC definitions baked in? Well, if you truly are using Protobuf with multiple languages, perhaps diving into the vast and opaque waters of proto3 custom options and gRPC extensions is worth it; however, I believe this approach comes with major compromises.

It becomes quite hard to glean what the RBACs are for any particular request, and changes to services could appear to make changes to RBACs. The last thing we want is an RBAC list in constant flux. As well, this path may lead you to the practice of beginning and ending your auth at the service or transport level. Instead, we want to allow the potential for auth to be asserted throughout our stack.

This is awk-ward

I propose a rather simple approach, you should hard-code the RBACs for your auto-generated gRPC services, and really, you should never touch them unless broken.

How do we hard-code values for dynamic services? Couldn’t this lead to RBACs that fall out of contract with their services? How do we get the best of both worlds, clear and concrete RBACs, that also reflect an accurate state of the generated gRPC code?

Well I’m an advocate for awk and stupid-simple solutions, and sometimes they’re manual approaches coupled with automation. You likely leverage some build script, maybe a Makefile, to generate your gRPC and Protobuf, add another target to generate the RBACs:

awk -F '[[:space:]_]+' '/^\s*const\s+\(/{c=1}/^\)/{c=0} \
  c&&/_FullMethodName/{a[$2][$3] = $6} \
  END{for (i in a) { printf "\npackage %s\n\nconst (\n", tolower(i); \
    for (j in a[i]) printf "\tMethod%-24s = %s\n", j, a[i][j]; printf ")\n\n"}}' \
    path/to/protos/*.pb.go

package foo

const (
  MethodGetFoo = "/api.v1.Foo/GetFoo"
  MethodPutFoo = "/api.v1.Foo/PutFoo"
)

That is quite clear and concise, but how do we ensure it’s not fallen out of contract with our generated code? Well simply, leverage the dynamic function during testing to ensure RBACs you have manually enshrined as consts, still exist in the generated code, and that all routes in your generated code are recognized by your RBACs. When the test fails, leverage your awk script, inspect the changes, and run the tests again. Your RBACs are no longer hidden in the generated rat’s nest that can be gRPC output; after all, it is code intended for robots, not humans! You also get a dynamic contract check on build time by leveraging tests.

Failing Closed

Following the awk-ward example above, you should implement a function like below:

func RolesForMethod(method string) (roles []user.Role, err error) {
  switch method {
  case MethodGetFoo:
    roles = []user.Role{user.Client, user.UnAuthenticated}
  case MethodPutFoo:
    roles = []user.Role{user.Staff, user.Admin}
  default:
    err = ErrUnknownMethod
  }
  return roles, err
}

You can adjust it to your liking, but the point is to always require methods to provide roles and make it obvious when a malformed route is requested. That default case is the whole story of this CVE: Foo/GetFoo without its leading slash matches nothing, so it is denied. This is easy to understand, update, and test.

Now, if you implement multiple gRPC services, you can implement generic Unary or Stream interceptors, with a single authn function, and a single authz function that leverages RolesForMethod().

Phew

It can be hard sometimes to make the decision, should I write my own auth, should I stick to a library? For me, this CVE landed directly in my security audit window of a back-end gRPC service that required authn/authz. It was one of those frightening moments to see a 9.1 CVE pop-up from dependabot, but delightful in realizing, you dodged it because you followed ZTA and common sense.