Skip to content
Open
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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Humanizer" Version="2.14.1" />
<PackageVersion Include="Markdig" Version="0.40.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.10" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
Expand Down
15 changes: 14 additions & 1 deletion src/BaGetter.Core/Authentication/ApiKeyAuthenticationService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BaGetter.Core.Configuration;
Expand Down Expand Up @@ -28,6 +30,17 @@ private bool Authenticate(string apiKey)
// No authentication is necessary if there is no required API key.
if (_apiKey == null && (_apiKeys.Length == 0)) return true;

return _apiKey == apiKey || _apiKeys.Any(x => x.Key.Equals(apiKey));
return FixedTimeEquals(_apiKey, apiKey) || _apiKeys.Any(x => FixedTimeEquals(x.Key, apiKey));
}

private static bool FixedTimeEquals(string expected, string actual)
{
if (expected == null || actual == null)
return false;

var expectedBytes = Encoding.UTF8.GetBytes(expected);
var actualBytes = Encoding.UTF8.GetBytes(actual);
Comment on lines +41 to +42

return CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
}
}
13 changes: 13 additions & 0 deletions src/BaGetter.Core/Configuration/BaGetterOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,17 @@ public class BaGetterOptions
public StatisticsOptions Statistics { get; set; }

public NugetAuthenticationOptions Authentication { get; set; }

/// <summary>
/// Allowed CORS origins. If empty or null, all origins are allowed.
/// Example: ["https://myapp.example.com", "https://admin.example.com"]
/// </summary>
public string[] AllowedCorsOrigins { get; set; }

