diff --git a/lib/plug/static.ex b/lib/plug/static.ex index f55b8b8e..6b0b5475 100644 --- a/lib/plug/static.ex +++ b/lib/plug/static.ex @@ -285,7 +285,8 @@ defmodule Plug.Static do defp serve_range(conn, file_info, path, [range], options) do file_info(size: file_size) = file_info - with %{"bytes" => bytes} <- Plug.Conn.Utils.params(range), + with true <- if_range_matches?(conn), + %{"bytes" => bytes} <- Plug.Conn.Utils.params(range), # 41 bytes covers two 64 bit byte offsets, enough to address files up to 16 EiB. true <- byte_size(bytes) <= 41, {range_start, range_end} <- start_and_end(bytes, file_size) do @@ -300,6 +301,16 @@ defmodule Plug.Static do send_entire_file(conn, path, options) end + # The range is only served if If-Range, when given, is the current strong + # ETag. Dates never match because no Last-Modified is sent. See RFC 9110, 13.1.5. + defp if_range_matches?(conn) do + case {get_req_header(conn, "if-range"), get_resp_header(conn, "etag")} do + {[], _} -> true + {[etag], [etag]} -> not String.starts_with?(etag, "W/") + _ -> false + end + end + defp start_and_end(_range, 0), do: :error defp start_and_end("-" <> rest, file_size) do diff --git a/test/plug/static_test.exs b/test/plug/static_test.exs index 544eb9d9..6cabf5cb 100644 --- a/test/plug/static_test.exs +++ b/test/plug/static_test.exs @@ -527,6 +527,46 @@ defmodule Plug.StaticTest do end end + test "serves the range only if if-range matches the etag" do + conn = call(conn(:get, "/public/fixtures/static.txt")) + assert [etag] = get_resp_header(conn, "etag") + + conn = + conn(:get, "/public/fixtures/static.txt", []) + |> put_req_header("range", "bytes=0-1") + |> put_req_header("if-range", etag) + |> call() + + assert conn.status == 206 + assert conn.resp_body == "HE" + assert get_resp_header(conn, "content-range") == ["bytes 0-1/5"] + + for if_range <- [~s("other"), "W/" <> etag, "Sun, 25 Oct 2026 00:00:00 GMT"], + range <- ["bytes=0-1", "bytes=10-20"] do + conn = + conn(:get, "/public/fixtures/static.txt", []) + |> put_req_header("range", range) + |> put_req_header("if-range", if_range) + |> call() + + assert conn.status == 200, "expected 200 for #{if_range} and #{range}" + assert conn.resp_body == "HELLO" + assert get_resp_header(conn, "content-range") == [] + end + end + + test "ignores range if if-range is given and there is no etag" do + conn = + conn(:get, "/public/fixtures/static.txt?vsn=bar", []) + |> put_req_header("range", "bytes=0-1") + |> put_req_header("if-range", ~s("abc")) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "HELLO" + assert get_resp_header(conn, "etag") == [] + end + test "performs etag negotiation" do conn = conn(:get, "/public/fixtures/static.txt")