From 542200b2c03cc67a4157bda4d6aa12f71404235b Mon Sep 17 00:00:00 2001 From: Wleeaf <179125857+wleeaf@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:10:46 +0000 Subject: [PATCH] Use weak comparison and accept lists and * in Plug.Static If-None-Match --- lib/plug/static.ex | 16 +++++++++++++++- test/plug/static_test.exs | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/plug/static.ex b/lib/plug/static.ex index f55b8b8e4..991e3c7f7 100644 --- a/lib/plug/static.ex +++ b/lib/plug/static.ex @@ -391,7 +391,7 @@ defmodule Plug.Static do |> put_resp_header("cache-control", et_cache) |> put_resp_header("etag", etag) - if etag in get_req_header(conn, "if-none-match") do + if etag_matches?(get_req_header(conn, "if-none-match"), etag) do {:fresh, conn} else {:stale, conn} @@ -402,6 +402,20 @@ defmodule Plug.Static do {:stale, conn} end + # If-None-Match is "*" or a list of ETags, compared with the weak comparison + # function, so the W/ prefix is ignored. See RFC 9110, 13.1.2. + defp etag_matches?(if_none_match, etag) do + opaque_tag = strip_weak(etag) + + Enum.any?(if_none_match, fn value -> + value == etag or + Enum.any?(Plug.Conn.Utils.list(value), &(&1 == "*" or strip_weak(&1) == opaque_tag)) + end) + end + + defp strip_weak("W/" <> etag), do: etag + defp strip_weak(etag), do: etag + defp etag_for_path(file_info, et_generation, path) do case et_generation do {module, function, args} -> diff --git a/test/plug/static_test.exs b/test/plug/static_test.exs index 544eb9d91..36eb24cea 100644 --- a/test/plug/static_test.exs +++ b/test/plug/static_test.exs @@ -127,6 +127,32 @@ defmodule Plug.StaticTest do assert get_resp_header(conn, "etag") == [etag] end + test "performs etag negotiation with weak etags, lists and *" do + conn = call(conn(:get, "/public/fixtures/static.txt")) + assert [etag] = get_resp_header(conn, "etag") + + for if_none_match <- ["W/" <> etag, ~s("other", ) <> etag, ~s("other",W/) <> etag, "*"] do + conn = + conn(:get, "/public/fixtures/static.txt", nil) + |> put_req_header("if-none-match", if_none_match) + |> call() + + assert conn.status == 304, "expected 304 for #{if_none_match}" + assert conn.resp_body == "" + assert get_resp_header(conn, "etag") == [etag] + end + + for if_none_match <- [~s("other"), ~s("other", W/"another")] do + conn = + conn(:get, "/public/fixtures/static.txt", nil) + |> put_req_header("if-none-match", if_none_match) + |> call() + + assert conn.status == 200, "expected 200 for #{if_none_match}" + assert conn.resp_body == "HELLO" + end + end + defmodule EtagGenerator do def generate(path, a, b) do {:ok, contents} = :prim_file.read_file(path)