From 88d75afa3e1aadcb6edb9dbe75f95cfe657f9fb7 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Sun, 31 May 2026 16:52:53 +0300 Subject: [PATCH] Work around Discogs TLS chain issue --- lib/discogs/api.ex | 69 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/lib/discogs/api.ex b/lib/discogs/api.ex index d80463eb..c54978d2 100644 --- a/lib/discogs/api.ex +++ b/lib/discogs/api.ex @@ -8,6 +8,16 @@ defmodule Discogs.API do require Logger + # Discogs/Cloudflare currently sends an expired ISRG Root X2 cross-sign after + # the valid leaf/intermediate chain. Browser and curl stacks recover by + # building an alternate path to the locally trusted ISRG Root X2, but OTP's + # verifier rejects the presented chain. Keep TLS verification enabled and + # allow only that known expired cross-sign by fingerprint. + @expired_isrg_root_x2_cross_sign_sha256 Base.decode16!( + "8B05B68CC659E5ED0FCB38F2C942FBFD200E6F2FF9F85D63C6994EF5E0B02701", + case: :upper + ) + @spec get_artist(integer() | String.t(), Discogs.Config.t()) :: {:ok, map()} | {:error, ErrorResponse.t() | Exception.t()} def get_artist(id, config) do @@ -24,7 +34,7 @@ defmodule Discogs.API do {:ok, binary()} | {:error, :cover_not_available} def get_artist_image(url, config) do case Req.new(url: url, max_retries: 1, user_agent: config.user_agent) - |> Request.merge_options(config.req_options) + |> Request.merge_options(req_options(config)) |> Request.append_request_steps(log_attempt: &log_attempt/1) |> Request.append_response_steps(log_error: &log_error/1) |> get_request() do @@ -40,12 +50,67 @@ defmodule Discogs.API do user_agent: config.user_agent, auth: "Discogs token=#{config.personal_access_token}" ) - |> Request.merge_options(config.req_options) + |> Request.merge_options(req_options(config)) |> Req.RateLimiter.attach(name: :discogs, cooldown: config.api_cooldown) |> Request.append_request_steps(log_attempt: &log_attempt/1) |> Request.append_response_steps(parse_error: &parse_error/1) end + defp req_options(config) do + Keyword.update( + config.req_options, + :connect_options, + discogs_connect_options(), + fn connect_options -> + Keyword.update( + connect_options, + :transport_opts, + discogs_transport_options(), + fn transport_opts -> + Keyword.put_new(transport_opts, :verify_fun, {&verify_discogs_certificate/3, []}) + end + ) + end + ) + end + + defp discogs_connect_options do + [transport_opts: discogs_transport_options()] + end + + defp discogs_transport_options do + [verify_fun: {&verify_discogs_certificate/3, []}] + end + + defp verify_discogs_certificate(cert, {:bad_cert, :cert_expired} = reason, state) do + if expired_isrg_root_x2_cross_sign?(cert) do + {:valid, state} + else + {:fail, reason} + end + end + + defp verify_discogs_certificate(_cert, {:bad_cert, _reason} = reason, _state) do + {:fail, reason} + end + + defp verify_discogs_certificate(_cert, {:extension, _extension}, state) do + {:unknown, state} + end + + defp verify_discogs_certificate(_cert, :valid, state) do + {:valid, state} + end + + defp verify_discogs_certificate(_cert, :valid_peer, state) do + {:valid, state} + end + + defp expired_isrg_root_x2_cross_sign?(cert) do + der = :public_key.pkix_encode(:OTPCertificate, cert, :otp) + :crypto.hash(:sha256, der) == @expired_isrg_root_x2_cross_sign_sha256 + end + defp get_request(request) do case Req.get(request) do {:ok, %{status: status, body: body}} when status in 200..299 ->