Demystifying Flask's request.authorization
Demystifying Flask’s request.authorization
While doing HITB’s Secconf Attack/Defense CTF, there was one web service that a teammate and I came across that used Python’s Flask webserver. Authentication was implemented using request.authorization
and Authorization: Bearer ...
headers. We started fuzzing the header to see if we could get any weird behavior, and stumbled upon a 500 Internal Server Error being thrown whenever there was an equals sign (=
) in the middle of one of these tokens.
After the CTF, I did some digging into this weird behavior to see why that would break it. It turns out Flask changed the way it handles Authorization
headers in version 2.3, and the new handling isn’t super clearly documented. Also, all relevant questions about request.authorization
that I could find on the Internet referred to the old handling and not the new way. In this blog post, I will attempt to clearly define the behavior of Flask when it comes to Authorization
headers, and define edge cases and weird behavior. This way, security researchers, CTFers, and developers can understand how to properly deal with Authorization
headers and avoid unintended behavior.
Old request.authorization
Previously, Flask only supported Basic Authentication from the Authorization
header, where it parsed the base64-encoded value, and returned a Python Dictionary like {"username":"value","password":"value"}
. However, using Authorization: Bearer <token>
has become a lot more popular in recent years, and so to ensure developers don’t have to parse those tokens themselves, Flask pushed out an update so that request.authorization
returns a werkzeug.datastructures.Authorization
object.
Parsing the Authorization
Header
When you call request.authorization
, Python will inevitably call the from_header()
function of the Authorization class with the sole parameter being request.headers['Authorization']
. If the Authorization
header is not defined in the HTTP request, this will return None
.
The header is parsed into 2 parts - the scheme, and the “rest”; these 2 parts are separated by the first space (if multiple spaces are in the header, the scheme is everything before the first space, and the “rest” is everything else). If scheme == 'basic'
, then the “rest” is based64-decoded, with the username being everything before the colon (:
) and the password being everything afterwards.
If the scheme is not 'basic'
, it’s ignored. If “rest” has an equals sign that’s NOT at the end, it’s treated as parameters; otherwise, it’s treated as a token (no Authorization object can have both a token and parameters defined). Parameters are parsed using key=value, key2=value2
, and the token isn’t parsed as anything. The parsing seems fairly simple, but the way it’s accessed is kind of weird and we run into some odd edge cases (which I’ll go over later).
A review:
- If the scheme is
basic
(case-insensitive), then 2 parameters (username
andpassword
) are extracted from the base64-encoded, colon-separated remainder - If an equals sign is present in the middle of the “rest”, then the entire “rest” is parsed as parameters using the
parse_dict_header()
function- Note that the
parse_dict_header()
has interesting functionality itself, such as support for various encoding techniques
- Note that the
- Otherwise, the entire “rest” is processed as a token
Accessing request.authorization
Information
Let’s use the header Authorization: Bearer test
request.authorization == 'Bearer test'
request.authorization.parameters == {}
request.authorization.token == 'test'
Our second example is Authorization: Bearer key=value
request.authorization == 'Bearer key=value'
request.authorization.parameters == {"key": "value"}
request.authorization.token == None
Our third example is Authorization: Bearer dXNlcm5hbWU6cGFzc3dvcmQ=
request.authorization == 'Bearer dXNlcm5hbWU6cGFzc3dvcmQ='
request.authorization.parameters == {"username": "username", "password": "password"}
request.authorization.token == None
Interesting Edge Cases and Behavior
Here are some examples where the parsing is somewhat interesting and, depending on a setup, can lead to unintended behavior.
Authorization: test test
–>request.authorization == 'Test test'
(notice the forced capitalization)Authorization: bAsIc dXNlcm5hbWU6cGFzc3dvcmQ=
–>request.authorization.parameters == {"username": "username", "password": "password"}
(case-insensitive scheme)Authorization: asdf token
–>request.authorization.token == 'token'
(all non-basic schemes are ignored)Authorization: Bearer a~!@#$%^&*()_+b
–>request.authorization.token == 'a~!@#$%^&*()_+b'
(all non-equals sign symbols are accepted)Authorization: Bearer a~!@#$%^&*(=)_+b
–>request.authorization.token == None
(Bearer tokens with an equals sign are not processed as tokens)Authorization: Bearer a~!@#$%^&*()_+b=
–>request.authorization.token == 'a~!@#$%^&*()_+b='
(Bearer tokens with an equals sign at the end are processed as tokens)Authorization: Bearer username=username,password=password
–>request.authorization.parameters == {"username": "username", "password": "password"}
(using parameters will give the same result as the Basic scheme)Authorization: Bearer key1=test,key2
request.authorization.parameters == {"key1":"test", "key2":None}
request.authorization["key1"] == "test"
request.authorization["key2"] == None
request.authorization.key1 == "test"
request.authorization.key2 == None
- (parameters can be accessed through the
parameter
attribute, using dot notation, or using index notation) - (if a key has no value, it’s set to None by default)
Authorization: Bearer token=a
request.authorization.parameters == {"token":"a"}
request.authorization["token"] == "a"
request.authorization.token == None
- (parameters cannot overwrite pre-existing attributes of the
request.authorization
object)
Authorization: Basic a
–>request.authorization == None
(Invalid base64 values with the Basic scheme will cause the entirerequest.authorization
object to beNone
)Authorization: Basic AAAA
–>request.authorization == 'Basic AAAAOg=='
- (when the base64 is valid but does not have a colon in it [indicating the delimiter between username and password], this colon is somehow added to the end)