0%

Who May Connect? · practice

Check a Token Before 101

A scorekeeper page can carry its token only in the query , a Python client can send a Bearer header, and today the server reads neither. This lesson writes the decision: given a ’s path and headers, may this client score? The next lesson makes the library ask that question during the handshake.

Before 101, or never

The handshake is an exchange, and 101 Switching Protocols is its last HTTP message. After it, the connection speaks , and the handler is already running. A refusal at that point could only be a , sent on a connection the server has just accepted. A refusal before it is an ordinary , and no ever exists.

You have refused upgrades before. In Chapter 2, check_upgrade answered a malformed upgrade with 400 and a wrong version with 426, instead of 101. A missing token is the same kind of answer: a status, decided from the request’s line and headers, sent before any frame.

Why must the token be checked before the server answers 101?

Where the token is

The header rule is the one from HTTP with Python: one Authorization , split at its first space with partition(" "), the scheme bearer in any case, and the rest stripped.

What does this print?

print("bearer  tok".partition(" "))

The token comes back with a space in front, which is why the rest is stripped before it is compared.

A header can appear more than once in a request, and the library’s Headers keeps every copy. Its get raises MultipleValuesError when a name repeats, so read the header with headers.get_all("authorization"), which returns a of every value (empty when the header is missing). Two values are refused rather than guessed between.

With no Authorization header at all, look in the query. parse_qs, from the same urllib.parse as urlsplit, turns a query string into a that maps each name to a list of its values, decoding percent-escapes and + on the way:

from urllib.parse import parse_qs, urlsplit

print(urlsplit("/score?token=abc%2Fdef&x=1").path)
print(parse_qs("token=abc%2Fdef&token=2"))
print(parse_qs("token="))
print(parse_qs("token=a+b"))

The first line prints /score: the path without its query. The others print {'token': ['abc/def', '2']}, with %2F decoded to / and both values kept in the list; {}, because an empty value is dropped; and {'token': ['a b']}, with + decoded to a space.

So the query must hold exactly one token value: an empty token= leaves none, and two values are refused like two headers. find_token(path, headers) returns the token it found or None, by these rules:

What the request carriesWhat decides
exactly one Authorization header of the form Bearer <token>that header’s token
any other Authorization header: another scheme, no token, or two headersnobody: None, and the query is not read
no Authorization headerthe one token value in the query, else None

The middle row matters. A bad header does not fall back to the query: a client that sent a credential is judged by that credential.

Compare without leaking time

token == expected gives the right answer, but == on two strings stops at the first character that differs. A wrong guess whose first ten characters are right takes a little longer to refuse than one that is wrong from the start. Measured over many tries, that difference can tell an attacker how much of a guess was right, and let them build the token one character at a time. Such timing attacks across a network are hard but real, and the standard defense costs one line: hmac.compare_digest(a, b) takes the same time whatever part of the values matches. (Timing can still show the length of the values, but not which characters were right.) The course uses that tool rather than reasoning about when the attack would work.

One detail: compare_digest refuses str values that hold characters outside ASCII.

import hmac

hmac.compare_digest("café", "cafe")

A token from the query can be such a string after parse_qs decodes it (caf%C3%A9 becomes café), and so can a header value. Compare bytes instead: hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")) works for any text. Call it through the module, as hmac.compare_digest, with the import hmac line already at the top of scoreboard_server.py.

Why hmac.compare_digest instead of == for the token?

401, and why not 403

HTTP with Python drew the line: 401 when the server does not know who you are, 403 when it knows and still says no. The scoreboard has one token and one permission, to score. A client with no token, or a wrong one, is someone the server cannot identify, so every refusal on /score is 401. You will meet a 403 in Lesson 4, from a different check. /watch needs no token, so check_request(path, headers, expected) compares urlsplit(path).path with "/score" first and returns None for anything else; a query string cannot hide the path that way.

A scorekeeper sends the wrong token. Why 401 here and not 403?

The token file

The expected token lives in token.txt in your workspace. It holds a made-up practice token, not a secret: a real token never lives in a source tree, where every copy of the code carries it. Real services load it from the environment or a secret store. read_token(path) opens the file with encoding="utf-8" in a with block and returns its text stripped of the newline and any spaces around it.

Your task

In scoreboard_server.py, write read_token, find_token and check_request as their docstrings and this lesson describe. Nothing calls check_request during a handshake yet.

Press Run. The Lesson 2 section builds five requests with the library’s Headers and prints, for each, the path without its query, whether an Authorization header was sent, whether the query held a token, and what check_request answered. It never prints a token. A finished version, run on the site, printed:

Lesson 2: who may score?
    /watch, no Authorization header, a token in the query: False -> None
    /score, no Authorization header, a token in the query: False -> 401
    /score, an Authorization header, a token in the query: False -> None
    /score, no Authorization header, a token in the query: True -> None
    /score, an Authorization header, a token in the query: False -> 401

The viewer passes with nothing. The scorekeeper with nothing gets 401. The third and fourth requests carry the demo’s expected token, one in a header and one in the query. The last sends a guess in a header. Then press Submit.

Next lesson hands check_request to the library, so that a 401 goes out instead of a 101.

Task

In scoreboard_server.py:

  1. Write read_token(path): open the file at path with encoding="utf-8" in a with block and return its text with .strip().

  2. Write find_token(path, headers). Its docstring states the rule: “If any Authorization header is present, decide from the headers alone: the token of exactly one Bearer header, else None (a bad or repeated header never falls back to the query). Only with no Authorization header, return the one token query , else None.” Read the header with headers.get_all("authorization"), split its value with partition(" "), compare the scheme with "bearer" after .lower(), and strip the token; an empty token counts as none. Read the query with parse_qs(urlsplit(path).query).get("token"), which must be a of exactly one value.

  3. Write check_request(path, headers, expected): return None unless urlsplit(path).path is "/score"; return 401 when find_token finds no token or when hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")) is false; otherwise return None. Keep the import hmac line and call compare_digest through the .

Press Run: the Lesson 2 section prints None for /watch, 401 for /score with nothing, None for the expected token in a header and in the query, and 401 for a guess.

Then press Submit. The check uses an expected token you have not seen. It sends requests that carry it in a Bearer header (with the scheme and the header name in other cases, and extra spaces), in the query on its own or beside other values, and requests that must get 401: no token, a wrong token, the token with one more character, the end of the token only, another scheme, Bearer with nothing after it, two Authorization headers, a bad header beside the right query token, two token values, an empty one, and tokens with non-ASCII text, which must not raise. /watch is let through whatever its query holds. To see that compare_digest makes the decision, the check replaces scoreboard_server.hmac with a stand-in whose compare_digest gives a fixed answer, so a from hmac import line is refused; each call must pass two values of one type, and the non-ASCII cases need that type to be bytes. read_token reads token.txt and a second file the check writes beside it. Nothing may be printed.