Okta’s Trusted Origins: A Continued Cacophony of Security Issues

Chaim Sanders
10 min readJul 20, 2023

--

Every week, almost without fail, I come across one thing that confuses, entertains, or most commonly infuriates me. I’ve decided to keep a record of my adventures.

I must admit, last week I wrote about Okta’s API as a precursor for this post. This all started a few weeks ago when I presented at #identiverse. If you’re shocked there is a conference dedicated to identity, check it out.

While I’m sure that my presentation on ‘Postcompromise Detection and Persistence within IdPs’ will eventually be available, I decided I’d break down one of the more interesting nuggets. Today, we’re going to discuss Okta’s ‘Trusted Origins’ and some concerns with its use.

Background

Okta has an admin configuration area under ‘Security’->’API’->’Trusted Origins’. There are a couple settings in here and all of them are pretty interesting (aka dangerous), especially if an attacker takes over your account. While I may write about the ‘Redirect’ and ‘IFrame embed’ options in the future, today I’m focused on the ‘CORS’ option.

Most of you are familiar with Cross Origin Resource Sharing, well, as much as one really can be. The basic premise behind this is that browsers enforce a trust boundary between what they define as ‘origins’ (Scheme + Domain + Port) when accessed by client scripts (Javascript). This boundary, aptly titled the Same Origin Policy (SOP) forbids certain communications between two locations with separate origins (Note: SOP isn’t universally applied, for instance, the Cookie Same Origin Policy is different than what I just described, and way worse).

Turns out, in certain situations, folks would like to enable one origin to communicate with another origin (aka cross-origin communication). This is the reason CORS exists. Generally this is fine, but one needs to be SUPER DUPER careful when enabling this. An example might help drive this point home: imagine when loading chaimsanders.com if Javascript ran that allowed chaimsanders.com to read the contents of your emails on gmail.com…. Exactly.

The most common issue here is ‘trust’. CORS is finer grained than certain earlier attempts at SOP relaxation techniques (see document.domain), but, in spite of this, in some senses it’s still a blunt instrument.

So back to Okta. Looking at the Trusted Origin Okta config area, you’ll see something that may surprise you given that brief explanation: “Enable[s] browser-based applications to access Okta APIs from JavaScript (CORS).” Hmm, that doesn’t really sound like the CORS I just described.

The Problem

Last week we spoke at length about how Okta allowed access to APIs via session cookies. While I briefly mentioned CORS there, there is a difference between what an attacker can force a user’s browser to do and what can be accessed directly with a stolen session. That’s because Okta twists CORS into an API control boundary, which it was never meant to be. We’re gonna see the ramifications of that decision today.

This whole CORS business actually takes even experienced Okta admins for a spin because the API docs only reference API keys and OAuth access. To be fair, if you scroll quite a bit further in the same docs (all the way to the bottom) there is a little note about CORS. Following that note there is a warning: “Caution: Only grant access to specific origins (websites) that you control and trust to access the Okta API”. Yup, that seems — important.

In that same area there is also another note, APIs that support CORS are marked with the following icon:

This must all be REALLY confusing if you read last week’s post, because you should already know that Okta does NOT, in fact, have restrictions on what API calls can be made with cookies when setting an expected origin, so what’s going on here?

Well, turns out Okta’s ‘CORS control’ here is on when it returns the ‘Access-Control-Allow-Origin’ header in response to an ‘Origin’ header being set. This is interesting because this is an entirely browser level control, this is why we were able to make any API request last week, the responses didn’t contain an ‘Access-Control-Allow-Origin’ header but that won’t prevent an attacker from completing the request outside a browser.

