Firewall
The SimpleW.Service.Firewall package provides an application-level firewall module for SimpleW. It runs as middleware and reads SimpleW handler metadata for route-specific rules.
Features
- Allow or deny requests by client IP or CIDR
- Allow or deny requests by client country with optional MaxMind GeoIP2
- Configure global rules at installation and update them while the server is running
- Declare handler-specific rules with attributes
- Apply fixed-window or sliding-window rate limits
- Bypass rate limits for trusted IP/CIDR clients
- Clean internal per-IP state with TTL and hard caps
- Emit optional telemetry
Requirements
- .NET 8.0
- SimpleW (core server)
Installation
dotnet add package SimpleW.Service.FirewallSee the changelog
Configuration options
| Option | Default | Description |
|---|---|---|
AllowRules | [] | Global allow list by IP/CIDR. If not empty, non-matching requests are denied. |
DenyRules | [] | Global deny list by IP/CIDR. |
GlobalRateLimit | null | Global rate limit policy. |
RateLimitWhitelistRules | [] | IP/CIDR rules that bypass global and handler rate limits without bypassing allow or deny rules. |
StateTtl | 10 minutes | Retention for inactive per-IP state. |
MaxTrackedIps | 50000 | Safety cap for tracked IP entries. |
CleanupEveryNRequests | 10000 | Opportunistic cleanup frequency. |
EnableTelemetry | false | Enables firewall metrics when server telemetry is enabled too. |
MaxMindCountryDbPath | null | Optional MaxMind .mmdb country database path. |
TreatUnknownCountryAsMatchable | true | Lets unknown countries match CountryRule.Unknown() and unknown-country attributes. |
CountryCacheTtl | null | IP to country cache duration. Uses StateTtl when null. |
AllowCountries | [] | Global allow list by ISO2 country. If not empty, non-matching requests are denied. |
DenyCountries | [] | Global deny list by ISO2 country. |
Minimal example
using System.Net;
using SimpleW;
using SimpleW.Service.Firewall;
var server = new SimpleWServer(IPAddress.Any, 8080);
server.UseFirewallModule(options => {
options.AllowRules.Add(IpRule.Cidr("10.0.0.0/8"));
options.AllowRules.Add(IpRule.Single("127.0.0.1"));
});
server.MapController<ApiController>("/api");
await server.RunAsync();
[Route("/admin")]
public sealed class ApiController : Controller
{
[FirewallAllowIp("192.168.1.0/24")]
[FirewallRateLimit(20, 10, FirewallRateLimitWindowUnit.Seconds)]
[Route("GET", "/dashboard")]
public object Dashboard()
{
return new { ok = true };
}
}Runtime Updates
Install the firewall once with UseFirewallModule(...). After that, including after the server has started, retrieve the attached runtime instance with GetFirewall() and update its global options:
IFirewall firewall = server.GetFirewall();
IpRule temporaryBlock = IpRule.Single("203.0.113.10");
firewall.Update(options => {
options.DenyRules.Add(temporaryBlock);
});
firewall.Update(options => {
options.DenyRules.RemoveAll(rule => rule == temporaryBlock);
});GetFirewall() is also available from HttpSession and Controller. All three accessors return the same IFirewall instance attached to the current server.
| Context | Accessor |
|---|---|
| Server | server.GetFirewall() |
| Request handler | session.GetFirewall() |
| Controller method | this.GetFirewall() |
Each call to Update(...) starts from a copy of the current global options. Properties that are not changed by the callback keep their existing values. The replacement is validated and then applied atomically.
- Concurrent updates are serialized, so successful changes are not lost.
- If the callback throws, the active configuration is unchanged.
- If validation fails, the active configuration is unchanged.
- Requests already in progress continue with the configuration they captured; subsequent requests use the replacement.
- Controller and method attributes are static and are not modified by
Update(...).
All global options can be changed through the same API, not only allow and deny rules:
firewall.Update(options => {
options.GlobalRateLimit = new RateLimitOptions {
Limit = 200,
Window = TimeSpan.FromMinutes(1),
SlidingWindow = true
};
options.CountryCacheTtl = TimeSpan.FromMinutes(30);
});Changing only allow, deny, or country rules preserves rate-limit counters and the GeoIP cache. Changing the global rate-limit policy resets its counters. Changing the GeoIP database path or effective cache TTL clears the country cache and reloads the reader safely.
Calling GetFirewall() before installing the module throws InvalidOperationException. Calling UseFirewallModule(...) a second time on the same server also throws InvalidOperationException; use GetFirewall().Update(...) for every runtime change.
Handler Attributes
Handler-specific rules use attributes implementing SimpleW IHandlerMetadata.
| Attribute | Description |
|---|---|
[FirewallAllowIp("192.168.1.0/24", "127.0.0.1")] | Allows matching IP/CIDR rules. If present, non-matching requests are denied. |
[FirewallDenyIp("203.0.113.10")] | Denies matching IP/CIDR rules. |
[FirewallAllowCountry("FR", "BE")] | Allows matching resolved countries. If present, non-matching requests are denied. |
[FirewallDenyCountry("RU", "CN")] | Denies matching resolved countries. |
[FirewallAllowUnknownCountry] | Allows requests whose country cannot be resolved. |
[FirewallDenyUnknownCountry] | Denies requests whose country cannot be resolved. |
[FirewallRateLimit(5, 30, FirewallRateLimitWindowUnit.Seconds, slidingWindow: true)] | Declares a handler rate limit. |
Attributes can be used on controller classes, controller methods, and non-inline delegate methods. Inline lambdas cannot be decorated with C# attributes.
Rule Resolution
Global rules apply when a handler has no firewall metadata.
When a handler has at least one firewall attribute:
- handler allow/deny/country rules replace global allow/deny/country rules
- handler rate limit replaces
GlobalRateLimit - if no handler rate limit is declared,
GlobalRateLimitis still used
This mirrors the old path-override model: repeat global allow rules on the handler when the handler should keep the same allow list.
Decision order:
- missing client IP returns
403 - deny IP rules return
403 - deny country rules return
403 - allow rules miss returns
403 - rate-limit whitelist skips rate limiting for matching IP/CIDR rules
- rate limit returns
429 - otherwise the request continues
IP Rules
server.UseFirewallModule(options => {
options.DenyRules.Add(IpRule.Single("203.0.113.42"));
options.AllowRules.Add(IpRule.Cidr("192.168.1.0/24"));
});Supported formats:
- single IPv4 or IPv6 address
- IPv4 or IPv6 CIDR notation
Attributes also accept either format:
[FirewallAllowIp("10.0.0.0/8", "127.0.0.1")]Country Rules
Country filtering requires a MaxMind country database to resolve real countries. If no database is configured, the country is unresolved. An IP absent from the configured database is also treated as unresolved. Database and lookup failures are propagated as 500 Internal Server Error; they are not treated as unknown countries.
server.UseFirewallModule(options => {
options.MaxMindCountryDbPath = "/app/data/GeoLite2-Country.mmdb";
options.DenyCountries.Add(CountryRule.Any("RU", "CN"));
});Handler-level country rules:
[FirewallAllowCountry("FR", "BE", "CH")]
[Route("GET", "/billing")]
public object Billing()
{
return new { ok = true };
}Unknown country behavior:
options.TreatUnknownCountryAsMatchable = true; // defaultWhen enabled, unknown countries can match:
options.DenyCountries.Add(CountryRule.Unknown());or:
[FirewallDenyUnknownCountry]Country lookups are cached per IP. Set CountryCacheTtl to tune cache duration.
Rate Limiting
Global rate limit:
server.UseFirewallModule(options => {
options.GlobalRateLimit = new RateLimitOptions {
Limit = 100,
Window = TimeSpan.FromSeconds(10),
SlidingWindow = false
};
// Trusted internal clients bypass both global and handler rate limits.
options.RateLimitWhitelistRules.Add(IpRule.Single("127.0.0.1"));
options.RateLimitWhitelistRules.Add(IpRule.Cidr("10.0.0.0/8"));
});Handler rate limit:
[FirewallRateLimit(5, 30, FirewallRateLimitWindowUnit.Seconds, slidingWindow: true)]
[Route("POST", "/login")]
public object Login()
{
return new { ok = true };
}Fixed window is faster and uses less memory. Sliding window is more precise and better for sensitive endpoints. Rate-limit state is keyed by client IP. RateLimitWhitelistRules only skips rate limiting; matching clients still go through deny rules, country rules, and allow-list checks.
Client IP Resolution
The firewall uses:
session.ClientIpAddressFor reverse proxies, configure SimpleW explicitly:
server.ConfigureClientIPResolver(session => {
if (session.Request.Headers.TryGetValue("X-Real-IP", out string? value)
&& IPAddress.TryParse(value, out IPAddress? forwardedIp)) {
return forwardedIp;
}
if (session.RemoteEndPoint is not IPEndPoint endpoint) {
return null;
}
return endpoint.Address;
});Only trust forwarded IP headers from trusted proxy infrastructure.
Static Files And Fallbacks
PathRule / PathRules has been removed.
Static files and fallback routes usually do not carry custom attributes directly, so protect them with global firewall rules. If a fallback or static-like handler is mapped through a decorated method, handler metadata works normally.
Keep The MaxMind Database Up To Date
The SimpleW.Service.Background module can download and activate a fresh MaxMind database on a recurring schedule. Install it alongside the firewall package:
dotnet add package SimpleW.Service.BackgroundThe following example runs every day at 03:00 UTC. Each update uses a new maxmind db file.
server.UseFirewallModule(options => {
options.MaxMindCountryDbPath = "/app/data/GeoLite2-Country-20260823.mmdb";
});
server.UseBackgroundModule(options => {
options.Schedule(
"refresh-maxmind-country-database",
"0 3 * * *", // Every day at 03:00 UTC by default.
async ctx => {
// get the .mmdb
string newDatabase = "/app/data/GeoLite2-Country-20260824.mmdb";
// update firewall
server.GetFirewall().Update(firewallOptions => {
firewallOptions.MaxMindCountryDbPath = newDatabase;
});
},
cron => {
cron.AllowConcurrentExecutions = false;
cron.JobOptions.Timeout = TimeSpan.FromMinutes(10);
cron.JobOptions.RetryCount = 2;
}
);
});Telemetry
Enable telemetry:
server.UseFirewallModule(options => {
options.EnableTelemetry = true;
});The underlying SimpleWServer telemetry must also be enabled.
Metrics:
simplew.firewall.decision.countsimplew.firewall.block.countsimplew.firewall.ratelimit.countsimplew.firewall.match.allow.countsimplew.firewall.match.deny.countsimplew.firewall.match.allow_miss.countsimplew.firewall.decision.durationsimplew.firewall.tracked_ips.fixedsimplew.firewall.tracked_ips.slidingsimplew.firewall.tracked_ips.totalsimplew.firewall.tracked_ips.country_cachesimplew.firewall.rules.allow.globalsimplew.firewall.rules.deny.globalsimplew.firewall.rules.allow_countries.globalsimplew.firewall.rules.deny_countries.global
Decision tags use low-cardinality values such as result, reason, scope, and window.
HTTP Responses
403 Forbiddenfor missing IP, deny matches, and allow misses429 Too Many Requestsfor rate limiting
Requests are rejected before controller execution.