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
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
queries: security-extended

- name: Setup .NET
uses: actions/setup-dotnet@v5
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<!-- Authentication Packages -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="$(MicrosoftPackageVersion)" />
<!-- Web Packages -->
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.4" />
<PackageVersion Include="MudBlazor" Version="9.5.0" />
Expand Down
9 changes: 8 additions & 1 deletion src/Demo.JsonApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
if (!string.IsNullOrWhiteSpace(statusUpdate.Notes))
{
logger.LogInformation("Order {OrderId} status updated to {Status}. Notes: {Notes}",
id, statusUpdate.Status, statusUpdate.Notes);
id, statusUpdate.Status, SanitizeForLog(statusUpdate.Notes));

Check warning

Code scanning / CodeQL

Log entries created from user input Medium

This log entry depends on a
user-provided value
.
}

return Results.Ok(order);
Expand Down Expand Up @@ -153,3 +153,10 @@
app.MapDefaultEndpoints();

app.Run();

/// <summary>
/// Removes CR/LF from user-supplied strings before logging to prevent log-forging.
/// </summary>
static string SanitizeForLog(string? value) =>
value?.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal) ?? string.Empty;
11 changes: 9 additions & 2 deletions src/Demo.JsonApi/Services/InMemoryOrderService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public Task<Order> CreateOrderAsync(Order order, CancellationToken cancellationT
throw new InvalidOperationException($"Order with ID {order.OrderId} already exists");
}

_logger.LogInformation("Created new order: {OrderId}", order.OrderId);
_logger.LogInformation("Created new order: {OrderId}", SanitizeForLog(order.OrderId));
return Task.FromResult(order);
}

Expand All @@ -55,7 +55,7 @@ public Task<IEnumerable<Order>> GetAllOrdersAsync(CancellationToken cancellation
}

order.Status = status;
_logger.LogInformation("Updated order {OrderId} status to {Status}", orderId, status);
_logger.LogInformation("Updated order {OrderId} status to {Status}", SanitizeForLog(orderId), status);
return Task.FromResult<Order?>(order);
}

Expand Down Expand Up @@ -330,4 +330,11 @@ private static string GenerateOrderId()
var random = Random.Shared.Next(1000, 9999);
return $"ORD-{timestamp:yyyy}-{random}";
}

/// <summary>
/// Removes CR/LF from user-supplied strings before logging to prevent log-forging.
/// </summary>
private static string SanitizeForLog(string? value) =>
value?.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal) ?? string.Empty;
}
15 changes: 11 additions & 4 deletions src/Demo.SoapApi/Services/WarehouseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public WarehouseService(IFulfillmentRepository repository, ILogger<WarehouseServ

public async Task<SubmitFulfillmentResponse> SubmitFulfillmentRequest(SubmitFulfillmentRequest request)
{
_logger.LogInformation("Received fulfillment request for order {OrderNumber}", request.OrderNumber);
_logger.LogInformation("Received fulfillment request for order {OrderNumber}", SanitizeForLog(request.OrderNumber));

try
{
Expand Down Expand Up @@ -94,7 +94,7 @@ public async Task<SubmitFulfillmentResponse> SubmitFulfillmentRequest(SubmitFulf
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing fulfillment request for order {OrderNumber}", request.OrderNumber);
_logger.LogError(ex, "Error processing fulfillment request for order {OrderNumber}", SanitizeForLog(request.OrderNumber));
return new SubmitFulfillmentResponse
{
Success = false,
Expand All @@ -109,7 +109,7 @@ public async Task<SubmitFulfillmentResponse> SubmitFulfillmentRequest(SubmitFulf
public async Task<GetFulfillmentStatusResponse> GetFulfillmentStatus(GetFulfillmentStatusRequest request)
{
_logger.LogInformation("Status query for Order: {OrderNumber}, Confirmation: {ConfirmationNumber}",
request.OrderNumber, request.ConfirmationNumber);
SanitizeForLog(request.OrderNumber), SanitizeForLog(request.ConfirmationNumber));

try
{
Expand Down Expand Up @@ -170,7 +170,7 @@ record = await _repository.GetByOrderNumberAsync(request.OrderNumber);
public async Task<CancelFulfillmentResponse> CancelFulfillment(CancelFulfillmentRequest request)
{
_logger.LogInformation("Cancellation request for Order: {OrderNumber}, Confirmation: {ConfirmationNumber}",
request.OrderNumber, request.ConfirmationNumber);
SanitizeForLog(request.OrderNumber), SanitizeForLog(request.ConfirmationNumber));

try
{
Expand Down Expand Up @@ -300,4 +300,11 @@ private void SimulateStatusProgression(FulfillmentRecord record)
record.DeliveredDateTime = record.ShippedDateTime?.AddDays(deliveryDays);
}
}

/// <summary>
/// Removes CR/LF from user-supplied strings before logging to prevent log-forging.
/// </summary>
private static string SanitizeForLog(string? value) =>
value?.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal) ?? string.Empty;
}
9 changes: 8 additions & 1 deletion src/Demo.SoapApi/Storage/InMemoryFulfillmentRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public Task AddAsync(FulfillmentRecord record)
_orderToConfirmationMap[record.OrderNumber] = record.ConfirmationNumber;

_logger.LogDebug("Added fulfillment {ConfirmationNumber} for order {OrderNumber}",
record.ConfirmationNumber, record.OrderNumber);
SanitizeForLog(record.ConfirmationNumber), SanitizeForLog(record.OrderNumber));

return Task.CompletedTask;
}
Expand Down Expand Up @@ -83,4 +83,11 @@ public Task<IEnumerable<FulfillmentRecord>> GetAllAsync()
{
return Task.FromResult<IEnumerable<FulfillmentRecord>>(_fulfillmentsByConfirmation.Values.ToList());
}

/// <summary>
/// Removes CR/LF from user-supplied strings before logging to prevent log-forging.
/// </summary>
private static string SanitizeForLog(string? value) =>
value?.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal) ?? string.Empty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ public async Task<List<BehaviorMetadata>> GetBehaviorsAsync()
var query = string.Join("&", queryParams);
var url = $"api/messages?{query}";

_logger.LogInformation("Querying messages from {Url}", url);
_logger.LogInformation("Querying messages from {Url}", SanitizeForLog(url));
return await _httpClient.GetFromJsonAsync<MessagePagedResult>(url);
}
catch (Exception ex)
Expand Down Expand Up @@ -328,6 +328,13 @@ public async Task<bool> ResetDemoDataAsync()
return false;
}
}