Alright so let’s double click on these ‘approved CORS’ APIs. Couple problems here:

  1. There is no clear list of which APIs are supported and Okta’s own docs heavily contradict each other (which I’ll demonstrate)
  2. There is no way to limit which APIs are available and this is designed to be used by vendors/orgs. That means future restrictions are unlikely to happen. It also means you risk privacy issues from the read APIs and CSRF-y issues from the write/delete APIs.
  3. Giving trust to other domains increases the security boundary of your core authentication provider (Okta) — drastically. For instance, any XSS issues in the ‘trusted’ domains can be used by attackers to force admin users to make API calls — said another way, what you’re trusting is not just the intentions of an origin, but also their security.
  4. These APIs don’t require additional MFA, and only require a session cookie on {tenant}.okta.com and not {tenant}-admin.okta.com (although either is fine). This is both confusing and dangerous, see my last blog for more details.
  5. Okta needs to be careful to first authenticate these requests, then provide CORS response header information. They failed to do this in certain cases, leaking tenant specific origins.
  6. CORS is inherently a DNS layer control and therefore vulnerable to rebinding and other DNS manipulation issues. This is made worse by the fact that these trusted origins don’t have to be served over TLS.

Let’s dive into some of these in more detail:

Which APIs are supported

At the risk of cutting to the heart of the matter — i can tell you, simply looking for the ‘CORS’ icon will produce an incomplete list and is time consuming. How do I know? I did some of this work.

Okta maintains two different doc formats: OpenAPI and ‘normal’ API Docs. I’m sure the reason behind this is not simply to be confusing, but I assure you, that is the result.

Because a lot of the documentation is wrong (or missing), I’m going to use ‘✅’ to indicate that the API works with CORS (in browser) and ‘❌’ doesn’t work with CORS (in browser). Only APIs that work with CORS or are listed in the documentation as supporting CORS are listed below:

According to the normal API docs here are the supported endpoints. ‘*’ indicates its also listed as supported in the OpenAPI documentation:

  • ✅ GET /api/v1/sessions/me — Gets my session
  • ✅ DELETE /api/v1/sessions/me — Clears user sessions (not admin)
  • ✅ *GET /api/v1/users/${userId} — Get user information
  • ✅ POST /api/v1/sessions/me/lifecycle/refresh — Refresh Session
  • ✅ POST /api/v1/users/me/lifecycle/delete_sessions — Clear Session (admin only)
  • ✅ *GET /api/v1/users/${userId}/groups — List users’ groups
  • ✅ *GET /api/v1/users/${userId}/appLinks — List available apps

According to the OpenAPI docs the following API endpoints SUPPORT CORS access. But be careful, most of the APIs listed actually DO NOT actually support CORS:

  • ✅ POST /api/v1/users — Adds a user
  • ✅ POST /api/v1/users/{userId}/credentials/change_password — Changes a user password
  • ✅ POST /api/v1/users/{userId}/credentials/change_recovery_question — Changes a user recovery question
  • ✅ GET /api/v1/users/{userId}/clients — List all client resources for user
  • ❌ POST /api/v1/users/{userId} — Update a user — Doesn’t Work
  • ❌ PUT api/v1/users/{userId} — Replace a user — Doesn’t Work
  • ❌ DELETE api/v1/users/{userId} — Delete a user — Doesn’t Work
  • ❌ GET /api/v1/users/{userId}/blocks — Get all user blocks — Doesn’t Work
  • ❌ GET /api/v1/users — Get all users — Doesn’t Work
  • ⚠️ POST /api/v1/users/{userId}/credentials/forgot_password
  • ⚠️ POST /api/v1/users/{userId}/credentials/forgot_password_recovery_question
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/deactivate
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/activate
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/expire_password
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/expire_password_with_temp_password
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/reactivate
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/reset_factors
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/reset_password
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/suspend
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/unlock
  • ⚠️ POST /api/v1/users/{userId}/lifecycle/unsuspend

In addition to these, there seem to be sporadic APIs that allow CORS access. These APIs are:

  • ✅GET /api/v1/users/{userId}/grants
  • ✅ GET oauth2/default/.well-known/openid-configuration
  • ✅ GET /oauth2/v1/device/authorize — this is a 302
  • ✅ GET /oauth2/v1/keys

As a quick note, the above APIs represent a subset of somewhat easily testable APIs. One of the “best” parts of Okta is that it has tons of internal, undocumented, and otherwise difficult to test APIs. Often, to even know of the existence of such APIs requires reverse engineering Okta thick clients. You can safely assume therefore that this isn’t an exhaustive list, in fact, I’d bet Okta doesn’t even have an exhaustive list outside of code.

