From 8ffd842c386e4971ab4cadafd0d893ef40f0e808 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 20:48:16 +0100 Subject: [PATCH] Vereinfacht client: receipt allowlist, find_contact pagination, flatten nesting - Receipt attrs: allowlist only (no String.to_atom on API input / DoS) - find_contact_by_email: paginate through all pages (page[size]=100) - Extract helpers to satisfy Credo max nesting depth --- lib/mv/vereinfacht/client.ex | 61 ++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 2aafc7f..58e06a9 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -134,15 +134,25 @@ defmodule Mv.Vereinfacht.Client do end end + @find_contact_page_size 100 + @find_contact_max_pages 100 + defp do_find_contact_by_email(email) do - url = - base_url() - |> String.trim_trailing("/") - |> then(&"#{&1}/finance-contacts") + normalized = String.trim(email) |> String.downcase() + do_find_contact_by_email_page(1, normalized) + end + + defp do_find_contact_by_email_page(page, _normalized) when page > @find_contact_max_pages do + {:error, :not_found} + end + + defp do_find_contact_by_email_page(page, normalized) do + base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts") + url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}" case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do {:ok, %{status: 200, body: body}} when is_map(body) -> - parse_find_by_email_response(body, email) + handle_find_contact_page_response(body, page, normalized) {:ok, %{status: status, body: body}} -> {:error, {:http, status, extract_error_message(body)}} @@ -152,15 +162,21 @@ defmodule Mv.Vereinfacht.Client do end end - defp parse_find_by_email_response(body, email) do - normalized = String.trim(email) |> String.downcase() - + defp handle_find_contact_page_response(body, page, normalized) do case find_contact_id_by_email_in_list(body, normalized) do - nil -> {:error, :not_found} - id -> {:ok, id} + id when is_binary(id) -> {:ok, id} + nil -> maybe_find_contact_next_page(body, page, normalized) end end + defp maybe_find_contact_next_page(body, page, normalized) do + data = Map.get(body, "data") || [] + + if length(data) < @find_contact_page_size, + do: {:error, :not_found}, + else: do_find_contact_by_email_page(page + 1, normalized) + end + defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do Enum.find_value(list, fn %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}} @@ -249,31 +265,28 @@ defmodule Mv.Vereinfacht.Client do base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1) end + # Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS). + @receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a + defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do included |> Enum.filter(&match?(%{"type" => "receipts"}, &1)) |> Enum.map(fn %{"id" => id, "attributes" => attrs} = r -> - Map.merge(%{id: id, type: r["type"]}, string_keys_to_atoms(attrs || %{})) + Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(attrs || %{})) end) end defp extract_receipts_from_response(_), do: [] - defp string_keys_to_atoms(map) when is_map(map) do - Map.new(map, fn {k, v} -> {to_atom_key(k), v} end) + defp receipt_attrs_allowlist(attrs) when is_map(attrs) do + Map.new(@receipt_attr_allowlist, fn key -> + str_key = to_string(key) + {key, Map.get(attrs, str_key)} + end) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() end - defp to_atom_key(k) when is_binary(k) do - try do - String.to_existing_atom(k) - rescue - ArgumentError -> String.to_atom(k) - end - end - - defp to_atom_key(k) when is_atom(k), do: k - defp to_atom_key(k), do: to_atom_key(to_string(k)) - defp base_url, do: Mv.Config.vereinfacht_api_url() defp api_key, do: Mv.Config.vereinfacht_api_key() defp club_id, do: Mv.Config.vereinfacht_club_id()