11 KiB
id, title, status, assignee, created_date, updated_date, labels, dependencies, references, priority, ordinal
| id | title | status | assignee | created_date | updated_date | labels | dependencies | references | priority | ordinal | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ML-181 | Harden public asset transforms against unbounded resource consumption and cache amplification | To Do | 2026-05-13 18:33 | 2026-05-13 19:36 |
|
|
medium | 9000 |
Description
The unauthenticated GET /public/assets/:transform_payload endpoint decodes arbitrary JSON into a %Transform{} struct without validating width type, range, or maximum. An attacker with a valid asset hash (obtainable from public email image URLs) can craft unlimited variant payloads, forcing repeated native libvips resize attempts and amplifying ETS cache entries. Prior hardening (ml-35, commit 92a36b91) fixed crashes from malformed payloads but left resource-control validation open.
Acceptance Criteria
- #1 Transform.decode/1 rejects payloads with string width (400 Bad Request)
- #2 Transform.decode/1 rejects payloads with negative width (400 Bad Request)
- #3 Transform.decode/1 rejects payloads with zero width (400 Bad Request)
- #4 Transform.decode/1 rejects payloads with float width (400 Bad Request)
- #5 Transform.decode/1 rejects payloads with width > 2048 (400 Bad Request)
- #6 Transform.decode/1 accepts width: nil (original size, no resize)
- #7 Transform.decode/1 accepts widths in 1..2048 (e.g., 96, 150, 480, 300, 2048)
- #8 Canonical cache key collates variant payloads for the same (hash, width) into a single ETS entry
- #9 Existing email image URLs (width: 96) continue to serve correctly
- #10 API and authenticated asset routes continue to work unchanged
- #11 Existing controller and LiveView tests pass without modification
Implementation Plan
Objective Alignment
This plan addresses F1 from the security review by adding two complementary defenses:
- Width validation in
Transform.decode/1anddecode!/1— Rejects payloads wherewidthis notnilor a positive integer in range1..2048, preventing unbounded libvips thumbnail operations. - Canonical cache key derivation — Derives ETS cache keys from the validated
%Transform{hash, width}struct instead of the raw payload string, eliminating cache amplification from variant JSON representations. HTTP ETag remains the raw payload to preserve existing test assertions and browser caching behavior.
Together these close both the resource consumption vector (arbitrary widths → native image processing) and the cache amplification vector (variant payload strings → unbounded ETS entries).
Alternatives Considered
See doc-21 - ML-181-Research-Public-Asset-Transform-Hardening-Routes.md for full analysis. Route 3 (Combined) was selected as the simplest approach that fully addresses the finding. Route 4 (payload signing) is deferred as overkill for the single-operator threat model.
Implementation Steps
Step 1: Add width validation to Transform.decode/1 and decode!/1
File: lib/music_library/assets/transform.ex
Add a private valid_width?/1 guard that checks:
nil→ valid (no resize, serve original)- Positive integer in
1..2048→ valid - Anything else (string, negative, zero, float, very large) → invalid
Update decode/1 to call valid_width?/1 after parsing params, before constructing the struct. Return {:error, :invalid_payload} on validation failure.
Update decode!/1 to delegate to decode/1 and raise on the error tuple, keeping the ! convention consistent:
def decode!(payload) do
case decode(payload) do
{:ok, transform} -> transform
{:error, :invalid_payload} -> raise ArgumentError, "invalid transform payload"
end
end
This replaces the current decode!/1 body which manually decodes base64/JSON without validation.
Dependencies: None Verification:
- Run doctests:
mix test test/music_library/assets/transform_test.exs - The existing doctest (
width: 300) still passes (300 is in range) - The
decode!/1doctest still passes (same valid payload now flows through validation) - New unit tests (see Step 4) confirm rejection of edge cases
Step 2: Add canonical_key/1 to Transform module
File: lib/music_library/assets/transform.ex
Add a public function with a doctest:
@doc """
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}"
Dependencies: Step 1 (struct is now guaranteed valid) Verification: Doctest confirms deterministic output
Step 3: Update AssetController to use canonical cache key (ETS only)
File: lib/music_library_web/controllers/asset_controller.ex
Changes:
- In
show/2, after successful decode, computecache_key = Transform.canonical_key(transform) - Pass
cache_key(notpayload) as the first argument tocached_get/3 - In
cached_get/3, use the first argument forCache.get/2andCache.set/3— rename the parameter frompayloadtocache_keyfor clarity - Keep
payloadas the HTTP ETag value (unchanged) — theif-none-matchcomparison inshow/2and therespond_with_cache/5call continue to use the rawpayloadstring
The separation is:
- ETS cache key: canonical
"hash:width"— prevents cache amplification - HTTP ETag: raw payload string — preserves existing test assertions on
get_resp_header(conn, "etag")
Dependencies: Steps 1 and 2 Verification:
- Existing controller tests pass without modification:
mix test test/music_library_web/controllers/asset_controller_test.exs- ETag assertions all check against the raw
payload, which is unchanged
- ETag assertions all check against the raw
- Existing LiveView tests pass (they use Transform for cover URLs):
mix test test/music_library_web/live/collection_live/
Step 4: Add tests
File: test/music_library/assets/transform_test.exs
Add tests for decode/1 with invalid widths:
- String width:
%{hash: "abc", width: "300"}→{:error, :invalid_payload} - Negative width:
%{hash: "abc", width: -1}→{:error, :invalid_payload} - Zero width:
%{hash: "abc", width: 0}→{:error, :invalid_payload} - Float width:
%{hash: "abc", width: 300.5}→{:error, :invalid_payload} - Very large width:
%{hash: "abc", width: 99999}→{:error, :invalid_payload} - Nil width:
%{hash: "abc", width: nil}→ valid - Max allowed width:
%{hash: "abc", width: 2048}→ valid
Add a doctest for canonical_key/1 (included in Step 2 above).
File: test/music_library_web/controllers/asset_controller_test.exs
Add integration tests:
- Payload with string width returns 400
- Payload with negative width returns 400
- Payload with very large width returns 400
Add test for cache amplification fix:
- Two variant payloads encoding the same (hash, width) produce the same cache entry (verify by checking that the second request does not trigger a new resize — either inspect ETS entry count or verify response timing)
Dependencies: Steps 1-3
Verification: mix test — all tests pass
Step 5: Update inline module documentation
File: lib/music_library/assets/transform.ex
Update @moduledoc to document:
- The width validation rules:
nil(original size) or positive integer1..2048 - The
canonical_key/1function and its purpose for cache deduplication
File: lib/music_library/assets/cache.ex
Update the "Cache key" section of @moduledoc:
- Remove: "where
payloadis a transform parameter string (encoding width and asset hash)" - Replace with: "where the key string is an opaque canonical transform key provided by the caller (currently
"hash:width"fromTransform.canonical_key/1)"
This reflects that Cache no longer knows or cares about the key's origin — it's just an opaque string.
Dependencies: Steps 1-3
Verification: Review updated doc output with h MusicLibrary.Assets.Transform and h MusicLibrary.Assets.Cache
Architecture Impact Analysis
| Touchpoint | Impact |
|---|---|
MusicLibrary.Assets.Transform |
Adds validation to decode/1 and decode!/1 + new canonical_key/1 function |
MusicLibraryWeb.AssetController |
Uses canonical key for ETS Cache; HTTP ETag continues to use raw payload |
MusicLibrary.Assets.Cache |
No code change — key format remains {string, format}, but the string is now canonical |
MusicLibrary.Assets.Image |
No change |
MusicLibraryWeb.RecordsOnThisDayEmail |
No change — already uses valid width (96) |
MusicLibraryWeb.CollectionJSON |
No change — uses valid widths (nil, 150, 480) |
| All LiveViews using Transform | No change — no width or valid widths only |
| Routes | No change |
| ETS cache | Existing entries invalidated on deploy restart (in-memory, expected) |
| HTTP caching (ETag/If-None-Match) | No change — raw payload remains the ETag, preserving existing browser-cached responses |
| External APIs | Not affected |
Performance Profile
valid_width?/1: O(1) — two guard checks (is_nilandis_integer and > 0 and ≤ 2048)canonical_key/1: O(1) — string concatenation of two fields- Cache lookup: Unchanged — single
:ets.lookup/2per request - Memory: Reduced — canonical keys collapse variant payloads into single entries
- N+1 risk: None — no new queries introduced
- Latency impact: Negligible (< 1µs for validation + key concatenation)
Benchmarking Requirements
No benchmarks needed. The change removes work (fewer cache entries, early rejection of invalid payloads) rather than adding it.
Cost Profile
No cost impact — no paid resources are consumed.
Production Infrastructure Steps
No production changes required:
- No environment variables
- No database migrations
- No service provisioning
- ETS cache is in-memory and auto-clears on application restart during deploy
- No DNS, firewall, or Coolify configuration changes
Documentation Updates
doc-21(already created): Contains the full research analysis and route comparisonTransform@moduledoc: Updated in Step 5 to document width validation rules andcanonical_key/1Cache@moduledoc: Updated in Step 5 to describe the key as opaque rather than raw-payload-specificdocs/architecture.md: No update needed — asset serving path is not documented at this granularitydocs/project-conventions.md: No update needed