Some checks failed
Build and Deploy LittleShop / Build TeleBot Docker Image (push) Failing after 11s
Build and Deploy LittleShop / Build LittleShop Docker Image (push) Failing after 15s
Build and Deploy LittleShop / Deploy to Production VPS (Manual Only) (push) Has been skipped
Build and Deploy LittleShop / Deploy to Pre-Production (CT109) (push) Has been skipped
Major Feature Additions: - Customer management: Full CRUD with data export and privacy compliance - Payment management: Centralized payment tracking and administration - Push notification subscriptions: Manage and track web push subscriptions Security Enhancements: - IP whitelist middleware for administrative endpoints - Data retention service with configurable policies - Enhanced push notification security documentation - Security fixes progress tracking (2025-11-14) UI/UX Improvements: - Enhanced navigation with improved mobile responsiveness - Updated admin dashboard with order status counts - Improved product CRUD forms - New customer and payment management interfaces Backend Improvements: - Extended customer service with data export capabilities - Enhanced order service with status count queries - Improved crypto payment service with better error handling - Updated validators and configuration Documentation: - DEPLOYMENT_NGINX_GUIDE.md: Nginx deployment instructions - IP_STORAGE_ANALYSIS.md: IP storage security analysis - PUSH_NOTIFICATION_SECURITY.md: Push notification security guide - UI_UX_IMPROVEMENT_PLAN.md: Planned UI/UX enhancements - UI_UX_IMPROVEMENTS_COMPLETED.md: Completed improvements Cleanup: - Removed temporary database WAL files - Removed stale commit message file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
219 lines
6.4 KiB
C#
219 lines
6.4 KiB
C#
using Microsoft.Extensions.Options;
|
|
using System.Net;
|
|
|
|
namespace LittleShop.Middleware;
|
|
|
|
/// <summary>
|
|
/// Middleware to restrict access to admin endpoints based on IP address
|
|
/// Optional defense-in-depth measure (reverse proxy is preferred approach)
|
|
/// </summary>
|
|
public class IPWhitelistMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
private readonly ILogger<IPWhitelistMiddleware> _logger;
|
|
private readonly IPWhitelistOptions _options;
|
|
|
|
public IPWhitelistMiddleware(
|
|
RequestDelegate next,
|
|
ILogger<IPWhitelistMiddleware> logger,
|
|
IOptions<IPWhitelistOptions> options)
|
|
{
|
|
_next = next;
|
|
_logger = logger;
|
|
_options = options.Value;
|
|
}
|
|
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
// Skip if IP whitelist is disabled
|
|
if (!_options.Enabled)
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
var remoteIp = context.Connection.RemoteIpAddress;
|
|
|
|
// Check for X-Forwarded-For header if behind proxy
|
|
if (_options.UseForwardedHeaders && context.Request.Headers.ContainsKey("X-Forwarded-For"))
|
|
{
|
|
var forwardedFor = context.Request.Headers["X-Forwarded-For"].ToString();
|
|
var ips = forwardedFor.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
|
if (ips.Length > 0 && IPAddress.TryParse(ips[0].Trim(), out var parsedIp))
|
|
{
|
|
remoteIp = parsedIp;
|
|
}
|
|
}
|
|
|
|
// Check for X-Real-IP header if behind nginx
|
|
if (_options.UseForwardedHeaders && context.Request.Headers.ContainsKey("X-Real-IP"))
|
|
{
|
|
var realIp = context.Request.Headers["X-Real-IP"].ToString();
|
|
if (IPAddress.TryParse(realIp, out var parsedIp))
|
|
{
|
|
remoteIp = parsedIp;
|
|
}
|
|
}
|
|
|
|
if (remoteIp == null)
|
|
{
|
|
_logger.LogWarning("Unable to determine client IP address");
|
|
context.Response.StatusCode = 403;
|
|
await context.Response.WriteAsJsonAsync(new
|
|
{
|
|
error = "Access denied",
|
|
message = "Unable to determine client IP address"
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check if IP is whitelisted
|
|
if (!IsIPWhitelisted(remoteIp))
|
|
{
|
|
_logger.LogWarning("Access denied for IP {IP} to {Path}", remoteIp, context.Request.Path);
|
|
|
|
context.Response.StatusCode = 403;
|
|
await context.Response.WriteAsJsonAsync(new
|
|
{
|
|
error = "Access denied",
|
|
message = "Admin access is restricted to authorized networks",
|
|
clientIp = _options.ShowClientIP ? remoteIp.ToString() : "hidden"
|
|
});
|
|
return;
|
|
}
|
|
|
|
_logger.LogDebug("IP {IP} whitelisted for {Path}", remoteIp, context.Request.Path);
|
|
await _next(context);
|
|
}
|
|
|
|
private bool IsIPWhitelisted(IPAddress clientIp)
|
|
{
|
|
// Localhost is always allowed
|
|
if (IPAddress.IsLoopback(clientIp))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Check individual IPs
|
|
foreach (var allowedIp in _options.WhitelistedIPs)
|
|
{
|
|
if (IPAddress.TryParse(allowedIp, out var parsed) && clientIp.Equals(parsed))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check CIDR ranges
|
|
foreach (var cidr in _options.WhitelistedCIDRs)
|
|
{
|
|
if (IsInCIDRRange(clientIp, cidr))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private bool IsInCIDRRange(IPAddress clientIp, string cidr)
|
|
{
|
|
try
|
|
{
|
|
var parts = cidr.Split('/');
|
|
if (parts.Length != 2)
|
|
{
|
|
_logger.LogWarning("Invalid CIDR format: {CIDR}", cidr);
|
|
return false;
|
|
}
|
|
|
|
var networkAddress = IPAddress.Parse(parts[0]);
|
|
var prefixLength = int.Parse(parts[1]);
|
|
|
|
// Convert to byte arrays
|
|
var clientBytes = clientIp.GetAddressBytes();
|
|
var networkBytes = networkAddress.GetAddressBytes();
|
|
|
|
// Check if same address family
|
|
if (clientBytes.Length != networkBytes.Length)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Calculate network mask
|
|
var maskBytes = new byte[networkBytes.Length];
|
|
var fullBytes = prefixLength / 8;
|
|
var remainingBits = prefixLength % 8;
|
|
|
|
// Set full bytes to 255
|
|
for (int i = 0; i < fullBytes; i++)
|
|
{
|
|
maskBytes[i] = 255;
|
|
}
|
|
|
|
// Set remaining bits
|
|
if (remainingBits > 0 && fullBytes < maskBytes.Length)
|
|
{
|
|
maskBytes[fullBytes] = (byte)(255 << (8 - remainingBits));
|
|
}
|
|
|
|
// Compare masked addresses
|
|
for (int i = 0; i < clientBytes.Length; i++)
|
|
{
|
|
if ((clientBytes[i] & maskBytes[i]) != (networkBytes[i] & maskBytes[i]))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error checking CIDR range {CIDR}", cidr);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configuration options for IP whitelist middleware
|
|
/// </summary>
|
|
public class IPWhitelistOptions
|
|
{
|
|
public const string SectionName = "IPWhitelist";
|
|
|
|
/// <summary>
|
|
/// Enable IP whitelist enforcement
|
|
/// </summary>
|
|
public bool Enabled { get; set; } = false;
|
|
|
|
/// <summary>
|
|
/// Use X-Forwarded-For and X-Real-IP headers (when behind reverse proxy)
|
|
/// </summary>
|
|
public bool UseForwardedHeaders { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Show client IP in error response (for debugging)
|
|
/// </summary>
|
|
public bool ShowClientIP { get; set; } = false;
|
|
|
|
/// <summary>
|
|
/// List of whitelisted IP addresses
|
|
/// </summary>
|
|
public List<string> WhitelistedIPs { get; set; } = new()
|
|
{
|
|
"127.0.0.1",
|
|
"::1"
|
|
};
|
|
|
|
/// <summary>
|
|
/// List of whitelisted CIDR ranges
|
|
/// </summary>
|
|
public List<string> WhitelistedCIDRs { get; set; } = new()
|
|
{
|
|
"192.168.0.0/16", // Private network Class C
|
|
"10.0.0.0/8", // Private network Class A
|
|
"172.16.0.0/12" // Private network Class B
|
|
};
|
|
}
|