scan

Fixing the case of the Implicit Flow modification


Last year I met this web application. Let’s call it Hank. Hank was pretty, but not that smart. In particular, Hank was very prone to trusting what user’s told it. Earlier we learned about how Hank would just trust what a user entered, and then later use that against others. But that is not what we are talking about today. Today we are talking about how Hank would let a user modify some data and become an administrator.

You see, the browser is a funny world. It tries very hard to protect its user from the threats that lurk on the web. But, it’s also not very trustworthy itself. Any user can modify data in it. You can’t have secrets, you can’t trust fields. You can’t have some variable ‘admin=true’ and expect the user to just behave.

In Hank’s case it used a login method called OpenID Connect. Yes the very same one I’m a big fan of. But, Hank skipped some classes and used the Implicit Flow instead of the PKCE flow. Bad Hank. This would be fine if Hank just used the authentication and passed the token as-is to the back-end, but, Hank used some fields in the ID Token to indicate role (e.g. admin vs end-user). Worse, Hank’s back-end used the same fields but did not validate the ID Token. So a user could login, modify the resulting info, and pass it back to the back-end and become an admin. Naughty Hank!

Now the correct solution here is sometimes called the 3-legged flow. In this model, the front end (the browser), sets up and finishes the login flow, but, the last step is it shares a code w/ the back-end which then goes and fetches from the authentication and retrieves the token. In this model we have a protected code base and can have secrets, we would often cipher the result and make a session cookie, making it impossible to modify in the front-end.
So, how would you solve this? Send Hank in for some code surgery? That would be the best. But what if you don’t have the time or money? Can you let Hank loose on the Internet?

Sure you can! The fix was complex, but, in the Web Application Firewall we intercepted the response to the back-end that had the fields in it that were prone to modification. Using the Access Token that Hank supplied, we then went and fetched the real values (in this example the user role), overwriting what the user supplied. We then ciphered with something the user doesn’t have access to (since its in the back-end), and returned it as a cookie. Later, on other requests, we would translate it back for the back-end.

Fully transparent, fully secure. Now that you’ve read the story of Hank, I hope that you will read your OpenID Code Flows and look closely at PKCE or 3-legged. Don’t allow critical information to be modified in the browser and just trusted in the backend. Trust, but verify. Defense in Depth. Assume the browser is broken. Its just easier that way.

As for the workaround in the Web Application Firewall? First we make the observation that we have already confirmed the validity of the Bearer token. Then, we put in a rule like this:

local auth_header = ngx.req.get_headers()["authorization"]
local str = require "resty.string"
local aes = require "resty.aes"
local ck = require "resty.cookie"
local cookie, err = ck:new()
local aes_256_cbc_sha512x5 = aes:new(session_secret, nil, aes.cipher(256,"cbc"), aes.hash.sha512, 5)
local encrypted = aes_256_cbc_sha512x5:encrypt(auth_header)
-- str.to_hex(encrypted),
local ok, err = cookie:set({
  key = "agilicus_token",
  value = ngx.encode_base64(encrypted, true),
  path = "/",
  httponly = true,
  secure = true,
  samesite = "Strict"
})

Later we use that encrypted cookie overriding what the user can provide. We also do an east-west call (replicating what the browser did and trusted), but in the back-end where they cannot modify:

local http = require "resty.http"
local httpc = http.new()
local auth_header = ngx.req.get_headers()["authorization"]
-- Initialise response to 500, override if we logout
ngx.status = 500
if auth_header then
  ngx.say("Error: cannot log out, Host or Authentication header is not set")
else
  local domain = x.."."..y.."."..z
  local uri = "https://auth."..domain.."/token/revoke"
  local token = string.match(auth_header, "[^ ]+ (.*)")
  local res, err = httpc:request_uri(uri, {
  method = "POST",
  body = "token="..token.."&token_type_hint=access_token",
  headers = {
    ["Content-Type"] = "application/x-www-form-urlencoded",
  },
  keepalive_timeout = 1,
  keepalive_pool = 1
})
if not res then
  ngx.say("Failed to revoke access token on "..uri.." : ", err)
else
  ngx.status = res.status
  ngx.say(res.body)
end
end