VYPR
High severityNVD Advisory· Published Jan 30, 2024· Updated Aug 23, 2024

TrueLayer.Client SSRF when fetching payment or payment provider

CVE-2024-23838

Description

TrueLayer.NET is the .Net client for TrueLayer. The vulnerability could potentially allow a malicious actor to gain control over the destination URL of the HttpClient used in the API classes. For applications using the SDK, requests to unexpected resources on local networks or to the internet could be made which could lead to information disclosure. The issue can be mitigated by having strict egress rules limiting the destinations to which requests can be made, and applying strict validation to any user input passed to the truelayer-dotnet library. Versions of TrueLayer.Client v1.6.0 and later are not affected.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
TrueLayer.ClientNuGet
< 1.6.01.6.0

Affected products

1

Patches

1
75e436ed5360

Merge pull request from GHSA-67m4-qxp3-j6hh

https://github.com/TrueLayer/truelayer-dotnetMauro FranchiJan 22, 2024via ghsa
16 files changed · +343 75
  • src/TrueLayer/ApiClient.cs+11 6 modified
    @@ -9,6 +9,8 @@
     using System.Net.Mime;
     using TrueLayer.Serialization;
     using System.Text.Json;
    +using Microsoft.Extensions.Options;
    +using TrueLayer.Common;
     using TrueLayer.Signing;
     #if NET6_0 || NET6_0_OR_GREATER
     using System.Net.Http.Json;
    @@ -25,20 +27,23 @@ private static readonly String TlAgentHeader
                 = $"truelayer-dotnet/{ReflectionUtils.GetAssemblyVersion<ITrueLayerClient>()}";
     
             private readonly HttpClient _httpClient;
    +        private readonly TrueLayerOptions _options;
     
             /// <summary>
             /// Creates a new <see cref="ApiClient"/> instance with the provided configuration, HTTP client factory and serializer.
             /// </summary>
             /// <param name="httpClient">The client used to make HTTP requests.</param>
    -        public ApiClient(HttpClient httpClient)
    +        /// <param name="options"></param>
    +        public ApiClient(HttpClient httpClient, IOptions<TrueLayerOptions> options)
             {
                 _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    +            _options = options.Value ?? throw new ArgumentNullException(nameof(options));
             }
     
             /// <inheritdoc />
             public async Task<ApiResponse<TData>> GetAsync<TData>(Uri uri, string? accessToken = null, CancellationToken cancellationToken = default)
             {
    -            if (uri is null) throw new ArgumentNullException(nameof(uri));
    +            uri.HasValidBaseUri(nameof(uri), _options);
     
                 using var httpResponse = await SendRequestAsync(
                     httpMethod: HttpMethod.Get,
    @@ -56,7 +61,7 @@ public async Task<ApiResponse<TData>> GetAsync<TData>(Uri uri, string? accessTok
             /// <inheritdoc />
             public async Task<ApiResponse<TData>> PostAsync<TData>(Uri uri, HttpContent? httpContent = null, string? accessToken = null, CancellationToken cancellationToken = default)
             {
    -            if (uri is null) throw new ArgumentNullException(nameof(uri));
    +            uri.HasValidBaseUri(nameof(uri), _options);
     
                 using var httpResponse = await SendRequestAsync(
                     httpMethod: HttpMethod.Post,
    @@ -74,7 +79,7 @@ public async Task<ApiResponse<TData>> PostAsync<TData>(Uri uri, HttpContent? htt
             /// <inheritdoc />
             public async Task<ApiResponse<TData>> PostAsync<TData>(Uri uri, object? request = null, string? idempotencyKey = null, string? accessToken = null, SigningKey? signingKey = null, CancellationToken cancellationToken = default)
             {
    -            if (uri is null) throw new ArgumentNullException(nameof(uri));
    +            uri.HasValidBaseUri(nameof(uri), _options);
     
                 using var httpResponse = await SendJsonRequestAsync(
                     httpMethod: HttpMethod.Post,
    @@ -91,7 +96,7 @@ public async Task<ApiResponse<TData>> PostAsync<TData>(Uri uri, object? request
     
             public async Task<ApiResponse> PostAsync(Uri uri, HttpContent? httpContent = null, string? accessToken = null, CancellationToken cancellationToken = default)
             {
    -            if (uri is null) throw new ArgumentNullException(nameof(uri));
    +            uri.HasValidBaseUri(nameof(uri), _options);
     
                 using var httpResponse = await SendRequestAsync(
                     httpMethod: HttpMethod.Post,
    @@ -108,7 +113,7 @@ public async Task<ApiResponse> PostAsync(Uri uri, HttpContent? httpContent = nul
     
             public async Task<ApiResponse> PostAsync(Uri uri, object? request = null, string? idempotencyKey = null, string? accessToken = null, SigningKey? signingKey = null, CancellationToken cancellationToken = default)
             {
    -            if (uri is null) throw new ArgumentNullException(nameof(uri));
    +            uri.HasValidBaseUri(nameof(uri), _options);
     
                 using var httpResponse = await SendJsonRequestAsync(
                     httpMethod: HttpMethod.Post,
    
  • src/TrueLayer/Auth/AuthApi.cs+8 6 modified
    @@ -3,14 +3,13 @@
     using System.Net.Http;
     using System.Threading;
     using System.Threading.Tasks;
    +using TrueLayer.Common;
    +using TrueLayer.Extensions;
     
     namespace TrueLayer.Auth
     {
         internal class AuthApi : IAuthApi
         {
    -        internal const string ProdUrl = "https://auth.truelayer.com/";
    -        internal const string SandboxUrl = "https://auth.truelayer-sandbox.com/";
    -
             private readonly IApiClient _apiClient;
             private readonly TrueLayerOptions _options;
             private readonly Uri _baseUri;
    @@ -20,8 +19,11 @@ public AuthApi(IApiClient apiClient, TrueLayerOptions options)
                 _apiClient = apiClient.NotNull(nameof(apiClient));
                 _options = options.NotNull(nameof(options));
     
    -            _baseUri = options.Auth?.Uri ??
    -                      new Uri((options.UseSandbox ?? true) ? SandboxUrl : ProdUrl);
    +            var baseUri = (options.UseSandbox ?? true)
    +                ? TrueLayerBaseUris.SandboxAuthBaseUri
    +                : TrueLayerBaseUris.ProdAuthBaseUri;
    +
    +            _baseUri = options.Auth?.Uri ?? baseUri;
             }
     
             /// <inheritdoc />
    @@ -42,7 +44,7 @@ public async ValueTask<ApiResponse<GetAuthTokenResponse>> GetAuthToken(GetAuthTo
                 }
     
                 return await _apiClient.PostAsync<GetAuthTokenResponse>(
    -                new Uri(_baseUri, "connect/token"), new FormUrlEncodedContent(values), null, cancellationToken);
    +                _baseUri.Append("connect/token"), new FormUrlEncodedContent(values), null, cancellationToken);
             }
         }
     }
    
  • src/TrueLayer/Common/TrueLayerBaseUris.cs+13 0 added
    @@ -0,0 +1,13 @@
    +using System;
    +
    +namespace TrueLayer.Common;
    +
    +internal static class TrueLayerBaseUris
    +{
    +    internal static readonly Uri ProdApiBaseUri = new("https://api.truelayer.com/");
    +    internal static readonly Uri SandboxApiBaseUri = new("https://api.truelayer-sandbox.com/");
    +    internal static readonly Uri ProdAuthBaseUri = new("https://auth.truelayer.com/");
    +    internal static readonly Uri SandboxAuthBaseUri = new("https://auth.truelayer-sandbox.com/");
    +    internal static readonly Uri ProdHppBaseUri = new("https://payment.truelayer.com/");
    +    internal static readonly Uri SandboxHppBaseUri = new("https://payment.truelayer-sandbox.com/");
    +}
    
  • src/TrueLayer/Extensions/UriExtensions.cs+3 2 modified
    @@ -1,5 +1,6 @@
     using System;
     using System.Linq;
    +using System.Text;
     using System.Text.Json;
     using TrueLayer.Serialization;
     
    @@ -9,8 +10,8 @@ public static class UriExtensions
         {
             public static Uri Append(this Uri uri, params string[] segments)
             {
    -            string newUri = string.Join("/", new[] { uri.AbsoluteUri.TrimEnd('/') }
    -                .Concat(segments.Select(s => s.Trim('/'))));
    +            string newUri = string.Join("/", new[] { uri.AbsoluteUri.TrimEnd('/').Replace("\\", string.Empty) }
    +                .Concat(segments.Select(s => s.Replace("\\", string.Empty).Trim('/'))));
                 return new Uri(newUri);
             }
     
    
  • src/TrueLayer/Guard.cs+88 0 modified
    @@ -1,6 +1,7 @@
     using System;
     using System.Diagnostics;
     using System.Diagnostics.CodeAnalysis;
    +using TrueLayer.Common;
     
     namespace TrueLayer
     {
    @@ -87,5 +88,92 @@ public static T GreaterThan<T>([NotNull] this T value, T greaterThan, string par
     
                 return value;
             }
    +
    +        /// <summary>
    +        /// Validates that the provided <paramref name="value"/> is not an URL
    +        /// </summary>
    +        /// <param name="value">The value to validate</param>
    +        /// <param name="name">The name of the argument</param>
    +        /// <returns>The value of <paramref name="value"/> if it is not an URL</returns>
    +        /// <exception cref="ArgumentException">Thrown when the value is an URL</exception>
    +        /// <example>
    +        /// <code>
    +        /// _id = id.NotAUrl(nameof(id));
    +        /// </code>
    +        /// </example>
    +        [DebuggerStepThrough]
    +        public static string? NotAUrl(this string? value, string name)
    +            => value is not null
    +               && (value.Contains(' ')
    +                || Uri.IsWellFormedUriString(value, UriKind.Absolute)
    +                || value.StartsWith('\\')
    +                || value.Contains('/')
    +                || value.Contains('.'))
    +                ? throw new ArgumentException("Value is malformed", name)
    +                : value;
    +
    +        /// <summary>
    +        /// Validate that the provided URI one of the configured (from the options) URIs as base address, or one of the TrueLayer ones based on the environment used.
    +        /// </summary>
    +        /// <param name="value">The value to validate</param>
    +        /// <param name="name">The name of the argument</param>
    +        /// <param name="options">The <see cref="TrueLayerOptions"/> that contain the custom configured URIs</param>
    +        /// <returns>The value of <paramref name="value"/> if it is valid</returns>
    +        /// <exception cref="ArgumentException">Thrown when the value is not valid</exception>
    +        /// <example>
    +        /// <code>
    +        /// _uri = uri.HasValidBaseUri(nameof(_uri), options);
    +        /// </code>
    +        /// </example>
    +        internal static Uri? HasValidBaseUri(this Uri? value, string name, TrueLayerOptions options)
    +        {
    +            value.NotNull(name);
    +            const string errorMsg = "The URI must be a valid TrueLayer API URI one of those configured in the settings.";
    +            bool result = value.IsLoopback // is localhost?
    +                          || ((options.Payments?.Uri is not null) && options.Payments!.Uri.IsBaseOf(value))
    +                          || ((options.Auth?.Uri is not null) && options.Auth!.Uri.IsBaseOf(value))
    +                          || ((options.Payments?.HppUri is not null) && options.Payments!.HppUri.IsBaseOf(value));
    +
    +            if (options.UseSandbox == true)
    +            {
    +                result = result
    +                         || TrueLayerBaseUris.SandboxAuthBaseUri.IsBaseOf(value)
    +                         || TrueLayerBaseUris.SandboxApiBaseUri.IsBaseOf(value)
    +                         || TrueLayerBaseUris.SandboxHppBaseUri.IsBaseOf(value);
    +            }
    +            else
    +            {
    +                result = result
    +                         || TrueLayerBaseUris.ProdAuthBaseUri.IsBaseOf(value)
    +                         || TrueLayerBaseUris.ProdApiBaseUri.IsBaseOf(value)
    +                         || TrueLayerBaseUris.ProdHppBaseUri.IsBaseOf(value);
    +            }
    +
    +            result.ThrowIfFalse(name, errorMsg);
    +            return value;
    +        }
    +
    +        /// <summary>
    +        /// Validate that the provided value is not false
    +        /// </summary>
    +        /// <param name="value">The value to validate</param>
    +        /// <param name="name">The name of the argument</param>
    +        /// <param name="message">The message that needs to be assigned to the exception</param>
    +        /// <returns>The value of <paramref name="value"/> if not false</returns>
    +        /// <exception cref="ArgumentException">Thrown when the value is false</exception>
    +        /// <example>
    +        /// <code>
    +        /// _value = value.ThrowIfFalse(nameof(_value), "The value cannot be false");
    +        /// </code>
    +        /// </example>
    +        private static bool ThrowIfFalse(this bool value, string name, string message)
    +        {
    +            if (!value)
    +            {
    +                throw new ArgumentException(message, name);
    +            }
    +
    +            return value;
    +        }
         }
     }
    
  • src/TrueLayer/Mandates/IMandatesApi.cs+2 2 modified
    @@ -122,13 +122,13 @@ Task<ApiResponse<GetConstraintsResponse>> GetMandateConstraints(
             /// <summary>
             /// Revoke mandate
             /// </summary>
    -        /// <param name="id">The id of the mandate</param>
    +        /// <param name="mandateId">The id of the mandate</param>
             /// <param name="idempotencyKey">
             /// An idempotency key to allow safe retrying without the operation being performed multiple times.
             /// The value should be unique for each operation, e.g. a UUID, with the same key being sent on a retry of the same request.
             /// </param>
             /// <param name="cancellationToken">The cancellation token to cancel the operation</param>
             /// <returns>An API response that includes the payment details if successful, otherwise problem details</returns>
    -        Task<ApiResponse> RevokeMandate(string id, string idempotencyKey, MandateType mandateType, CancellationToken cancellationToken = default);
    +        Task<ApiResponse> RevokeMandate(string mandateId, string idempotencyKey, MandateType mandateType, CancellationToken cancellationToken = default);
         }
     }
    
  • src/TrueLayer/Mandates/MandatesApi.cs+35 26 modified
    @@ -3,26 +3,25 @@
     using System.Threading.Tasks;
     using TrueLayer.Auth;
     using OneOf;
    +using TrueLayer.Common;
    +using TrueLayer.Extensions;
    +using TrueLayer.Mandates.Model;
    +using TrueLayer.Models;
     
     namespace TrueLayer.Mandates
     {
    -    using TrueLayer.Mandates.Model;
    -    using TrueLayer.Models;
         using AuthorizationResponseUnion = OneOf<
    -        Models.AuthorisationFlowResponse.AuthorizationFlowAuthorizing,
    -        Models.AuthorisationFlowResponse.AuthorizationFlowAuthorizationFailed>;
    +        AuthorisationFlowResponse.AuthorizationFlowAuthorizing,
    +        AuthorisationFlowResponse.AuthorizationFlowAuthorizationFailed>;
         using MandateDetailUnion = OneOf<
    -        Model.MandateDetail.AuthorizationRequiredMandateDetail,
    -        Model.MandateDetail.AuthorizingMandateDetail,
    -        Model.MandateDetail.AuthorizedMandateDetail,
    -        Model.MandateDetail.FailedMandateDetail,
    -        Model.MandateDetail.RevokedMandateDetail>;
    +        MandateDetail.AuthorizationRequiredMandateDetail,
    +        MandateDetail.AuthorizingMandateDetail,
    +        MandateDetail.AuthorizedMandateDetail,
    +        MandateDetail.FailedMandateDetail,
    +        MandateDetail.RevokedMandateDetail>;
     
         internal class MandatesApi : IMandatesApi
         {
    -        private const string ProdUrl = "https://api.truelayer.com/v3/mandates/";
    -        private const string SandboxUrl = "https://api.truelayer-sandbox.com/v3/mandates/";
    -
             private readonly IApiClient _apiClient;
             private readonly TrueLayerOptions _options;
             private readonly Uri _baseUri;
    @@ -36,9 +35,12 @@ public MandatesApi(IApiClient apiClient, IAuthApi auth, TrueLayerOptions options
     
                 options.Payments.NotNull(nameof(options.Payments))!.Validate();
     
    -            _baseUri = options.Payments.Uri is not null
    -                ? new Uri(options.Payments.Uri, "/v3/mandates/")
    -                : new Uri((options.UseSandbox ?? true) ? SandboxUrl : ProdUrl);
    +            var baseUri = (options.UseSandbox ?? true)
    +                ? TrueLayerBaseUris.SandboxApiBaseUri
    +                : TrueLayerBaseUris.ProdApiBaseUri;
    +
    +            _baseUri = (options.Payments.Uri ?? baseUri)
    +                .Append("/v3/mandates/");
             }
     
             /// <inheritdoc />
    @@ -68,6 +70,7 @@ public async Task<ApiResponse<CreateMandateResponse>> CreateMandate(CreateMandat
             public async Task<ApiResponse<MandateDetailUnion>> GetMandate(string mandateId, MandateType mandateType, CancellationToken cancellationToken = default)
             {
                 mandateId.NotNullOrWhiteSpace(nameof(mandateId));
    +            mandateId.NotAUrl(nameof(mandateId));
     
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest($"recurring_payments:{mandateType.AsString()}"), cancellationToken);
     
    @@ -77,7 +80,7 @@ public async Task<ApiResponse<MandateDetailUnion>> GetMandate(string mandateId,
                 }
     
                 return await _apiClient.GetAsync<MandateDetailUnion>(
    -                new Uri(_baseUri, mandateId),
    +                _baseUri.Append(mandateId),
                     authResponse.Data!.AccessToken,
                     cancellationToken
                 );
    @@ -94,8 +97,8 @@ public async Task<ApiResponse<ResourceCollection<MandateDetailUnion>>> ListManda
                 }
     
                 var queryParameters = System.Web.HttpUtility.ParseQueryString(string.Empty);
    -            queryParameters["user_id"] = query.UserId;
    -            queryParameters["cursor"] = query.Cursor;
    +            queryParameters["user_id"] = query.UserId.NotAUrl($"{nameof(query)}.{nameof(query.UserId)}");
    +            queryParameters["cursor"] = query.Cursor.NotAUrl($"{nameof(query)}.{nameof(query.UserId)}");
                 queryParameters["limit"] = query.Limit.ToString();
                 var baseUriBuilder = new UriBuilder(_baseUri) { Query = queryParameters.ToString() };
     
    @@ -110,6 +113,7 @@ public async Task<ApiResponse<ResourceCollection<MandateDetailUnion>>> ListManda
             public async Task<ApiResponse<AuthorizationResponseUnion>> StartAuthorizationFlow(string mandateId, StartAuthorizationFlowRequest request, string idempotencyKey, MandateType mandateType, CancellationToken cancellationToken = default)
             {
                 mandateId.NotNullOrWhiteSpace(nameof(mandateId));
    +            mandateId.NotAUrl(nameof(mandateId));
                 request.NotNull(nameof(request));
                 idempotencyKey.NotNullOrWhiteSpace(nameof(idempotencyKey));
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest($"recurring_payments:{mandateType.AsString()}"), cancellationToken);
    @@ -120,7 +124,7 @@ public async Task<ApiResponse<AuthorizationResponseUnion>> StartAuthorizationFlo
                 }
     
                 return await _apiClient.PostAsync<AuthorizationResponseUnion>(
    -                new Uri(_baseUri, $"/v3/mandates/{mandateId}/authorization-flow"),
    +                _baseUri.Append($"{mandateId}/authorization-flow"),
                     request,
                     idempotencyKey,
                     authResponse.Data!.AccessToken,
    @@ -133,6 +137,7 @@ public async Task<ApiResponse<AuthorizationResponseUnion>> StartAuthorizationFlo
             public async Task<ApiResponse<AuthorizationResponseUnion>> SubmitProviderSelection(string mandateId, SubmitProviderSelectionRequest request, string idempotencyKey, MandateType mandateType, CancellationToken cancellationToken = default)
             {
                 mandateId.NotNullOrWhiteSpace(nameof(mandateId));
    +            mandateId.NotAUrl(nameof(mandateId));
                 request.NotNull(nameof(request));
                 idempotencyKey.NotNullOrWhiteSpace(nameof(idempotencyKey));
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest($"recurring_payments:{mandateType.AsString()}"), cancellationToken);
    @@ -143,7 +148,7 @@ public async Task<ApiResponse<AuthorizationResponseUnion>> SubmitProviderSelecti
                 }
     
                 return await _apiClient.PostAsync<AuthorizationResponseUnion>(
    -                new Uri(_baseUri, $"/v3/mandates/{mandateId}/authorization-flow/actions/provider-selection"),
    +                _baseUri.Append($"{mandateId}/authorization-flow/actions/provider-selection"),
                     request,
                     idempotencyKey,
                     authResponse.Data!.AccessToken,
    @@ -155,6 +160,7 @@ public async Task<ApiResponse<AuthorizationResponseUnion>> SubmitProviderSelecti
             public async Task<ApiResponse<AuthorizationResponseUnion>> SubmitConsent(string mandateId, string idempotencyKey, MandateType mandateType, CancellationToken cancellationToken = default)
             {
                 mandateId.NotNullOrWhiteSpace(nameof(mandateId));
    +            mandateId.NotAUrl(nameof(mandateId));
                 idempotencyKey.NotNullOrWhiteSpace(nameof(idempotencyKey));
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest($"recurring_payments:{mandateType.AsString()}"), cancellationToken);
     
    @@ -164,7 +170,7 @@ public async Task<ApiResponse<AuthorizationResponseUnion>> SubmitConsent(string
                 }
     
                 return await _apiClient.PostAsync<AuthorizationResponseUnion>(
    -                new Uri(_baseUri, $"/v3/mandates/{mandateId}/authorization-flow/actions/consent"),
    +                _baseUri.Append($"{mandateId}/authorization-flow/actions/consent"),
                     null,
                     idempotencyKey,
                     authResponse.Data!.AccessToken,
    @@ -177,6 +183,7 @@ public async Task<ApiResponse<AuthorizationResponseUnion>> SubmitConsent(string
             public async Task<ApiResponse<GetConfirmationOfFundsResponse>> GetConfirmationOfFunds(string mandateId, int amountInMinor, string currency, MandateType mandateType, CancellationToken cancellationToken = default)
             {
                 mandateId.NotNullOrWhiteSpace(nameof(mandateId));
    +            mandateId.NotAUrl(nameof(mandateId));
     
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest($"recurring_payments:{mandateType.AsString()}"), cancellationToken);
     
    @@ -186,7 +193,7 @@ public async Task<ApiResponse<GetConfirmationOfFundsResponse>> GetConfirmationOf
                 }
     
                 return await _apiClient.GetAsync<GetConfirmationOfFundsResponse>(
    -                new Uri(_baseUri, $"/v3/mandates/{mandateId}/funds?amount_in_minor={amountInMinor}&currency={currency}"),
    +                _baseUri.Append($"{mandateId}/funds?amount_in_minor={amountInMinor}&currency={currency}"),
                     authResponse.Data!.AccessToken,
                     cancellationToken
                 );
    @@ -196,6 +203,7 @@ public async Task<ApiResponse<GetConfirmationOfFundsResponse>> GetConfirmationOf
             public async Task<ApiResponse<GetConstraintsResponse>> GetMandateConstraints(string mandateId, MandateType mandateType, CancellationToken cancellationToken = default)
             {
                 mandateId.NotNullOrWhiteSpace(nameof(mandateId));
    +            mandateId.NotAUrl(nameof(mandateId));
     
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest($"recurring_payments:{mandateType.AsString()}"), cancellationToken);
     
    @@ -205,16 +213,17 @@ public async Task<ApiResponse<GetConstraintsResponse>> GetMandateConstraints(str
                 }
     
                 return await _apiClient.GetAsync<GetConstraintsResponse>(
    -                new Uri(_baseUri, $"/v3/mandates/{mandateId}/constraints"),
    +                _baseUri.Append($"{mandateId}/constraints"),
                     authResponse.Data!.AccessToken,
                     cancellationToken
                 );
             }
     
             /// <inheritdoc />
    -        public async Task<ApiResponse> RevokeMandate(string id, string idempotencyKey, MandateType mandateType, CancellationToken cancellationToken = default)
    +        public async Task<ApiResponse> RevokeMandate(string mandateId, string idempotencyKey, MandateType mandateType, CancellationToken cancellationToken = default)
             {
    -            id.NotNullOrWhiteSpace(nameof(id));
    +            mandateId.NotNullOrWhiteSpace(nameof(mandateId));
    +            mandateId.NotAUrl(nameof(mandateId));
     
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest($"recurring_payments:{mandateType.AsString()}"), cancellationToken);
     
    @@ -224,7 +233,7 @@ public async Task<ApiResponse> RevokeMandate(string id, string idempotencyKey, M
                 }
     
                 return await _apiClient.PostAsync(
    -                new Uri(_baseUri, $"/v3/mandates/{id}/revoke"),
    +                _baseUri.Append($"{mandateId}/revoke"),
                     null,
                     idempotencyKey,
                     authResponse.Data!.AccessToken,
    
  • src/TrueLayer/MerchantAccounts/MerchantAccountsApi.cs+12 6 modified
    @@ -2,14 +2,14 @@
     using System.Threading;
     using System.Threading.Tasks;
     using TrueLayer.Auth;
    +using TrueLayer.Common;
    +using TrueLayer.Extensions;
     using TrueLayer.MerchantAccounts.Model;
     
     namespace TrueLayer.MerchantAccounts
     {
         internal class MerchantAccountsApi : IMerchantAccountsApi
         {
    -        private const string ProdUrl = "https://api.truelayer.com/v3/merchant-accounts";
    -        private const string SandboxUrl = "https://api.truelayer-sandbox.com/v3/merchant-accounts";
             private readonly IApiClient _apiClient;
             private readonly Uri _baseUri;
             private readonly IAuthApi _auth;
    @@ -21,9 +21,12 @@ public MerchantAccountsApi(IApiClient apiClient, IAuthApi auth, TrueLayerOptions
     
                 options.Payments.NotNull(nameof(options.Payments))!.Validate();
     
    -            _baseUri = options.Payments.Uri is not null
    -                ? new Uri(options.Payments.Uri, "/v3/merchant-accounts")
    -                : new Uri(options.UseSandbox ?? true ? SandboxUrl : ProdUrl);
    +            var baseUri = (options.UseSandbox ?? true)
    +                ? TrueLayerBaseUris.SandboxApiBaseUri
    +                : TrueLayerBaseUris.ProdApiBaseUri;
    +
    +            _baseUri = (options.Payments.Uri ?? baseUri)
    +                .Append("/v3/merchant-accounts");
             }
     
             /// <inheritdoc />
    @@ -47,6 +50,7 @@ public async Task<ApiResponse<ResourceCollection<MerchantAccount>>> ListMerchant
             public async Task<ApiResponse<MerchantAccount>> GetMerchantAccount(string id, CancellationToken cancellationToken = default)
             {
                 id.NotNullOrWhiteSpace(nameof(id));
    +            id.NotAUrl(nameof(id));
     
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest("payments"), cancellationToken);
     
    @@ -67,7 +71,9 @@ public async Task<ApiResponse<MerchantAccount>> GetMerchantAccount(string id, Ca
             public async Task<ApiResponse<GetPaymentSourcesResponse>> GetPaymentSources(string merchantAccountId, string userId, CancellationToken cancellationToken = default)
             {
                 merchantAccountId.NotNullOrWhiteSpace(nameof(merchantAccountId));
    +            merchantAccountId.NotAUrl(nameof(merchantAccountId));
                 userId.NotNullOrWhiteSpace(nameof(userId));
    +            userId.NotAUrl(nameof(userId));
     
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest("payments"), cancellationToken);
     
    @@ -77,7 +83,7 @@ public async Task<ApiResponse<GetPaymentSourcesResponse>> GetPaymentSources(stri
                 }
     
                 return await _apiClient.GetAsync<GetPaymentSourcesResponse>(
    -                new Uri(_baseUri, $"merchant-accounts/{merchantAccountId}/payment-sources?user_id={userId}"),
    +                _baseUri.Append($"merchant-accounts/{merchantAccountId}/payment-sources?user_id={userId}"),
                     authResponse.Data!.AccessToken,
                     cancellationToken
                 );
    
  • src/TrueLayer/Payments/PaymentsApi.cs+10 8 modified
    @@ -3,6 +3,8 @@
     using System.Threading.Tasks;
     using OneOf;
     using TrueLayer.Auth;
    +using TrueLayer.Common;
    +using TrueLayer.Extensions;
     using TrueLayer.Payments.Model;
     
     namespace TrueLayer.Payments
    @@ -24,10 +26,6 @@ namespace TrueLayer.Payments
     
         internal class PaymentsApi : IPaymentsApi
         {
    -        private const string ProdUrl = "https://api.truelayer.com/v3/payments/";
    -        private const string SandboxUrl = "https://api.truelayer-sandbox.com/v3/payments/";
    -        internal static string[] RequiredScopes = new[] { "payments" };
    -
             private readonly IApiClient _apiClient;
             private readonly TrueLayerOptions _options;
             private readonly Uri _baseUri;
    @@ -43,9 +41,12 @@ public PaymentsApi(IApiClient apiClient, IAuthApi auth, TrueLayerOptions options
     
                 options.Payments.NotNull(nameof(options.Payments))!.Validate();
     
    -            _baseUri = options.Payments.Uri is not null
    -                ? new Uri(options.Payments.Uri, "/v3/payments/")
    -                : new Uri((options.UseSandbox ?? true) ? SandboxUrl : ProdUrl);
    +            var baseUri = (options.UseSandbox ?? true)
    +                ? TrueLayerBaseUris.SandboxApiBaseUri
    +                : TrueLayerBaseUris.ProdApiBaseUri;
    +
    +            _baseUri = (options.Payments.Uri ?? baseUri)
    +                .Append("/v3/payments/");
             }
     
             /// <inheritdoc />
    @@ -76,6 +77,7 @@ public async Task<ApiResponse<CreatePaymentUnion>> CreatePayment(CreatePaymentRe
             public async Task<ApiResponse<GetPaymentUnion>> GetPayment(string id, CancellationToken cancellationToken = default)
             {
                 id.NotNullOrWhiteSpace(nameof(id));
    +            id.NotAUrl(nameof(id));
     
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest("payments"), cancellationToken);
     
    @@ -85,7 +87,7 @@ public async Task<ApiResponse<GetPaymentUnion>> GetPayment(string id, Cancellati
                 }
     
                 return await _apiClient.GetAsync<GetPaymentUnion>(
    -                new Uri(_baseUri, id),
    +                _baseUri.Append(id),
                     authResponse.Data!.AccessToken,
                     cancellationToken
                 );
    
  • src/TrueLayer/PaymentsProviders/PaymentsApi.cs+10 7 modified
    @@ -1,14 +1,13 @@
     using System;
     using System.Threading.Tasks;
    +using TrueLayer.Common;
    +using TrueLayer.Extensions;
     using TrueLayer.PaymentsProviders.Model;
     
     namespace TrueLayer.PaymentsProviders
     {
         internal class PaymentsProvidersApi : IPaymentsProvidersApi
         {
    -        private const string ProdUrl = "https://api.truelayer.com/v3/payments-providers/";
    -        private const string SandboxUrl = "https://api.truelayer-sandbox.com/v3/payments-providers/";
    -
             private readonly IApiClient _apiClient;
             private readonly TrueLayerOptions _options;
             private readonly Uri _baseUri;
    @@ -20,16 +19,20 @@ public PaymentsProvidersApi(IApiClient apiClient, TrueLayerOptions options)
     
                 options.Payments.NotNull(nameof(options.Payments))!.Validate();
     
    -            _baseUri = options.Payments.Uri is not null
    -                ? new Uri(options.Payments.Uri, "/v3/payments-providers/")
    -                : new Uri((options.UseSandbox ?? true) ? SandboxUrl : ProdUrl);
    +            var baseUri = (options.UseSandbox ?? true)
    +                ? TrueLayerBaseUris.SandboxApiBaseUri
    +                : TrueLayerBaseUris.ProdApiBaseUri;
    +
    +            _baseUri = (options.Payments.Uri ?? baseUri)
    +                .Append("/v3/payments-providers/");
             }
     
             public async Task<ApiResponse<PaymentsProvider>> GetPaymentsProvider(string id)
             {
                 id.NotNullOrWhiteSpace(nameof(id));
    +            id.NotAUrl(nameof(id));
     
    -            UriBuilder baseUri = new(new Uri(_baseUri, id)) { Query = $"client_id={_options.ClientId}" };
    +            UriBuilder baseUri = new(_baseUri.Append(id)) { Query = $"client_id={_options.ClientId}" };
     
                 return await _apiClient.GetAsync<PaymentsProvider>(baseUri.Uri);
             }
    
  • src/TrueLayer/Payouts/PayoutsApi.cs+8 7 modified
    @@ -3,6 +3,7 @@
     using System.Threading.Tasks;
     using OneOf;
     using TrueLayer.Auth;
    +using TrueLayer.Common;
     using TrueLayer.Extensions;
     using TrueLayer.Payouts.Model;
     using static TrueLayer.Payouts.Model.GetPayoutsResponse;
    @@ -18,9 +19,6 @@ namespace TrueLayer.Payouts
     
         internal class PayoutsApi : IPayoutsApi
         {
    -        private const string ProdUrl = "https://api.truelayer.com/v3/payouts";
    -        private const string SandboxUrl = "https://api.truelayer-sandbox.com/v3/payouts";
    -
             private readonly IApiClient _apiClient;
             private readonly TrueLayerOptions _options;
             private readonly Uri _baseUri;
    @@ -34,10 +32,12 @@ public PayoutsApi(IApiClient apiClient, IAuthApi auth, TrueLayerOptions options)
     
                 options.Payments.NotNull(nameof(options.Payments))!.Validate();
     
    -            string payoutsApiUrl = (options.UseSandbox ?? true) ? SandboxUrl : ProdUrl;
    -            _baseUri = options.Payments.Uri is not null
    -                ? new Uri(options.Payments.Uri, "/v3/payouts")
    -                : new Uri(payoutsApiUrl);
    +            var baseUri = (options.UseSandbox ?? true)
    +                ? TrueLayerBaseUris.SandboxApiBaseUri
    +                : TrueLayerBaseUris.ProdApiBaseUri;
    +
    +            _baseUri = (options.Payments.Uri ?? baseUri)
    +                .Append("/v3/payouts/");
             }
     
             /// <inheritdoc />
    @@ -66,6 +66,7 @@ public async Task<ApiResponse<CreatePayoutResponse>> CreatePayout(CreatePayoutRe
             public async Task<ApiResponse<GetPayoutUnion>> GetPayout(string id, CancellationToken cancellationToken = default)
             {
                 id.NotNullOrWhiteSpace(nameof(id));
    +            id.NotAUrl(nameof(id));
     
                 ApiResponse<GetAuthTokenResponse> authResponse = await _auth.GetAuthToken(new GetAuthTokenRequest("payments"), cancellationToken);
     
    
  • src/TrueLayer/TrueLayerServiceCollectionExtensions.cs+1 1 modified
    @@ -39,7 +39,7 @@ public static IServiceCollection AddTrueLayer(
                 configureBuilder?.Invoke(httpClientBuilder);
     
                 services.AddTransient<ITrueLayerClient, TrueLayerClient>();
    -            
    +
                 return services;
             }
         }
    
  • test/TrueLayer.Tests/ApiClientTests.cs+3 3 modified
    @@ -10,6 +10,7 @@
     using TrueLayer.Serialization;
     using System.Text;
     using System.Text.Json;
    +using Microsoft.Extensions.Options;
     
     namespace TrueLayer.Sdk.Tests
     {
    @@ -31,8 +32,7 @@ public ApiClientTests()
                 _httpMessageHandler = new MockHttpMessageHandler();
     
                 _apiClient = new ApiClient(
    -                _httpMessageHandler.ToHttpClient()
    -            );
    +                _httpMessageHandler.ToHttpClient(), Options.Create(new TrueLayerOptions()));
     
                 _stub = new TestResponse
                 {
    @@ -241,7 +241,7 @@ public async Task Generates_request_signature_when_signing_key_and_body_provided
                 {
                     key = "value"
                 };
    -            
    +
                 var signingKey = new SigningKey { KeyId = Guid.NewGuid().ToString(), PrivateKey = _privateKey };
     
                 var requestUri = new Uri("http://localhost/signing");
    
  • test/TrueLayer.Tests/Extensions/UriExtensionsTests.cs+7 0 modified
    @@ -28,13 +28,20 @@ public static IEnumerable<object[]> UriTestData()
                 Uri baseUri = new(baseUrl);
     
                 yield return new object[] { baseUri, new[] {"test"}, new Uri($"{baseUrl}test") };
    +            yield return new object[] { baseUri, new[] {"/test"}, new Uri($"{baseUrl}test") };
                 yield return new object[] { baseUri, new[] {"test", "/test2/"}, new Uri($"{baseUrl}test/test2") };
                 yield return new object[]
                 {
                     new Uri("http://test.foo.test/extra-path"),
                     new[] { "test/" },
                     new Uri("http://test.foo.test/extra-path/test"),
                 };
    +            yield return new object[]
    +            {
    +                new Uri("http://test.foo.test"),
    +                new[] { "/test" },
    +                new Uri("http://test.foo.test/test"),
    +            };
             }
         }
     }
    
  • test/TrueLayer.Tests/GuardTests.cs+131 0 modified
    @@ -39,5 +39,136 @@ public void Greater_than_throws_if_less_or_equal_to_value(int value)
             [Fact]
             public void Greater_than_does_not_throw_if_greater_than_value()
                 => _ = 10.GreaterThan(5, "value");
    +
    +        [Theory]
    +        [InlineData(null)]
    +        [InlineData("not_a_url")]
    +        [InlineData("anotherNonUrl")]
    +        [InlineData("7effef4a-17f2-4139-aee2-fae13544530a")]
    +        [InlineData("85BF9448-A93F-4F5F-A325-8B5BA7845F83")]
    +        [InlineData("{C5A41B28-109A-41C5-8CFD-695CC52A7539}")]
    +        [InlineData("12345")]
    +        public void NotAUrl_WithNullOrNonUrlValue_ReturnsSameValue(string? value)
    +        {
    +            Assert.Equal(value, value.NotAUrl("value"));
    +        }
    +
    +        [Theory]
    +        [InlineData("http://example.com")]
    +        [InlineData("https://example.com")]
    +        [InlineData("/relative/url")]
    +        [InlineData("http://example.com?query=string")]
    +        [InlineData("http://example.com?query=string&otherquery=foo")]
    +        [InlineData("http://example.com/path%20with%20spaces")]
    +        [InlineData("string with spaces")]
    +        [InlineData("A7+uG3zwvUiKtrwb/ZtQow==")]
    +        [InlineData("\\/g8ph66mx5ltptbsdfwmr6kut2k8bw8kx.oastify.com")]
    +        [InlineData("fake.test.com")]
    +        [InlineData("fake.com")]
    +        [InlineData("fake.com/")]
    +        public void NotAUrl_WithUrlValue_ThrowsArgumentException(string value)
    +            => Assert.Throws<ArgumentException>(() => value.NotAUrl("value"));
    +
    +        [Theory]
    +        // null URI
    +        [InlineData(null, null, null, null, true, typeof(ArgumentNullException))]
    +        [InlineData(null, null, null, null, false, typeof(ArgumentNullException))]
    +        [InlineData(null, "http://foo.com", "http://foo.com", "http://foo.com", true, typeof(ArgumentNullException))]
    +        [InlineData(null, "http://foo.com", "http://foo.com", "http://foo.com", false, typeof(ArgumentNullException))]
    +        // URI not based on TL URIs
    +        [InlineData("http://www.foo.com", null, null, null, true, typeof(ArgumentException))]
    +        [InlineData("http://www.foo.com", null, null, null, false, typeof(ArgumentException))]
    +        [InlineData("https://www.foo.com/path/", null, null, null, true, typeof(ArgumentException))]
    +        [InlineData("https://www.foo.com/path", null, null, null, false, typeof(ArgumentException))]
    +        // URI not based on custom configured Payments URIs
    +        [InlineData("http://www.foo.com", "http://payments.sandbox-foo.com", null, null, false, typeof(ArgumentException))]
    +        [InlineData("https://payments.foo.com", "http://payments.sandbox-foo.com", null, null, false, typeof(ArgumentException))]
    +        [InlineData("http://payments.foo.com", "https://payments.sandbox-foo.com", null, null, false, typeof(ArgumentException))]
    +        [InlineData("http://payments.foo.com/path", "https://payments.sandbox-foo.com", null, null, false, typeof(ArgumentException))]
    +        [InlineData("http://www.foo.com", "http://payments.foo.com", null, null, true, typeof(ArgumentException))]
    +        // URI not based on custom configured Hpp URIs
    +        [InlineData("http://www.foo.com", null, "http://hpp.sandbox-foo.com", null, false, typeof(ArgumentException))]
    +        [InlineData("http://www.foo.com", null, "http://hpp.foo.com", null, true, typeof(ArgumentException))]
    +        // URI not based on custom configured Auth URIs
    +        [InlineData("http://www.foo.com", null, null, "http://auth.sandbox-foo.com", false, typeof(ArgumentException))]
    +        [InlineData("http://www.foo.com", null, null, "http://auth.foo.com", true, typeof(ArgumentException))]
    +        public void HasValidBaseUri_WithNullOrNotValidValue_ThrowsExpectedException(
    +            string? url,
    +            string? configuredPaymentApiUrl,
    +            string? configuredPaymentHppUrl,
    +            string? configuredAuthApiUrl,
    +            bool useSandbox,
    +            Type exceptionType)
    +        {
    +            var value = url != null ?  new Uri(url) : null;
    +            var options = new TrueLayerOptions()
    +            {
    +                UseSandbox = useSandbox,
    +                Payments = new()
    +                {
    +                    Uri = configuredPaymentApiUrl is not null ? new Uri(configuredPaymentApiUrl) : null,
    +                    HppUri =
    +                        configuredPaymentHppUrl is not null ? new Uri(configuredPaymentHppUrl) : null,
    +                },
    +                Auth = new()
    +                {
    +                    Uri = configuredAuthApiUrl is not null ? new Uri(configuredAuthApiUrl) : null,
    +                }
    +            };
    +
    +            Assert.Throws(exceptionType, () => value.HasValidBaseUri(nameof(value), options));
    +        }
    +
    +        [Theory]
    +        // URI based on custom configured Payments URIs
    +        [InlineData("http://payments.foo.com", "http://payments.foo.com", null, null, false)]
    +        [InlineData("http://payments.foo.com/path/", "http://payments.foo.com", null, null, false)]
    +        [InlineData("http://payments.foo.com/path", "http://payments.foo.com", null, null, false)]
    +        [InlineData("https://payments.foo.com/path", "https://payments.foo.com", null, null, false)]
    +        [InlineData("http://payments.sandbox-foo.com", "http://payments.sandbox-foo.com", null, null, true)]
    +        // URI based on custom configured Hpp URIs
    +        [InlineData("http://hpp.sandbox-foo.com", null, "http://hpp.sandbox-foo.com", null, true)]
    +        [InlineData("http://hpp.foo.com", null, "http://hpp.foo.com", null, false)]
    +        // URI based on custom configured Auth URIs
    +        [InlineData("http://auth.sandbox-foo.com", null, null, "http://auth.sandbox-foo.com", true)]
    +        [InlineData("http://auth.foo.com", null, null, "http://auth.foo.com", false)]
    +        // URI based on Tl URIs
    +        [InlineData("https://auth.truelayer-sandbox.com/v3/foo", null, null, null, true)]
    +        [InlineData("https://auth.truelayer.com/v3/foo/", null, null, null, false)]
    +        [InlineData("https://api.truelayer-sandbox.com/v3/foo", null, null, null, true)]
    +        [InlineData("https://api.truelayer.com/v3/foo/", null, null, null, false)]
    +        [InlineData("https://payment.truelayer-sandbox.com/v3/foo", null, null, null, true)]
    +        [InlineData("https://payment.truelayer.com/v3/foo/", null, null, null, false)]
    +        // URI is localhost
    +        [InlineData("https://localhost/v3/foo/", null, null, null, false)]
    +        [InlineData("http://localhost/v3/foo/", null, null, null, false)]
    +        [InlineData("http://localhost/v3/foo/", null, null, null, true)]
    +        public void HasValidBaseUri_WithValidInput_ReturnsSameValue(
    +            string url,
    +            string? configuredPaymentApiUrl,
    +            string? configuredPaymentHppUrl,
    +            string? configuredAuthApiUrl,
    +            bool useSandbox)
    +        {
    +            var value = new Uri(url);
    +            var options = new TrueLayerOptions()
    +            {
    +                UseSandbox = useSandbox,
    +                Payments = new()
    +                {
    +                    Uri = configuredPaymentApiUrl is not null ? new Uri(configuredPaymentApiUrl) : null,
    +                    HppUri =
    +                        configuredPaymentHppUrl is not null ? new Uri(configuredPaymentHppUrl) : null,
    +                },
    +                Auth = new()
    +                {
    +                    Uri = configuredAuthApiUrl is not null ? new Uri(configuredAuthApiUrl) : null,
    +                }
    +            };
    +
    +            var actual = value.HasValidBaseUri(nameof(value), options);
    +            Assert.Equal(value, actual);
    +
    +        }
         }
     }
    
  • test/TrueLayer.Tests/TrueLayerClientTests.cs+1 1 modified
    @@ -33,7 +33,7 @@ public void Can_create_truelayer_client_from_options()
                     }
                 };
     
    -            var client = new TrueLayerClient(new ApiClient(new HttpClient()), Options.Create(options));
    +            var client = new TrueLayerClient(new ApiClient(new HttpClient(), Options.Create(options)), Options.Create(options));
                 client.Auth.ShouldNotBeNull();
                 client.Payments.ShouldNotBeNull();
                 client.MerchantAccounts.ShouldNotBeNull();
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.