/// <summary>
/// Removes CR/LF from user-supplied strings before logging to prevent log-forging.
/// </summary>
private static string SanitizeForLog(string? value) =>
value?.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal) ?? string.Empty;
}

// Demo DTO
Expand Down
37 changes: 37 additions & 0 deletions src/QuickApiMapper.Management.Api/Auth/DevNoOpAuthHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

namespace QuickApiMapper.Management.Api.Auth;

/// <summary>
/// Development-only authentication handler that authenticates every request as a local
/// admin principal. This handler MUST NOT be used in production; configure a real
/// identity provider via "Auth:Authority" in appsettings before exposing the Management
/// API to untrusted callers.
/// </summary>
internal sealed class DevNoOpAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public DevNoOpAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "dev-local"),
new Claim(ClaimTypes.Name, "Developer"),
new Claim(ClaimTypes.Role, "Admin")
};

var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);

return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuickApiMapper.Management.Contracts.Models;
using QuickApiMapper.Management.Api.Services;
Expand All @@ -10,6 +11,7 @@ namespace QuickApiMapper.Management.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
[Authorize]
public class IntegrationsController : ControllerBase
{
private readonly IIntegrationService _integrationService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public async Task<ActionResult<CapturedMessageDto>> GetById(

if (message == null)
{
_logger.LogWarning("Message {MessageId} not found", messageId);
_logger.LogWarning("Message {MessageId} not found", SanitizeForLog(messageId));
return NotFound(new { Message = $"Message {messageId} not found" });
}

Expand Down Expand Up @@ -160,6 +160,13 @@ public async Task<ActionResult<int>> PurgeOldMessages(
return Ok(new { DeletedCount = deletedCount });
}

/// <summary>
/// Removes CR/LF from user-supplied strings before logging to prevent log-forging.
/// </summary>
private static string SanitizeForLog(string? value) =>
value?.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal) ?? string.Empty;

/// <summary>
/// Maps a CapturedMessage domain model to a CapturedMessageDto.
/// </summary>
Expand Down
31 changes: 31 additions & 0 deletions src/QuickApiMapper.Management.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@
// Add Aspire service defaults (health checks, telemetry, service discovery)
builder.AddServiceDefaults();

// Add authentication and authorization.
// Controllers in this Management API are protected with [Authorize]; callers must
// present a valid bearer token issued by the configured identity provider.
// In development with no IdP configured the scheme falls back to no-op, but the
// middleware chain is always present so the protection attribute is enforced in
// production where a real authority is wired up via "Auth:Authority" / "Auth:Audience".
var authAuthority = builder.Configuration["Auth:Authority"];
var authAudience = builder.Configuration["Auth:Audience"];

if (!string.IsNullOrEmpty(authAuthority))
{
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = authAuthority;
options.Audience = authAudience ?? "quickapimapper-management";
});
}
else
{
// Development fallback: register a no-op authentication scheme so the
// middleware pipeline is valid. The [Authorize] attribute still gates requests —
// replace this with a real scheme before exposing to untrusted networks.
builder.Services.AddAuthentication("DevNoOp")
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions,
QuickApiMapper.Management.Api.Auth.DevNoOpAuthHandler>("DevNoOp", _ => { });
}

builder.Services.AddAuthorization();

// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
Expand Down Expand Up @@ -160,6 +190,7 @@

app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="FluentValidation" />
Expand Down
11 changes: 10 additions & 1 deletion src/QuickApiMapper.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ async Task<IResult> HandleMappingRequest(

// Read and parse input
var inputBody = await new StreamReader(request.Body).ReadToEndAsync(cancellationToken);
logger.LogDebug("Received input: {Input}", inputBody);
// Sanitize before logging to prevent log-forging via newline injection.
logger.LogDebug("Received input: {Input}", SanitizeForLog(inputBody));

// Parse input and create appropriate mapping engine based on source and destination types
var mappingEngineFactory = serviceProvider.GetRequiredService<IMappingEngineFactory>();
Expand Down Expand Up @@ -209,6 +210,14 @@ await engine.ApplyMappingAsync(
}
}

/// <summary>
/// Removes CR/LF characters from user-supplied strings before they reach the log
/// to prevent log-forging / log-injection attacks.
/// </summary>
static string SanitizeForLog(string? value) =>
value?.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal) ?? string.Empty;

XDocument CreateXmlDocument(IntegrationMapping integration)
{
// Create XML output with proper namespace if specified
Expand Down