--- id: doc-20 title: Deepseek-v4-pro Security Review type: other created_date: "2026-05-13 18:27" --- ## Trust boundaries | Actor | Trusted | Controls | Source | | ---------------------- | ----------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Web user (browser) | no | URL params, form fields, file uploads, LiveView events, HTTP headers | All routes behind `require_logged_in` (except login, health, Last.fm callback, public assets) — `MusicLibraryWeb.Auth` | | Presto API consumer | conditional | HTTP Authorization header, query params (`?q=`) | Requires Bearer token validated via `Plug.Crypto.secure_compare/2` in `Auth.require_api_token/2` — `api/v1/*` routes | | Last.fm OAuth callback | no | `token` query parameter (validated server-side against Last.fm) | `LastFmController` `moduledoc` — single-user deployment, token validated via `LastFm.get_session/1` | | Operator / config | yes | Application environment variables and config files | `config/runtime.exs` and `config/prod.exs` — all secrets originate from env vars | | MusicBrainz API | conditional | JSON responses with record/artist metadata | `MusicBrainz.API` — data going to HTML is sanitised via MDEx/ammonia; SQL uses parameterised bindings | | Last.fm API | conditional | JSON responses with scrobble/artist data | `LastFm.API` — same sanitisation path | | OpenAI API | conditional | Chat responses (markdown), embeddings (float arrays) | `OpenAI.API` — responses rendered through MDEx HTML sanitisation | | Wikipedia API | conditional | Plain-text article extracts | `Wikipedia.API` — extracts go through MDEx markdown sanitisation | | Brave Search / Discogs | conditional | Image URLs, artist metadata | Rendered through MDEx, or used as `href` targets | | Mailgun (outbound) | yes | Outbound email only (no inbound parsing) | `MusicLibrary.Mailer` — Swoosh adapter | ## Inventory | ID | Location | Class | Consumes | | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | S1 | `lib/music_library_web/components/notes.ex:164` | Template / `raw()` | Note content (user-authored markdown) via `Markdown.to_html()` | | S2 | `lib/music_library_web/components/core_components.ex:149` | Template / `raw()` | Debug JSON via `Lumis.highlight!()` — dev/monitoring routes only | | S3 | `lib/music_library_web/components/search_components.ex:401` | Template / `raw()` | Record set description via `Markdown.to_html()` | | S4 | `lib/music_library_web/components/chat.ex:228` | Template / `raw()` | AI-generated assistant message via `Markdown.to_html()` | | S5 | `lib/music_library_web/components/chat.ex:243` | Template / `raw()` | AI streaming response via `Markdown.streaming_to_html()` | | S6 | `lib/music_library_web/live/artist_live/biography.ex:70` | Template / `raw()` | Wikipedia/Last.fm extract via `Markdown.to_html()` | | S7 | `lib/music_library_web/live/artist_live/show.ex:756` | Template / `raw()` | Pre-compiled biography HTML (`biography.bio_html`) | | S8 | `lib/music_library_web/live/record_set_live/show.ex:275` | Template / `raw()` | Record set description via `Markdown.to_html()` | | S9 | `lib/music_library_web/live/record_set_live/index.ex:446` | Template / `raw()` | Record set description via `Markdown.to_html()` | | S10 | `lib/music_library_web/live/stats_live/top_by_period.ex:118` | Resource consumption | URL param `period` via `String.to_existing_atom/1` | | S11 | `lib/music_library/online_store_templates.ex:87-90` | Template (URL) | User-defined `url_template` interpolated with `String.replace` + `URI.encode_www_form` | | S12 | `lib/music_library_web/router.ex` (CSP header) | Validation / CSP | Content-Security-Policy with `'unsafe-inline'` scripts and `'wasm-unsafe-eval'` | | S13 | All HTTP client modules (`LastFm.API`, `MusicBrainz.API`, `OpenAI.API`, `Discogs.API`, `BraveSearch.API`, `Wikipedia.API`) | Network | Req default TLS verification (no `:verify_none`) | | S14 | `lib/last_fm/api.ex` (log callbacks) | Shared mutable state / log injection | `sanitize_url/2` redacts API key from logged URLs | | S15 | `lib/music_library_web/controllers/archive_controller.ex:21` | File operations | `send_download` with config-derived database path | | S16 | `lib/music_library_web/components/record_form.ex:434` | File operations | `File.read!/1` on LiveView upload temp path | | S17 | `lib/music_library_web/live/artist_live/form.ex:209` | File operations | `File.read!/1` on LiveView upload temp path | | S18 | `lib/music_library_web/auth.ex:25` | Cryptography | Login password comparison via `Plug.Crypto.secure_compare/2` | | S19 | `lib/music_library_web/auth.ex:42` | Cryptography | API token comparison via `Plug.Crypto.secure_compare/2` | | S20 | `lib/music_library_web/controllers/error_controller.ex:11-16` | Validation / resource consumption | API query params `status`, `muted`, `search`, `limit`, `offset` — parsed to atoms/integers, no raw SQL | | S21 | `lib/music_library_web/markdown.ex:103-107` | Template / `raw()` return value | `process_double_bracket_links/1` — `URI.encode_www_form` on bracket content, then MDEx/ammonia sanitisation | | S22 | `lib/music_library_web/records_on_this_day_email.ex:59` | Template / interpolation | Email HTML body: record titles and artist names via `html_escape/1` | | S23 | `lib/music_library/chats.ex:74-77` | Template / SQL | Chat `topic` from first-message content truncated to 80 chars — `String.slice`, no raw SQL context | | S24 | All Ecto `fragment()` call-sites in `records/search.ex`, `scrobble_rules.ex`, `maintenance.ex`, `listening_stats.ex`, `collection.ex`, `artists.ex`, `errors.ex`, `records/import.ex` | SQL injection | User-supplied or externally-supplied strings used in `fragment()` calls with `^` parameterised bindings | ## Findings ### F1 — Artist name parameter smuggling in online store URL templates **Severity:** Low **CWE:** CWE-88 (Argument Injection) **Location:** `lib/music_library/online_store_templates.ex:87-90` **Sinks:** S11 **Trace:** 1. Operator creates an `OnlineStoreTemplate` with `url_template` = `"https://store.example.com/search?q={artist}+{title}"`. 2. Artist names originate from MusicBrainz JSON responses → `MusicBrainz.Release.from_api_response/1` → stored in `records.artists` JSON column → read via `Record.artist_names/1`. 3. `OnlineStoreTemplates.generate_url/2` calls `String.replace(template.url_template, "{artist}", URI.encode_www_form(artists_string))`. 4. `artists_string = Enum.map_join(record.artists, " ", & &1.name)` — this produces a string with literal spaces, not `%20`. 5. `URI.encode_www_form/1` encodes spaces as `+` but does not strip or encode structural characters (`?`, `#`, `&`, `=`) that may already be present in artist names from upstream data. A MusicBrainz artist whose name contains `&sort=price` (or any URL structural character) causes the generated store URL to carry an unintended query parameter or fragment. For example, an artist named `"Porcupine Tree &sort=price"` would produce a URL where `&sort=price` is interpreted as a separate query parameter by the target store. **Boundary:** Artist name originates at the MusicBrainz API (trusted but not controlled by the operator). The template is operator-defined and validated via `URI.parse/1` to require `http` or `https` scheme. **Validation:** ```elixir # scripts/music_library/online_store_url_smuggling.exs Mix.install([{:jason, "~> 1.4"}]) # Simulate the generate_url logic defmodule Smuggle do def generate_url(template, artists_string, title) do encoded_artists = URI.encode_www_form(artists_string) encoded_title = URI.encode_www_form(title) template |> String.replace("{artist}", encoded_artists) |> String.replace("{title}", encoded_title) |> String.replace("{format}", URI.encode_www_form("vinyl")) end end # Benign case IO.puts("Benign:") url1 = Smuggle.generate_url( "https://store.example.com/search?q={artist}+{title}", "Porcupine Tree", "In Absentia" ) IO.puts(" #{url1}") # Artist name with structural characters IO.puts("\nArtist name containing '&sort=price':") url2 = Smuggle.generate_url( "https://store.example.com/search?q={artist}+{title}", "Porcupine Tree &sort=price", "In Absentia" ) IO.puts(" #{url2}") # Artist name with fragment IO.puts("\nArtist name containing '#fragment':") url3 = Smuggle.generate_url( "https://store.example.com/search?q={artist}+{title}", "Artist Name #bonus", "Album Title" ) IO.puts(" #{url3}") ``` **Prior art:** No related git commits, issues, or PRs found. The `validate_url_template/1` changeset function was added to prevent non-HTTP schemes, but structural-character encoding in substitution values was not addressed. **Reach:** Consumer is a single-user personal music-collection application. The `generate_url/2` output is rendered as an `` on the wishlist detail page. Exploitation requires a malicious upstream data source (MusicBrainz) to serve an artist name with URL structural characters, which is then stored and later interpolated into a store template URL. The impact is link manipulation on an internal-facing wishlist page — an operator clicking a store link could be sent to a URL with additional query parameters or a truncated fragment. **Rating:** Low — requires an attacker who can control MusicBrainz artist data (unrealistic for a curated database like MusicBrainz) or the operator to manually create a record with a crafted artist name, which is self-inflicted. Impact is limited to query-parameter injection in outbound store links, not to the application itself. **Suggested fix:** Apply `URI.encode_www_form/1` to the complete query-value portion (i.e. encode the per-artist names individually and join with `+`), or use `URI.encode/2` with `&URI.char_unreserved?/1` to encode all reserved characters including `&`, `?`, `#`, and `=`. --- ## Ruled out - **S1–S9** (step 3 — sanitisation) — Every `raw()` call site is preceded by `Markdown.to_html/1` (backed by `MDEx.Document.default_sanitize_options()` which wraps ammonia), by `Lumis.highlight!/2` outputting static syntax-highlighted HTML from controlled debug JSON (S2, dev routes only), or by a pre-compiled `biography.bio_html` string that was itself produced through the MDEx pipeline (S7). The `sobelow_skip` annotations at each site correctly identify that the upstream sanitisation has already run. No path from untrusted input reaches `raw()` without traversing ammonia-based HTML filtering. - **S10** (step 1 — internal) — `String.to_existing_atom/1` only succeeds for atoms already present in the atom table (the `:period` enum values: `:7d`, `:30d`, `:90d`, `:1y`, `:all_time`). If the URL param doesn't match, it raises `ArgumentError`. This does not create new atoms and cannot exhaust the atom table. - **S12** (step 5 — reach) — `'unsafe-inline'` for scripts/styles is a known tradeoff in Phoenix LiveView applications (morphdom inline patching, colocated hooks). `'wasm-unsafe-eval'` is required by `zxing-wasm` (barcode-detector). The app additionally uses `protect_from_forgery`, CSRF tokens on LiveSocket, and `Content-Type: application/x-www-form-urlencoded` form submissions. The risk surface is comparable to any modern LiveView deployment; no additional CSP bypass vector beyond the standard LiveView surface exists. - **S13** (step 1 — no boundary crossing) — All HTTP clients use Req's default TLS verification. No `verify: :verify_none`, no `transport_opts` override, no custom `:ssl` configuration found in any API module or config file. The `force_ssl` option in `runtime.exs` is commented out but references a Coolify reverse-proxy TLS terminator in production (documented in `docs/production-infrastructure.md`). - **S14** (step 3 — validated) — `sanitize_url/2` in `LastFm.API` redacts the API key from log output with `String.replace(url, api_key, "")`. MusicBrainz/OpenAI/BraveSearch/Discogs/Wikipedia API modules log full URLs but do not include API keys in URL paths (they use headers or auth structs, not query params). No API key leakage to logs. - **S15** (step 1 — config-derived) — `database_path/0` reads `Application.get_env(:music_library, MusicLibrary.Repo)[:database]`, a value set from `DATABASE_PATH` env var in `runtime.exs`. No user input reaches the file path; the `sobelow_skip ["Traversal.SendDownload"]` annotation is correct. - **S16–S17** (step 2 — Phoenix-managed) — LiveView `allow_upload` provides temporary paths via `entry.client_type`. Phoenix generates these on disk; the caller reads them via `File.read!/1` before storing the content as an Asset (content-addressed by SHA-256 hash). No path traversal possible because the path is assigned by the framework, not derived from user input. - **S18–S19** (step 3 — validated) — Both login password and API token comparisons use `Plug.Crypto.secure_compare/2`, which is constant-time. No timing side-channel. - **S20** (step 3 — validated) — `ErrorController` parses all query params into explicit atoms/integers with fallback defaults. The `search` parameter reaches `Errors.list_errors/1` which uses `like/2` with Ecto parameterised bindings (`^"%#{escaped}%"` with LIKE escape). No SQL injection. - **S21** (step 3 — sanitised) — `process_double_bracket_links/1` extracts content from `[[...]]` brackets, `URI.encode_www_form/1` encodes it for URL query strings, then the full markdown text is processed through MDEx/ammonia. No HTML/URL injection path. - **S22** (step 3 — sanitised) — `build_html/3` in `RecordsOnThisDayEmail` uses `html_escape/1` (delegating to `Phoenix.HTML.html_escape/1`) on all user-derived values (record titles, artist names). The email body cannot inject HTML from record data. - **S23** (step 1 — truncated string) — Chat topic is `String.slice(content, 0, 80)` of user input, stored via Ecto changeset. It is displayed in templates where Phoenix auto-escapes HEEx expressions. No raw or unescaped path. - **S24** (step 3 — validated) — Every `fragment()` call across the codebase uses `^value` parameterised bindings for all user-supplied or externally-supplied strings. The `fts_escape/1` function in `records/search.ex` wraps search terms in double-quoted FTS5 phrase syntax with `""` escaping for literal double quotes, preventing FTS5 query injection. String concatenation (`||`) in `fragment()` calls (e.g., `"records_search_index MATCH 'genres : ' || ?"`) only combines static SQL fragments with `^`-bound values, never dynamic string interpolation. No SQL injection surface identified.