diff --git a/Configs.cs b/Configs.cs index cbd1265..cb13660 100644 --- a/Configs.cs +++ b/Configs.cs @@ -43,12 +43,14 @@ public record MessagingConfig(string ConnectionString); /// The storage /// The redirect URL for unmatched requests to the server /// The machine name +/// Whether to disable encryption on stored reports public record EnvironmentConfig( string? Hostname, bool IsProd, string Storage, string? RedirectUrl = null, - string? MachineName = null + string? MachineName = null, + bool? DisableReportEncryption = null ); /// diff --git a/DuplicatiIngress.csproj b/DuplicatiIngress.csproj index 3b6390d..643403b 100644 --- a/DuplicatiIngress.csproj +++ b/DuplicatiIngress.csproj @@ -23,6 +23,8 @@ + + diff --git a/IngressHandler.cs b/IngressHandler.cs index 531345d..2614ed5 100644 --- a/IngressHandler.cs +++ b/IngressHandler.cs @@ -30,12 +30,14 @@ namespace DuplicatiIngress; /// /// Represents the ingress handler /// +/// The environment configuration /// The HTTP context accessor /// The legacy tokens /// The JWT validator /// The bus control /// The storage public class IngressHandler( + EnvironmentConfig environmentConfig, IHttpContextAccessor httpContextAccessor, IPreconfiguredTokens preconfiguredTokens, IJWTValidator jWTValidator, @@ -71,7 +73,7 @@ public async Task MapPost(string token, CancellationToken ct) // 2. Generate a filename var uuid = Uuid7.String(); - var filename = $"{parsedToken.OrganizationId}/{uuid}.json.aes"; + var filename = $"{parsedToken.OrganizationId}/{uuid}.json{(environmentConfig.DisableReportEncryption ?? false ? "" : ".aes")}"; // 4. Rudimentary validation of input tempFileToDelete = Path.GetTempFileName(); @@ -102,20 +104,30 @@ public async Task MapPost(string token, CancellationToken ct) throw new UserReportedException("Payload is not valid JSON", statuscode: 400, exception: ex); } - // 5. Encrypt with AESCrypt - encFileToDelete = Path.GetTempFileName(); - var encHeaders = new KeyValuePair("key", Encoding.UTF8.GetBytes(parsedToken.KeyId)); - var options = new SharpAESCrypt.EncryptionOptions(InsertPlaceholder: false, AdditionalExtensions: [encHeaders]); + string fileToUpload; + if (environmentConfig.DisableReportEncryption ?? false) + { + fileToUpload = tempFileToDelete; + } + else + { + // 5. Encrypt with AESCrypt + encFileToDelete = Path.GetTempFileName(); + var encHeaders = new KeyValuePair("key", Encoding.UTF8.GetBytes(parsedToken.KeyId)); + var options = new SharpAESCrypt.EncryptionOptions(InsertPlaceholder: false, AdditionalExtensions: [encHeaders]); + + using (var s1 = File.OpenRead(tempFileToDelete)) + using (var s2 = File.OpenWrite(encFileToDelete)) + await SharpAESCrypt.AESCrypt.EncryptAsync(parsedToken.EncryptionKey, s1, s2, options, ct); - using (var s1 = File.OpenRead(tempFileToDelete)) - using (var s2 = File.OpenWrite(encFileToDelete)) - await SharpAESCrypt.AESCrypt.EncryptAsync(parsedToken.EncryptionKey, s1, s2, options, ct); + fileToUpload = encFileToDelete; + } // 6. Store the payload with IKVPS var uploaded = false; try { - using (var fs = File.OpenRead(encFileToDelete)) + using (var fs = File.OpenRead(fileToUpload)) await storage.WriteAsync(filename, fs, ct); uploaded = true; } diff --git a/JWTValidator.cs b/JWTValidator.cs index 87ee9f5..31e04e5 100644 --- a/JWTValidator.cs +++ b/JWTValidator.cs @@ -47,15 +47,21 @@ public class JWTValidator : IJWTValidator /// private readonly IEncryptionKeyProvider EncryptionKeyProvider; + /// + /// The environment configuration + /// + private readonly EnvironmentConfig EnvironmentConfig; + /// /// Initializes a new instance of the class /// /// The encryption key provider /// The JWT configuration - public JWTValidator(IEncryptionKeyProvider encryptionKeyProvider, JWTConfig jWTConfig) + public JWTValidator(IEncryptionKeyProvider encryptionKeyProvider, JWTConfig jWTConfig, EnvironmentConfig environmentConfig) { EncryptionKeyProvider = encryptionKeyProvider; TokenValidationParameters = GetTokenValidationParameters(jWTConfig); + EnvironmentConfig = environmentConfig; } /// @@ -126,6 +132,9 @@ public ParsedIngressToken Validate(string token) if (string.IsNullOrWhiteSpace(orgId)) throw new SecurityTokenValidationException("Organization ID is missing"); + if (EnvironmentConfig.DisableReportEncryption ?? false) + return new ParsedIngressToken(orgId, null!, null!); + if (string.IsNullOrWhiteSpace(keyId)) throw new SecurityTokenValidationException("Key ID is missing"); diff --git a/PreconfigureTokens.cs b/PreconfigureTokens.cs index 6bb2118..17ee39b 100644 --- a/PreconfigureTokens.cs +++ b/PreconfigureTokens.cs @@ -90,7 +90,7 @@ internal static PreconfiguredTokensConfig CreateEmpty() /// /// Service for getting information about preconfigured tokens /// -public class PreconfiguredTokens(IEncryptionKeyProvider encryptionKeyProvider, PreconfiguredTokensConfig preconfiguredTokensConfig) : IPreconfiguredTokens +public class PreconfiguredTokens(IEncryptionKeyProvider encryptionKeyProvider, PreconfiguredTokensConfig preconfiguredTokensConfig, EnvironmentConfig environmentConfig) : IPreconfiguredTokens { /// public ParsedIngressToken? GetPreconfiguredToken(string token) @@ -101,12 +101,18 @@ public class PreconfiguredTokens(IEncryptionKeyProvider encryptionKeyProvider, P if (preconfiguredTokensConfig.BlacklistTokens.Contains(token)) throw new SecurityTokenValidationException("Invalid token, blacklisted"); - if (preconfiguredTokensConfig.WhitelistTokens.TryGetValue(token, out var tokenEntry) && !string.IsNullOrWhiteSpace(tokenEntry?.OrganizationId) && !string.IsNullOrWhiteSpace(tokenEntry?.KeyId)) + if (preconfiguredTokensConfig.WhitelistTokens.TryGetValue(token, out var tokenEntry) && !string.IsNullOrWhiteSpace(tokenEntry?.OrganizationId)) { - var encryptionKey = encryptionKeyProvider.GetEncryptionKey(tokenEntry.KeyId); - if (string.IsNullOrWhiteSpace(encryptionKey)) - throw new SecurityTokenValidationException("Invalid token, key not found"); - return new ParsedIngressToken(tokenEntry.OrganizationId, tokenEntry.KeyId, encryptionKey); + if (environmentConfig.DisableReportEncryption ?? false) + return new ParsedIngressToken(tokenEntry.OrganizationId, null!, null!); + + if (!string.IsNullOrWhiteSpace(tokenEntry?.KeyId)) + { + var encryptionKey = encryptionKeyProvider.GetEncryptionKey(tokenEntry.KeyId); + if (string.IsNullOrWhiteSpace(encryptionKey)) + throw new SecurityTokenValidationException("Invalid token, key not found"); + return new ParsedIngressToken(tokenEntry.OrganizationId, tokenEntry.KeyId, encryptionKey); + } } return null; diff --git a/Program.cs b/Program.cs index 162a2fe..75611c7 100644 --- a/Program.cs +++ b/Program.cs @@ -27,6 +27,7 @@ using Serilog; using Serilog.Core; using Serilog.Events; +using SimpleSecurityFilter; var builder = WebApplication.CreateBuilder(args); @@ -85,6 +86,9 @@ builder.Services.AddHttpContextAccessor(); +var securityconfig = builder.Configuration.GetSection("Security").Get(); +builder.AddSimpleSecurityFilter(securityconfig, msg => Log.Warning(msg)); + // Load encryption keys var encryptionKeys = builder.Configuration.GetSection("EncryptionKey") .GetChildren() @@ -164,7 +168,7 @@ }; }); -app.UseSecurityFilter(); +app.UseSimpleSecurityFilter(securityconfig); app.MapPost("/backupreports/{token}", async ([FromServices] IngressHandler handler, [FromRoute] string token, CancellationToken ct) => diff --git a/README.md b/README.md index 19d5a31..abed502 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,18 @@ The ingress server is intended to have very few moving parts and generally just The following environment variables are optional, and should be considered for a production deployment: -| Variable | Description | -| -------------------------------- | ----------------------------------------------------------------------------- | -| ENVIRONMENT\_\_HOSTNAME | The server hostname for logging purposes | -| ENVIRONMENT\_\_MACHINENAME | Name of the machine for logging purposes | -| ENVIRONMENT\_\_REDIRECTURL | Url to redirect to when visiting the root path | -| PRECONFIGUREDTOKENS\_\_STORAGE | The KVPSButter connection string to the storage that contains an IP blacklist | -| PRECONFIGUREDTOKENS\_\_WHITELIST | The key that contains the IP blacklist | -| PRECONFIGUREDTOKENS\_\_BLACKLIST | The key that contains the IP blacklist | +| Variable | Description | +| -------------------------------------- | ------------------------------------------------------------------------------ | +| ENVIRONMENT\_\_HOSTNAME | The server hostname for logging purposes | +| ENVIRONMENT\_\_MACHINENAME | Name of the machine for logging purposes | +| ENVIRONMENT\_\_REDIRECTURL | Url to redirect to when visiting the root path | +| ENVIRONMENT\_\_DISABLEREPORTENCRYPTION | Disables encrypting received backup reports on storage | +| PRECONFIGUREDTOKENS\_\_STORAGE | The KVPSButter connection string to the storage that contains an IP blacklist | +| PRECONFIGUREDTOKENS\_\_WHITELIST | The key that contains the IP blacklist | +| PRECONFIGUREDTOKENS\_\_BLACKLIST | The key that contains the IP blacklist | +| SECURITY\_\_MAXREQUESTSPERSECONDPERIP | The maximum number of request from a single IP per second before throttling it | +| SECURITY\_\_FILTERPATTERNS | Boolean toggling filtering of scanning patterns | +| SECURITY\_\_RATELIMITENABLED | Boolean toggling if IP rate limiting is enabled | ## Setting Up Local Development Environment diff --git a/SecurityMiddleware.cs b/SecurityMiddleware.cs deleted file mode 100644 index ffd79ca..0000000 --- a/SecurityMiddleware.cs +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) 2025 Duplicati Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights to -// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -// of the Software, and to permit persons to whom the Software is furnished to do -// so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -namespace DuplicatiIngress; - -/// -/// Middleware to filter against most common security threats and crawlers. -/// This is a simple implementation and is meant to provide basic protection and -/// discourage automated attacks as the requests will be blocked with a 403 status code. -/// -/// This in conjunction with the rate limiting options provides basic protection. -/// -public class SecurityMiddleware(RequestDelegate next, ILogger logger) -{ - - /// - /// These are the file extensions that are blocked by default. - /// - private static readonly HashSet _blockedExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".php", ".cgi", ".asp", ".aspx", ".ashx", ".asmx", ".axd", ".config", ".env", - ".exe", ".dll", ".bat", ".cmd", ".sh", ".jar", ".jsp", ".jspx", ".war", - ".pl", ".py", ".rb", ".htaccess", ".htpasswd", ".ini", ".cfg", ".xml", ".conf" - }; - - /// - /// This is a comprehensive list of attack paterns to filter, this is not exhaustive - /// it can be incremented with more patterns as needed. - /// - private static readonly HashSet _blockedPatterns = new(StringComparer.OrdinalIgnoreCase) - { - // Path traversal - "../../", "../", "..\\", "%2e%2e", "%252e", "..;", "%c0%ae", - - // XSS - " - public async Task InvokeAsync(HttpContext context) - { - if (IsBlocked(context)) - { - var remoteip = context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - logger.LogWarning("Blocked request from IP {IP} with path {Path} and user agent {UserAgent}", - remoteip, - context.Request.Path, - context.Request.Headers.UserAgent.ToString()); - - context.Response.StatusCode = StatusCodes.Status403Forbidden; - return; - } - - await next(context); - } - - /// - /// Returns a requests matches any of the blocked patterns or extensions. - /// - /// Context of the request - /// True if the request should be blocked - private bool IsBlocked(HttpContext context) - { - var path = context.Request.Path.ToString().ToLowerInvariant(); - - // Check file extensions - if (_blockedExtensions.Any(x => path.EndsWith(x))) - return true; - - // Check for suspicious patterns in path, query string, and headers - var valuesToCheck = new[] - { - path, - context.Request.QueryString.ToString().ToLowerInvariant(), - context.Request.Headers["Referer"].ToString(), - context.Request.Headers["Cookie"].ToString(), - context.Request.Headers["X-Forwarded-For"].ToString(), - context.Request.Headers["X-Forwarded-Host"].ToString() - }; - - return valuesToCheck.Any(value => - _blockedPatterns.Any(pattern => value.Contains(pattern, StringComparison.OrdinalIgnoreCase))); - } -} - -/// -/// Helper to register the middleware on app. -/// -public static class SecurityMiddlewareExtensions -{ - /// - /// Use the security filter middleware - /// - /// The application builder - /// The application builder - public static IApplicationBuilder UseSecurityFilter(this IApplicationBuilder builder) - => builder.UseMiddleware(); -} \ No newline at end of file