Why are some of these listed as ⚠️? Well, turns out Okta’s control here is on when it returns the ‘Access-Control-Allow-Origin’ header. The important part to remember is that CORS is a browser side limitation — so the question is how does not providing an ‘Access-Control-Allow-Origin’ header prevent cross-origin writes, aka CSRF?

It turns out that Okta will not provide this needed ‘Access-Control-Allow-Origin’ header on any preflight request (OPTIONS method) for unapproved API locations, this will prevent the subsequent request (POST/PUT/DELETE method). You may initially think all Okta POST/PUT/DELETE requests will require these preflight requests (as they are application/json requests).

However, as I discussed in my previous post, Okta APIs that are POST requests and don’t send a POST body, will allow you to send the content type: application/x-www-form-urlencoded. POST requests with an x-www-form-urlencoded mime type, are considered ‘simple requests’ and don’t require a preflight.

This means, that an attacker could direct a user to a trusted origin and still trigger POST requests even though the browser won’t allow the result to be accessed. These POST requests still undertake the action however and some of them are pretty critical, reenabling users for example. This is pretty much just CSRF but with an extra step.

No API Scope

The scope of CORS isn’t configurable. Okta is in control of which APIs are supported, and it’s an all or nothing sorta config. This means that not only is it up to the admin to determine the full capabilities of this function (by manually testing as I did above), but Okta can arbitrarily change this at any time (as they’ve done in the past).

The types of APIs listed here might convince you this is a significant privacy risk, and it is, but these aren’t just read-only APIs. As I demoed at Identiverse, this is designed be used to add and activate a user just by having an admin visit a specific domain. There is no reasonable way this should be supported — no use case is worth the risk.

Who Even Uses This?

Alright, this is DICEY — but surely, no one would add untrusted CORs domains, right? Well, the problem sorta arises if you have older apps, particularly implicit flow apps. See there was a time before Okta supported OAuth based API access. This meant, that applications that wanted to do things like get user information were super tempted to leverage this CORS approach, Okta even demos this in the API docs for /api/v1/users/me.

Of course, as a result, applications used this capability. A quick google search results in documentation for setting up Lastpass, tons of Okta github repos suggesting it, hell, Okta even recommends adding developer.okta.com directly within their documentation. By the same token, it would also be pretty difficult for Okta to ever deprecate this feature as they’ll arbitrarily break compatibility with third party developed applications. Ooof.

You may be thinking, “but an attacker doesn’t know our CORS origins, so in some sense … security by obscurity, right?”. To that I’d respond that your former admins are likely to have seen this. But if that wasn’t enough, lets take a look at how to get around that problem with a unauthenticated Trusted Origin enumeration vulnerability.

See, while considering this I thought to myself: “Wow, they need to be careful to authenticate the user before they provide the ‘Access-Control-Allow-Origin’ header, otherwise an attacker can just send spoofed origins and determine which responds with the header. If the specified test request resulted in an ‘Access-Control-Allow-Origin’ header then it was, in fact, a trusted origin.

Most API endpoints actually do take account for the idea that authentication needs to be done before CORS or it will leak information. However, most is not — all. I was able to find at least one API (although there may be others), that didn’t correctly process this:

So in our example below, we can see that a GET request to /api/v1/session/me provides back this special response: “Not Found: Resource not found: me (Session)”, which is a 404 and not a normal 403. This appears to be effectively an unauthenticated endpoint masquerading as a missing endpoint.

When the attacker provides a valid ‘Origin’ header, the ‘Access-Control-Allow-Origin’ header will be provided in the response. For testing purposes, it is important to remember that {tenant}.okta.com, is always a ‘trusted origin’.

So let’s take a look at some results. As part of this work I made a little enumeration tool for valid domains on the okta.com domain. Then I ran a few common trusted origins (https://developer.okta.com, https://lastpass.com/, https://oauth.pstmn.io, http://localhost:8080, http://localhost:8100, http://localhost:8888, http://localhost:4200) against just some of those tenants. Here are some results:

The Solution

I said this last post, but really again, don’t use trusted origins. They’re just dangerous and there are few, if any, situations where this is the correct design now a days. While allowing localhost can be less of a risk, Any OAuth application can be granted API scopes which are generally more fine-grained than the total set of APIs allowed by Trusted Origins.

--

--