garrison

garrison

The masking is needed to avoid the BREACH attack, a variant of the CRIME compression attack. CRIME was an attack on compressed headers and was fixed in HTTP2 with the HPACK algorithm which compresses headers in such a way that they are not vulnerable. Unfortunately the CSRF token is present in the body and does not change between requests, meaning it can slowly be revealed if an attacker can tweak the page contents and observe the compression ratio.

Masking literally just generates another token’s worth of bytes as a mask, xors that with the token, and returns the concatenation of the two. This essentially hides (masks) the original token from the compression algorithm, and the mask changes on every request so it cannot be iteratively revealed.

Personally I find the complexity (and therefore bug surface) of CSRF protection quite distressing given that experts believe the token approach is no longer necessary. But of course this is brand new guidance and it will take time for everyone to adapt.

0
Post #11
LostKobrakai

LostKobrakai

Reading the article I’m also not sure if this is any less complex tbh.

0
Post #12
garrison

garrison

That article is a pretty thorough technical deep dive into whether this new approach is secure (and the conclusion is that it is).

In practice the new approach is just comparing a couple of headers sent by the browser, versus creating a magic token (which you have to do very carefully, see above), writing it into the session, and propagating it into every single form and checking it on submit.

The CSRF token approach is a gross hack that has to touch an unreasonable amount of the stack just to check that the request came from the correct origin, which is something that the browser just, like, tells you for free now.

Not to mention the only reason this topic even came up in the Go community is that they found a serious security bug in their CSRF library that had been there for years lol.

1
Post #13
garrison

garrison

Obviously this is security-sensitive code and I am just typing into a forum box so DO NOT USE THIS FOR ANYTHING, but here is a port of the Go implementation.

def check(conn) when conn.method in ["GET", "HEAD", "OPTIONS"] do
  :ok
end

def check(conn) do
  case get_req_header(conn, "sec-fetch-site") do
    [value] when value in ["same-origin", "none"] -> :ok
    [_other] -> {:error, :cross_origin}
    [] -> check_origin(conn)
  end
end

# Fallback for 2020-2023 browsers with Origin but without Sec-Fetch-Site
defp check_origin(conn) do
  case get_req_header(conn, "origin") do
    [origin] ->
      %URI{host: origin_host} = URI.new!(origin)
      case origin_host == conn.host do
        true -> :ok
        false -> {:error, :cross_origin_older_browser}
      end
    [] ->
      # No Sec-Fetch-Site or Origin headers were present
      # This is not a browser (or is a pre-2020 browser)
      :ok
  end
end

Note that the entire Origin fallback section isn’t even necessary if you’re willing to ditch support for pre-2023 browsers (which I personally would tbh), at which point this is like 5 lines of actual code.

Contrast that with using cryptography to generate, sign, and mask a token, write it to a session cookie, and propagate the value into every single form in your application, which is hundreds of lines at least and leaks all the way into the high-level abstractions (forms) of the framework.

I think the path forward is pretty clear. If you’re writing a new framework there is no reason to even think about the token approach. Existing frameworks will adapt over time.

1
Post #14
adamu

adamu

@garrison that is super interesting. Thanks for going into the details. I had read Filippo’s post before and mentally bookmarked it to come back to. My objective in this thread is to understand Phoenix’s current default behaviour, although the explanation of CRIME has raised another question:

  1. The fix seems to depend on the way the headers are compressed (or not). As Plug stores the unmasked token in the cookie, does this mean the attack is still possible in some cases? Maybe this is mitigated by the way the session is serialised (external term format, cryptographically signed, base64)?

  2. The original question @i-n-g-m-a-r asked 5 years ago that I’m re-asking: what is the purpose of sending the (masked) CSRF token back to LV via the socket via JS, considering web components can and do fetch it themselves directly on the server anyway?

1
Post #15
adamu

adamu

Answering my own question in the hope of activating Cunningham’s Law.

The reason that the (masked) CSRF token is passed to the LiveSocket via JS is because this is a new HTTP request, so CSRF is possible (somebody could be serving up a page that loads JS that tries to connect to our web socket using our cookie). As the socket connections are handled in the Endpoint, before the :protect_from_forgery plug in the Router, the socket performs its own validation of the CSRF token:

