Files
music_library/backlog/docs/doc-20 - Deepseek-v4-pro-Security-Review.md
T
2026-05-13 19:29:31 +01:00

157 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 `<a href>` 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
- **S1S9** (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, "<redacted_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.
- **S16S17** (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.
- **S18S19** (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.