ML-181: harden assets endpoints

This commit is contained in:
Claudio Ortolina
2026-05-22 08:20:35 +01:00
parent 5601d681e9
commit d8a67cd475
6 changed files with 202 additions and 31 deletions
+4 -3
View File
@@ -4,9 +4,10 @@ defmodule MusicLibrary.Assets.Cache do
## Cache key
Each entry is keyed by `{payload, format}` where `payload` is a transform
parameter string (encoding width and asset hash) and `format` is the output
MIME type (e.g. `"image/webp"`).
Each entry is keyed by `{key, format}` where `key` is an opaque canonical
transform key provided by the caller (currently `"hash:width"` from
`Transform.canonical_key/1`) and `format` is the output MIME type
(e.g. `"image/webp"`).
## TTL and pruning
+37 -10
View File
@@ -1,6 +1,18 @@
defmodule MusicLibrary.Assets.Transform do
@moduledoc """
Represents an image transformation (hash + target width) for asset serving.
## Width validation
`decode/1` validates the `width` field: it must be `nil` (serve original size)
or a positive integer in `1..2048`. Any other value (string, negative, zero,
float, or very large) returns `{:error, :invalid_payload}`.
## Canonical cache key
`canonical_key/1` produces a deterministic `"hash:width"` string used as the
ETS cache key in `MusicLibraryWeb.AssetController`, collapsing variant JSON
payloads that encode the same (hash, width) into a single cache entry.
"""
@derive JSON.Encoder
@@ -39,11 +51,19 @@ defmodule MusicLibrary.Assets.Transform do
iex> Transform.decode("!!!invalid")
{:error, :invalid_payload}
"""
@max_width 2048
@spec decode(payload()) :: {:ok, t()} | {:error, :invalid_payload}
def decode(payload) do
with {:ok, decoded} <- Base.url_decode64(payload, padding: false),
{:ok, params} when is_map(params) <- JSON.decode(decoded) do
{:ok, struct!(__MODULE__, %{hash: params["hash"], width: params["width"]})}
width = params["width"]
if valid_width?(width) do
{:ok, struct!(__MODULE__, %{hash: params["hash"], width: width})}
else
{:error, :invalid_payload}
end
else
_ -> {:error, :invalid_payload}
end
@@ -57,16 +77,23 @@ defmodule MusicLibrary.Assets.Transform do
"""
@spec decode!(payload()) :: t()
def decode!(payload) do
params =
payload
|> Base.url_decode64!(padding: false)
|> JSON.decode!()
struct!(__MODULE__, %{
hash: params["hash"],
width: params["width"]
})
case decode(payload) do
{:ok, transform} -> transform
{:error, :invalid_payload} -> raise ArgumentError, "invalid transform payload"
end
end
@doc """
iex> alias MusicLibrary.Assets.Transform
iex> Transform.canonical_key(%Transform{hash: "abc123", width: 96})
"abc123:96"
"""
@spec canonical_key(t()) :: String.t()
def canonical_key(%__MODULE__{hash: hash, width: width}), do: "#{hash}:#{width}"
defp valid_width?(nil), do: true
defp valid_width?(width) when is_integer(width) and width > 0 and width <= @max_width, do: true
defp valid_width?(_), do: false
end
defimpl Phoenix.Param, for: MusicLibrary.Assets.Transform do
@@ -17,7 +17,9 @@ defmodule MusicLibraryWeb.AssetController do
bad_request(conn)
{:ok, transform} ->
case cached_get(payload, transform, format) do
cache_key = Transform.canonical_key(transform)
case cached_get(cache_key, transform, format) do
nil ->
not_found(conn)
@@ -30,12 +32,12 @@ defmodule MusicLibraryWeb.AssetController do
end
end
defp cached_get(_payload, transform, _format) when is_nil(transform.hash) do
defp cached_get(_cache_key, transform, _format) when is_nil(transform.hash) do
nil
end
defp cached_get(payload, transform, format) do
case Cache.get(payload, format) do
defp cached_get(cache_key, transform, format) do
case Cache.get(cache_key, format) do
:not_found ->
if asset = Assets.get(transform.hash) do
result =
@@ -47,7 +49,7 @@ defmodule MusicLibraryWeb.AssetController do
case result do
{:ok, image_data} ->
Cache.set(payload, format, image_data)
Cache.set(cache_key, format, image_data)
image_data
{:error, reason} ->