https://github.com/phoenixframework/phoenix/blob/675e924f73da1a594e07ba22fcbda6636790ce4a/lib/phoenix/socket/transport.ex#L564-L570

In this code, csrf_token is the masked CSRF token passed to the LiveSocket connection request, and csrf_state is the unmasked one from the cookie.

1
Post #16
garrison

garrison

Headers are not vulnerable to CRIME in HTTP2 because they are compressed with an algorithm called HPACK which is part of the standard.

Originally HTTP headers were not compressed at all (in 1.1), but then people started trying to compress them naively (in SPDY) to save on ingress costs. As shown in that CF blog post it actually saves like half of ingress costs because headers are most of a request (not so much for a response).

Unfortunately it was shown that naively compressing headers with e.g. DEFLATE can leak them to an attacker (in an unlikely but plausible scenario), so everyone turned off compression. But compression was nice (made things cheaper), so they designed a special compression algorithm that is not vulnerable, and that’s HPACK.

On a modern stack you can put whatever you want in the headers and it won’t be leaked because they are compressed with HPACK. The reason you have to mask the CSRF token is that it is sent in the body (with the forms).

As a side note, I believe this also means that you should never put any sort of confidential secret into a compressed body alongside variable content or it could theoretically leak. Try not to think about the implications there.

Again the reason the attack is not possible is because HTTP headers are not compressed this way, but if they were then the entire session would be vulnerable to exfiltration.

0
Post #17
garrison

garrison

I have not read the code but this makes perfect sense and I think it’s correct. Note that the token has to be masked because it is again in the body and could be compressed naively.

I know it’s not what you’re asking about but I can’t help but point out that it would be a whole lot easier to just check Sec-Fetch-Site and be done with it rather than pass a token through like four levels of abstraction. Of course this was not possible when the code was written!

1
Post #18
adamu

adamu

A summary for anyone else landing here.

  1. Phoenix uses Plug.CSRFProtection to generate the CSRF token. A token has two forms: a short/unmasked form, which is what is stored in the session, and a long/masked form, which is what is returned by Plug.CSRFProtection.get_csrf_token/0 and injected into the HTML responses. The long form in the HTML is necessary to guard against BREACH attacks.
  2. This is not mentioned in the thread, but it’s also possible for a single HTML page to have multiple masked CSRF tokens that correspond to the same unmasked one in the session. This is because the masked tokens are generated (from the token in the session) per process on the server. The process that returns the initial HTML response from the controller is different to the one that updates the DOM from LiveView.
  3. The reason the masked/long CSRF token is passed to the web socket is to prevent a CSRF request occurring when the websocket is created. Normally the masked CSRF token is validated against the one in the session by the :protect_from_forgery plug, but websocket requests are set up in the Endpoint and don’t use :protect_from_forgery, so the web socket code has its own code to validate the token (which ultimately uses Plug.CSRFProtection under the hood, the same as :protect_from_forgery). Additionally, the purpose of this is not because LiveView needs to otherwise know the long/masked token - it’s just to protect against CSRF when establishing the socket.
  4. As explained in this article by Filippo Valsorda, in 2025 CSRF tokens may not be the best way to protect against CSRF, instead the Sec-Fetch-Site header can be used - but this method is relatively new and is not currently used by Phoenix out of the box.
4
Post #19
garrison

garrison

The only other thing I would note is that calling it a “long” token makes it sound like some sort of magic is going on, but if you look at the implementation it’s actually quite simple.

Generating the masked token is literally equivalent to this:

token = Base.url_encode64(:crypto.strong_rand_bytes(18))
mask = Base.url_encode64(:crypto.strong_rand_bytes(18))

masked_token = Base.url_encode64(:crypto.exor(token, mask)) <> mask

So the masked token is roughly twice as long because the mask is simply concatenated to the token! That way, on the receiving end, the mask can be used to decode the token.

It’s also interesting that the token ends up being base64-encoded twice, which I had not noticed until I typed it out. Seems slightly suboptimal, but I would imagine fixing it wasn’t worth changing the rest of the code.

0
Post #20