Token lockdown?

I wrote about the risk of security token theft nearly 2 years ago, putting PoP tokens forward as a mitigation. Then, as now,  implementations of PoP tokens are scarce, but they are a great idea. For example, they will probably protect you against an API provider going rogue and abusing the tokens it receives. On the other hand, they don't offer much protection when an attacker is able to inject a script into a browser-based application. So PoP and the lockdown techniques I discuss in this post are complementary.

There are 2 companion Github repositories to this blog post: the first contains intentionally insecure idiomatic React code and is designed to serve as a starting point for a React security tutorial. The second solves the problems of the first. All code below comes from those repositories, so if you want a complete view, you know where to look.

As many OAuth and OIDC libraries use the Web Storage API to store security tokens, stealing them is trivial if an attacker succeeds in injecting a script into the DOM. Unfortunately, even with modern frameworks like React, it is still all too easy to inadvertently code an XSS vulnerability.

So let's not use the local and session storage and keep tokens in memory. How you do this depends on the OAuth library you use. I like oidc-client which, by default, stores tokens in session storage. Changing the token store is easy with the userStore configuration option:

const config = {
       ...
        userStore: new WebStorageStateStore({

                           store: new RideSharingStore()
                   })
      }
const userManager =  new UserManager(config) 


where UserManager is oidc-client's primary abstraction and RideSharingStore is an implementation of the Web Storage interface that keeps its items in memory. Very simple:

export default class RideSharingStore {
  constructor() {
    this._map = new Map()
  }

  get length() {
    return this._map.size
  }

  setItem = (key, value) => {
    this._map.set(key, value)
    return this
  }

  getItem = key => this._map.get(key)

  removeItem = key => {
    this._map.delete(key)
    return this
  }


  key = idx => {
    if (idx >= this._map.size) return
    const keys = Array.from(this._map.keys())
    return keys[idx]
  }
}


No more stealing security tokens from session storage.

The next step is to make sure that the application does not leak the tokens to a place where an attacker's script can pick them up. The most obvious one is the DOM: do not write tokens to the DOM. But then, why would you? So maybe we are done: under the assumption that none of the application's variables and constants are accessible from the global context, a script injected into the DOM cannot reach any of the application's memory. I am prepared to be proven wrong, but I believe this assumption holds for React. I would expect it to hold for other frameworks as well. In fact, I would consider any violation a security bug.

What if the attacker could not only inject a script into the DOM, but also into the application's runtime? This would gain access to all variables and constants in the scope in which it executes. To me, such attack seems pretty difficult to execute, but then I am not a pentester or security researcher. Better safe than sorry, so let's add an attacker with capabilities to corrupt our application to our threat model.

If the attacker can literally inject anywhere, we are lost, as, wherever we hide the tokens, the attacker will find them. But if we assume that we can write a few, small, high assurance, and thus trustworthy components, we should arrange that only these trusted components can access the tokens.

In other words, the design objective now becomes preventing leaking tokens from components that need access and therefore need special assurance.

In a React app using oidc-client, the code above would typically be part of the constructor of the top-level React element, conventionally called App. Then it is tempting to pass the userManager to components that perform an authentication-related task, a login button, for example. The button would have a click event handler that did

props.userManager.signinRedirect()

But the button does not need the UserManager for this - a function that makes the signinRedirect call would be sufficient. So instead of passing the UserManager to the button, create the function in the App component and pass that to the button. We can do even better than that, constraining all userManager references to the App constructor and creating all functions that need the user manager there. This further narrows the scope in which the attacker must inject.

While UserManager is oidc-client's top-level abstraction, the library exposes other sensitive objects that we are likely to use in an application. User springs to mind which can be obtained like so

userManager.getUser()

It contains goodies such as

access_token
profile
id_token

refresh_token
expires_at
token_type 

In other words, all the sensitive information.

It is common practice to place authentication information in the global application state. In React, for example, this would typically be a redux store. As its contents are globally accessible, some protection is needed. Enter the sealer/unsealer pairs pattern invented by James H. Morris in the 1970s. My implementation is inspired on the one in Google Caja:

const makeVault = () => {
      const map = new WeakMap()

      const seal = secret => {
        let key = Object.freeze({})
        map.set(key, secret)
        return key
      }
      const optUnseal = key => {
        return map.has(key) ? map.get(key) : null
      }
      const unseal = key => {
        let result = optUnseal(key)
        if (result === null) {
          throw new Error("Key does not fit")
        } else {
          return result
        }
      }
      return Object.freeze({
        seal: seal,
        unseal: unseal,
        optUnseal: optUnseal
      })
  }


So now, instead of putting the user in the redux store, the vault goes in.

propagateUser = user => {
    this.props.login(user)
}


becomes

propagateUser = user => {
    const vault = makeVault()
    const accessTokenKey = vault.seal(user.access_token)
    const profileKey = vault.seal(user.profile)
    this.props.login(vault)
    this.setState({
      accessTokenKey: accessTokenKey,
      profileKey: profileKey
    })
  }


Note that apart from swapping the vault for the user, the new code also places the keys in the App component's local state, so that it can pass them to children on a need-to-know basis. As a result, a component that used to construct Authorization headers on API calls like so

'Authorization': 
    `Bearer ${this.props.user.access_token}`

from now on needs to

'Authorization': 
    `Bearer ${this.props.user.unseal(this.props.accessTokenKey)}`


as the user object does not have profile and access_token fields anymore, but has become a vault.

I'll leave it here for now, although there are many more exciting ideas for shrinking an app's TCB, and I plan to explore some in further depth. Also on the todo list is tackling the elephant in the room: when you store tokens in memory they are gone when the page is reloaded. Losing them may be fine for some applications, for others it is likely to be a show-stopper.

Many thanks to everyone who gave feedback on my code - many of the ideas come from conversations with you. Special thanks go to Tom Van Cutsem who reminded me of capabilities and pointed me at sealer/unsealer pairs.

Comments

Popular Posts