/// <summary>
/// Trusted proxy IP addresses for forwarded headers.
/// If empty or null, forwarded headers are accepted from any source (not recommended in production).
/// Example: ["10.0.0.1", "192.168.1.1"]
/// </summary>
public string[] TrustedProxies { get; set; }
}
12 changes: 12 additions & 0 deletions src/BaGetter.Core/Configuration/MirrorAuthenticationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,22 @@ public class MirrorAuthenticationOptions
{
public MirrorAuthenticationType Type { get; set; } = MirrorAuthenticationType.None;

/// <summary>
/// Username for upstream mirror authentication.
/// Avoid storing in appsettings.json — use environment variables or Docker secrets instead.
/// </summary>
public string Username { get; set; }

/// <summary>
/// Password for upstream mirror authentication.
/// Avoid storing in appsettings.json — use environment variables or Docker secrets instead.
/// </summary>
public string Password { get; set; }

/// <summary>
/// Bearer token for upstream mirror authentication.
/// Avoid storing in appsettings.json — use environment variables or Docker secrets instead.
/// </summary>
public string Token { get; set; }

public Dictionary<string, string> CustomHeaders { get; set; } = [];
Expand Down
3 changes: 3 additions & 0 deletions src/BaGetter.Core/Indexing/PackageIndexingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public async Task<PackageIndexingResult> IndexAsync(Stream packageStream, Cancel
package = packageReader.GetPackageMetadata();
package.Published = _time.UtcNow;

// Validate package entries to prevent zip-slip and malformed archives
await packageReader.ValidatePackageEntriesAsync(cancellationToken);

nuspecStream = await packageReader.GetNuspecAsync(cancellationToken);
nuspecStream = await nuspecStream.AsTemporaryFileStreamAsync(cancellationToken);

Expand Down
18 changes: 18 additions & 0 deletions src/BaGetter.Core/Storage/PackageStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public async Task SavePackageContentAsync(
var lowercasedId = package.Id.ToLowerInvariant();
var lowercasedNormalizedVersion = package.NormalizedVersionString.ToLowerInvariant();

ValidatePathSegment(lowercasedId, nameof(package.Id));
ValidatePathSegment(lowercasedNormalizedVersion, nameof(package.NormalizedVersionString));

var packagePath = PackagePath(lowercasedId, lowercasedNormalizedVersion);
var nuspecPath = NuspecPath(lowercasedId, lowercasedNormalizedVersion);
var readmePath = ReadmePath(lowercasedId, lowercasedNormalizedVersion);
Expand Down Expand Up @@ -165,6 +168,9 @@ public async Task DeleteAsync(string id, NuGetVersion version, CancellationToken
var lowercasedId = id.ToLowerInvariant();
var lowercasedNormalizedVersion = version.ToNormalizedString().ToLowerInvariant();

ValidatePathSegment(lowercasedId, nameof(id));
ValidatePathSegment(lowercasedNormalizedVersion, "version");

var packagePath = PackagePath(lowercasedId, lowercasedNormalizedVersion);
var nuspecPath = NuspecPath(lowercasedId, lowercasedNormalizedVersion);
var readmePath = ReadmePath(lowercasedId, lowercasedNormalizedVersion);
Expand All @@ -184,6 +190,10 @@ private async Task<Stream> GetStreamAsync(
{
var lowercasedId = id.ToLowerInvariant();
var lowercasedNormalizedVersion = version.ToNormalizedString().ToLowerInvariant();

ValidatePathSegment(lowercasedId, nameof(id));
ValidatePathSegment(lowercasedNormalizedVersion, "version");

var path = pathFunc(lowercasedId, lowercasedNormalizedVersion);

try
Expand Down Expand Up @@ -239,4 +249,12 @@ private string IconPath(string lowercasedId, string lowercasedNormalizedVersion)
lowercasedNormalizedVersion,
"icon");
}

private static void ValidatePathSegment(string value, string paramName)
{
if (value.Contains("..") || value.Contains('/') || value.Contains('\\'))
{
throw new ArgumentException($"Invalid path segment: \"{value}\"", paramName);
}
}
}
4 changes: 3 additions & 1 deletion src/BaGetter.Gcp/GoogleCloudStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ public async Task<StoragePutResult> PutAsync(string path, Stream content, string
var existingObject = await storage.GetObjectAsync(_bucketName, objectName, cancellationToken: cancellationToken);
var existingHash = Convert.FromBase64String(existingObject.Md5Hash);

// hash the content that was uploaded
// MD5 is used here because Google Cloud Storage only provides MD5 hashes
// in object metadata. This is not used for security purposes, only for
// content-equality comparison to detect conflicts.
seekableContent.Position = 0;
byte[] contentHash;
using (var md5 = MD5.Create())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text;
using System.Threading.Tasks;
Expand Down Expand Up @@ -95,6 +96,19 @@ bagetterOptions.Value.Authentication.Credentials is null ||

private bool ValidateCredentials(string username, string password)
{
return bagetterOptions.Value.Authentication.Credentials.Any(a => a.Username.Equals(username, StringComparison.OrdinalIgnoreCase) && a.Password == password);
return bagetterOptions.Value.Authentication.Credentials.Any(a =>
a.Username.Equals(username, StringComparison.OrdinalIgnoreCase) &&
FixedTimeEquals(a.Password, password));
}

private static bool FixedTimeEquals(string expected, string actual)
{
if (expected == null || actual == null)
return false;

var expectedBytes = Encoding.UTF8.GetBytes(expected);
var actualBytes = Encoding.UTF8.GetBytes(actual);

return CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
}
}
1 change: 1 addition & 0 deletions src/BaGetter.Web/BaGetter.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<ItemGroup>
<PackageReference Include="Humanizer" />
<PackageReference Include="Markdig" />
<PackageReference Include="HtmlSanitizer" />
</ItemGroup>

<ItemGroup>
Expand Down
16 changes: 13 additions & 3 deletions src/BaGetter.Web/Pages/Package.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading;
using System.Threading.Tasks;
using BaGetter.Core;
using Ganss.Xss;
using Markdig;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.RazorPages;
Expand All @@ -16,6 +17,7 @@ namespace BaGetter.Web;
public class PackageModel : PageModel
{
private static readonly MarkdownPipeline MarkdownPipeline;
private static readonly HtmlSanitizer HtmlSanitizer;

private readonly IPackageService _packages;
private readonly IPackageContentService _content;
Expand All @@ -27,6 +29,12 @@ static PackageModel()
MarkdownPipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();

HtmlSanitizer = new HtmlSanitizer();
HtmlSanitizer.AllowedTags.Add("pre");
HtmlSanitizer.AllowedTags.Add("code");
// Allow id attributes for heading anchors generated by Markdig
HtmlSanitizer.AllowedAttributes.Add("id");
}

public PackageModel(
Expand Down Expand Up @@ -209,7 +217,8 @@ private async Task<HtmlString> GetReadmeHtmlStringOrNullAsync(
var readme = await reader.ReadToEndAsync(cancellationToken);

var readmeHtml = Markdown.ToHtml(readme, MarkdownPipeline);
return new HtmlString(readmeHtml);
var sanitizedHtml = HtmlSanitizer.Sanitize(readmeHtml);
return new HtmlString(sanitizedHtml);
}

private HtmlString ParseReleaseNotes()
Expand All @@ -219,8 +228,9 @@ private HtmlString ParseReleaseNotes()
return HtmlString.Empty;
}

var releseNotesHtml = Markdown.ToHtml(Package.ReleaseNotes, MarkdownPipeline);
return new HtmlString(releseNotesHtml);
var releaseNotesHtml = Markdown.ToHtml(Package.ReleaseNotes, MarkdownPipeline);
var sanitizedHtml = HtmlSanitizer.Sanitize(releaseNotesHtml);
return new HtmlString(sanitizedHtml);
}

public class DependencyGroupModel
Expand Down
42 changes: 35 additions & 7 deletions src/BaGetter/ConfigureBaGetterServer.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Linq;
using System.Net;
using BaGetter.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
Expand All @@ -24,12 +26,24 @@ public ConfigureBaGetterServer(IOptions<BaGetterOptions> baGetterOptions)

public void Configure(CorsOptions options)
{
// TODO: Consider disabling this on production builds.
options.AddPolicy(
CorsPolicy,
builder => builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
builder =>
{
var origins = _baGetterOptions.AllowedCorsOrigins;
if (origins is { Length: > 0 })
{
builder.WithOrigins(origins);
}
else
{
builder.AllowAnyOrigin();
}

builder
.WithMethods("GET", "HEAD", "OPTIONS")
.WithHeaders("Accept", "Accept-Language", "Content-Language", "Content-Type");
Comment on lines +36 to +45
});
}

public void Configure(FormOptions options)
Expand All @@ -42,9 +56,23 @@ public void Configure(ForwardedHeadersOptions options)
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;

// Do not restrict to local network/proxy
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
var trustedProxies = _baGetterOptions.TrustedProxies;
if (trustedProxies is { Length: > 0 })
{
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
foreach (var proxy in trustedProxies.Where(p => !string.IsNullOrWhiteSpace(p)))
{
options.KnownProxies.Add(IPAddress.Parse(proxy));
}
}
else
{
// No trusted proxies configured: accept forwarded headers from any source.
// This is not recommended in production — configure TrustedProxies for security.
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
}
}

public void Configure(IISServerOptions options)
Expand Down
6 changes: 3 additions & 3 deletions src/BaGetter/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ public static IHostBuilder CreateHostBuilder(string[] args)
{
web.ConfigureKestrel(options =>
{
// Remove the upload limit from Kestrel. If needed, an upload limit can
// be enforced by a reverse proxy server, like IIS.
options.Limits.MaxRequestBodySize = null;
// Limit upload size to 8 GiB (matches MaxPackageSizeGiB default).
// Can be further restricted by a reverse proxy.
options.Limits.MaxRequestBodySize = 8L * 1024 * 1024 * 1024;
Comment on lines +76 to +78
});

web.UseStartup<Startup>();
Expand Down
10 changes: 8 additions & 2 deletions src/BaGetter/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,23 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
app.UseDeveloperExceptionPage();
app.UseStatusCodePages();
}
else
{
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseForwardedHeaders();
app.UsePathBase(options.PathBase);

app.UseStaticFiles();
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();

app.UseCors(ConfigureBaGetterServer.CorsPolicy);

app.UseAuthentication();
app.UseAuthorization();

app.UseOperationCancelledMiddleware();

app.UseEndpoints(endpoints =>
Expand Down
23 changes: 23 additions & 0 deletions src/BaGetter/ValidateBaGetterOptions.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BaGetter.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace BaGetter;
Expand All @@ -16,6 +18,13 @@ namespace BaGetter;
public class ValidateBaGetterOptions
: IValidateOptions<BaGetterOptions>
{
private readonly ILogger<ValidateBaGetterOptions> _logger;

public ValidateBaGetterOptions(ILogger<ValidateBaGetterOptions> logger)
{
_logger = logger;
}

private static readonly HashSet<string> ValidDatabaseTypes
= new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Expand Down Expand Up @@ -50,6 +59,20 @@ public ValidateOptionsResult Validate(string name, BaGetterOptions options)
{
var failures = new List<string>();

// Security: warn prominently if no authentication is configured
var hasApiKey = !string.IsNullOrEmpty(options.ApiKey);
var hasApiKeys = options.Authentication?.ApiKeys?.Length > 0;
var hasCredentials = options.Authentication?.Credentials?.Length > 0 &&
options.Authentication.Credentials.Any(c => !string.IsNullOrWhiteSpace(c.Username));

if (!hasApiKey && !hasApiKeys && !hasCredentials)
{
_logger.LogWarning(
"SECURITY WARNING: No authentication is configured. " +
"Anyone can push, delete, and relist packages. " +
"Set 'ApiKey', 'Authentication:ApiKeys', or 'Authentication:Credentials' in your configuration.");
}

if (options.Database == null) failures.Add($"The '{nameof(BaGetterOptions.Database)}' config is required");
if (options.Mirror == null) failures.Add($"The '{nameof(BaGetterOptions.Mirror)}' config is required");
if (options.Search == null) failures.Add($"The '{nameof(BaGetterOptions.Search)}' config is required");
Expand Down
9 changes: 9 additions & 0 deletions src/BaGetter/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
"Path": ""
},

// To use MinIO or any S3-compatible storage, uncomment and replace the Storage section above:
//"Storage": {
// "Type": "AwsS3",
// "Endpoint": "http://localhost:9000",
// "Bucket": "nuget-packages",
// "AccessKey": "minioadmin",
// "SecretKey": "minioadmin"
Comment on lines +17 to +23
//},

"Search": {
"Type": "Database"
},
Expand Down