From c04de33af25dc8fa73091a9608ac3405a76fa391 Mon Sep 17 00:00:00 2001 From: John Cardinal Date: Sun, 25 Oct 2026 12:58:01 -0700 Subject: [PATCH] case 4732 - trusted reverse proxies setting New config setting AYANOVA_TRUSTED_PROXIES (IP addresses or CIDR networks) for reverse proxies on another computer: their X-Forwarded-For is used, a chain of trusted proxies is followed, invalid entries are warned at boot. X-Forwarded-For from a proxy that is not trusted logs one warning per server start naming the proxy's address. Without the setting nothing changes (only a loopback proxy is trusted). Shown in server info; manual page, Linux install note, changelog. F6 follow-up recorded. Co-Authored-By: Claude Opus 5.5 --- devdocs/modernization/NET10-PORT.md | 1 + devdocs/modernization/SECURITY-REVIEW.md | 2 + docs/8.0/ayanova/docs/changelog.md | 1 + .../docs/ops-config-environment-variables.md | 1 + .../docs/ops-config-trusted-proxies.md | 56 +++++++++++++++++++ .../ayanova/docs/ops-install-linux-server.md | 2 + docs/8.0/ayanova/mkdocs.yml | 1 + .../Controllers/ServerStateController.cs | 2 + server/AyaNova/Startup.cs | 31 +++++++++- server/AyaNova/util/ServerBootConfig.cs | 4 ++ 10 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 docs/8.0/ayanova/docs/ops-config-trusted-proxies.md diff --git a/devdocs/modernization/NET10-PORT.md b/devdocs/modernization/NET10-PORT.md index 9bd6cb54..077d8217 100644 --- a/devdocs/modernization/NET10-PORT.md +++ b/devdocs/modernization/NET10-PORT.md @@ -195,6 +195,7 @@ John's "next release" cases for AyaNova (exported 2026-10-24). Each is its own s | 4726 | Login throttling/lockout (raven and sockeye) | F6. **Done**; the Sockeye half (`6d6c74f`), like 4710's, takes effect when Sockeye is deployed to its droplet (10 failures per login or 50 per address in 15 minutes, then 429 for the rest of the window; same in Sockeye, whose login is public; test `LoginLockout`; per-address limit proved with a forwarded documentation address) | | 4727 | Low severity hardening | F10, F11, F12, F22, F23, F25, F29, F35, F36, F38; the AyaNova half of F39 (DB ID not shown anonymously). In steps: 1 logo (F23) **done**; 2 API root page (F11, F39) **done**; 3 `server-info` (F29) **done**; 4 template export (F22) **done**, job owner check accepted; 5 `OverrideUserId` and export download (F25) **done**; 6 safe headers (F35) **done**; 7 remove `dist/docker` (F38) **done**. All steps done; outside this repo, for `sockeye-client`: bind `127.0.0.1`, the `chmod`, the systemd options (F36, F38) | | 4681 | Sockeye: licence/trial request hardening (tagged sockeye, rockfish) | F40. **Done** (sockeye `cd3ebed`: rate limits, email check, cryptographic codes; rvf unchanged) | +| 4732 | Trusted reverse proxies setting (from the Sockeye migration runbook, Appendix A) | F6 follow-up. **Done** (`AYANOVA_TRUSTED_PROXIES`: addresses or CIDR networks, chain followed, invalid entries warned at boot; one log warning per start when `X-Forwarded-For` comes from an untrusted proxy; manual page and changelog. Proved by hand through the LAN address: not set, logins recorded as the proxy and the warning logged; set, the forwarded client address used, a forged extra entry ignored, a two-proxy chain followed; localhost unchanged. Not needed in Sockeye: its nginx connects on loopback) | **From the port and earlier triage** diff --git a/devdocs/modernization/SECURITY-REVIEW.md b/devdocs/modernization/SECURITY-REVIEW.md index 46e261ee..37f9a99c 100644 --- a/devdocs/modernization/SECURITY-REVIEW.md +++ b/devdocs/modernization/SECURITY-REVIEW.md @@ -308,6 +308,8 @@ as password). Fix options: count failures per login and per temp token with a sh **Fixed (2026-10-24).** 2FA: case 4710 (5 wrong codes clear the temporary token). Password login: case 4726, `util/LoginThrottle.cs` in AyaNova and Sockeye (Sockeye's login is public too; only SSH to its droplet is limited to the office address). Failures are counted in memory: 10 per login name or 50 per client address within 15 minutes, then attempts are refused with 429 "Too many failed login attempts" for the rest of the window, without checking the password; a correct password clears the login's count; unknown logins are counted the same way. Loopback is not counted per address (the hosted nginx and IIS pass the real client address through the forwarded headers, which are trusted from loopback only). Each lockout is logged as a warning. Accepted: an attacker can keep a known login locked by failing 10 times every 15 minutes. Proved in both servers, the per-address limit through `X-Forwarded-For` with a documentation address; test `LoginLockout`. The password rule and nginx `limit_req` are in Deferred in `NET10-PORT.md`. +**Follow-up, case 4732 (2026-10-25).** The per-address count relies on `UseForwardedHeaders`, which by default trusts only a loopback proxy. Behind a proxy on another computer every user appeared as the proxy's address and shared one count of 50, so one person could lock everyone out. New setting `AYANOVA_TRUSTED_PROXIES` (addresses or CIDR networks) adds trusted proxies and follows a chain of them; when `X-Forwarded-For` arrives from a proxy that is not trusted, the log warns once per server start. Default behaviour unchanged. + ### F7 Password change does not end sessions; download key loses its expiry (pass 2) `UserBiz.ChangePasswordAsync` (used by change-password and reset-password) does not replace diff --git a/docs/8.0/ayanova/docs/changelog.md b/docs/8.0/ayanova/docs/changelog.md index 09524f5f..c99610bf 100644 --- a/docs/8.0/ayanova/docs/changelog.md +++ b/docs/8.0/ayanova/docs/changelog.md @@ -28,6 +28,7 @@ IN PROGRESS - Server: removed an unused internal performance profiling component. - Server: AyaNova now checks the email (SMTP) server's security certificate when sending email. If your mail server uses a self-signed or internal certificate and email notifications stop after upgrading, see [SMTP certificate check](ops-config-smtp-accept-any-certificate.md). - Server: after repeated failed login attempts, further attempts for that login, or from that address, are refused for a few minutes. +- Server: new [trusted reverse proxies](ops-config-trusted-proxies.md) setting for a reverse proxy that runs on a different computer from the AyaNova server, so AyaNova sees each user's own address. - Server: the server information page at `/api/v8/` no longer shows license details. - Server: business logo uploads must be PNG, JPEG, GIF, WebP or SVG images, and need full rights to global settings. See [business logos](adm-global-logo.md). - Server: when a user's password is reset or set by an administrator, or their login is disabled, they are signed out and must sign in again. diff --git a/docs/8.0/ayanova/docs/ops-config-environment-variables.md b/docs/8.0/ayanova/docs/ops-config-environment-variables.md index 28dd0a0f..d6b4f022 100644 --- a/docs/8.0/ayanova/docs/ops-config-environment-variables.md +++ b/docs/8.0/ayanova/docs/ops-config-environment-variables.md @@ -102,3 +102,4 @@ Command line parameters are appended to the command to start the ayanova server, ### SECURITY - [AYANOVA_SET_SUPERUSER_PW](ops-config-set-superuser-pw.md) +- [AYANOVA_TRUSTED_PROXIES](ops-config-trusted-proxies.md) diff --git a/docs/8.0/ayanova/docs/ops-config-trusted-proxies.md b/docs/8.0/ayanova/docs/ops-config-trusted-proxies.md new file mode 100644 index 00000000..4b91e75f --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-config-trusted-proxies.md @@ -0,0 +1,56 @@ +# Trusted reverse proxies setting + +When AyaNova is reached through a reverse proxy server (for example NGINX, Apache, IIS, a load balancer or a cloud service in front of AyaNova), every connection AyaNova receives comes from the proxy, not from the user's computer. The proxy passes along the user's real address in an `X-Forwarded-For` header. + +AyaNova uses the user's address to limit repeated failed login attempts and to record where logins come from in the [server log](ops-log.md). Because anyone can add an `X-Forwarded-For` header to a request, AyaNova only accepts it from a proxy it trusts. + +A reverse proxy running on the **same computer** as the AyaNova server and connecting to it through `localhost` or `127.0.0.1` is always trusted. This is how our [Linux server](ops-install-linux-server.md) and [Windows IIS](ops-install-windows-iis.md) installation instructions set it up, and nothing needs to be done. + +## When this setting is needed + +If the reverse proxy runs on a **different computer** from the AyaNova server, AyaNova does not trust it by default. All users then appear to come from the proxy's address, and they share a single limit on failed login attempts: repeated failed logins by one person can then refuse logins for everyone connecting through the proxy for a few minutes. + +When this happens, the AyaNova server log records a warning once after each server start, naming the address of the proxy: + +`A request came through a reverse proxy at 10.0.0.5 that is not trusted...` + +If that address is your reverse proxy, add it to this setting. + +## Default + +If this setting is not specified only a reverse proxy on the same computer is trusted. + +## Overriding + +AyaNova expects this value to be provided by a config.json property, environment variable or command line parameter named + +`AYANOVA_TRUSTED_PROXIES` + +The value is a list of the IP addresses of your reverse proxies, separated by commas. A whole network can be given in CIDR notation, for example `10.0.0.0/24` for the addresses `10.0.0.0` to `10.0.0.255`. If there is more than one proxy in a chain (for example a load balancer in front of a reverse proxy), list each of them. + +Only list addresses of your own proxy servers. Any computer listed here can tell AyaNova a user's address, so never list an address range that ordinary users connect from. + +An entry that is not a valid IP address or network is ignored and the AyaNova server log records a warning when the server starts. + +Example config.json entry + +```json +{ + ...other properties... + "AYANOVA_TRUSTED_PROXIES": "10.0.0.5, 10.0.1.0/24" +} +``` + +Example command line parameter + +`ayanova.exe --AYANOVA_TRUSTED_PROXIES="10.0.0.5, 10.0.1.0/24"` + +Example environment variable + +Windows + +`set "AYANOVA_TRUSTED_PROXIES=10.0.0.5, 10.0.1.0/24"` + +Linux + +`export AYANOVA_TRUSTED_PROXIES="10.0.0.5, 10.0.1.0/24"` diff --git a/docs/8.0/ayanova/docs/ops-install-linux-server.md b/docs/8.0/ayanova/docs/ops-install-linux-server.md index c9bd169c..d9256c73 100644 --- a/docs/8.0/ayanova/docs/ops-install-linux-server.md +++ b/docs/8.0/ayanova/docs/ops-install-linux-server.md @@ -323,6 +323,8 @@ These instructions will assume use of a free SSL certificate from [LetsEncrypt]( We will provide instructions for using the free and open source [NGINX](https://nginx.org/) as a reverse proxy server, if you already have another server capable of being a reverse proxy (such as Apache) you will need to adapt these instructions to match your situation. +These instructions run the reverse proxy on the same server as AyaNova. If your reverse proxy runs on a different computer, see [trusted reverse proxies](ops-config-trusted-proxies.md). + #### A\. Install NGINX Install NGINX to be the reverse proxy server: diff --git a/docs/8.0/ayanova/mkdocs.yml b/docs/8.0/ayanova/mkdocs.yml index bd6aad38..f90afa82 100644 --- a/docs/8.0/ayanova/mkdocs.yml +++ b/docs/8.0/ayanova/mkdocs.yml @@ -200,6 +200,7 @@ nav: - "SMTP certificate check": "ops-config-smtp-accept-any-certificate.md" - "Security and JWT web tokens": "ops-config-jwt-secret.md" - "SuperUser password reset": "ops-config-set-superuser-pw.md" + - "Trusted reverse proxies": "ops-config-trusted-proxies.md" - "Server error codes": "ops-error-codes.md" - "Migrating from AyaNova 7": "ops-import-v7.md" - "Metrics": "ops-metrics.md" diff --git a/server/AyaNova/Controllers/ServerStateController.cs b/server/AyaNova/Controllers/ServerStateController.cs index 4e39b1ee..7c07d7d9 100644 --- a/server/AyaNova/Controllers/ServerStateController.cs +++ b/server/AyaNova/Controllers/ServerStateController.cs @@ -194,6 +194,7 @@ namespace AyaNova.Api.Controllers AYANOVA_LOG_LEVEL = ServerBootConfig.AYANOVA_LOG_LEVEL, AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG = ServerBootConfig.AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG, AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE = ServerBootConfig.AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE, + AYANOVA_TRUSTED_PROXIES = ServerBootConfig.AYANOVA_TRUSTED_PROXIES, serverinfo = ServerBootConfig.BOOT_DIAGNOSTIC_INFO, dbserverinfo = ServerBootConfig.DBSERVER_DIAGNOSTIC_INFO })); @@ -317,6 +318,7 @@ namespace AyaNova.Api.Controllers sbRet.AppendLine($"AYANOVA_LOG_LEVEL: {ServerBootConfig.AYANOVA_LOG_LEVEL}"); sbRet.AppendLine($"AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG: {ServerBootConfig.AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG}"); sbRet.AppendLine($"AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE: {ServerBootConfig.AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE}"); + sbRet.AppendLine($"AYANOVA_TRUSTED_PROXIES: {ServerBootConfig.AYANOVA_TRUSTED_PROXIES}"); sbRet.AppendLine("#########################################################"); sbRet.AppendLine("SERVER DIAGNOSTICS"); diff --git a/server/AyaNova/Startup.cs b/server/AyaNova/Startup.cs index eab543ec..d5a737d3 100644 --- a/server/AyaNova/Startup.cs +++ b/server/AyaNova/Startup.cs @@ -40,6 +40,7 @@ namespace AyaNova } private readonly ILogger _newLog; + private static int _untrustedProxyWarned = 0; private string _connectionString = ""; private readonly Microsoft.AspNetCore.Hosting.IWebHostEnvironment _hostingEnvironment; @@ -325,9 +326,37 @@ namespace AyaNova ServiceProviderProvider.Provider = app.ApplicationServices; //Enable ability to handle reverse proxy - app.UseForwardedHeaders(new ForwardedHeadersOptions + //case 4732: a proxy on this computer (loopback) is always trusted; proxies on other computers only when named in AYANOVA_TRUSTED_PROXIES + var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto + }; + if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_TRUSTED_PROXIES)) + { + foreach (var entry in ServerBootConfig.AYANOVA_TRUSTED_PROXIES.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (entry.Contains('/') && System.Net.IPNetwork.TryParse(entry, out var network)) + forwardedHeadersOptions.KnownIPNetworks.Add(network); + else if (!entry.Contains('/') && System.Net.IPAddress.TryParse(entry, out var address)) + forwardedHeadersOptions.KnownProxies.Add(address); + else + _newLog.LogWarning($"AYANOVA_TRUSTED_PROXIES entry \"{entry}\" is not an IP address or network and is ignored"); + } + //follow a chain of trusted proxies (e.g. a load balancer behind another proxy) back to the first address that is not trusted + forwardedHeadersOptions.ForwardLimit = null; + } + app.UseForwardedHeaders(forwardedHeadersOptions); + + //case 4732: X-Forwarded-For from a proxy that is not trusted is ignored (no X-Original-For is set), so every user appears to come + //from the proxy's address and shares one failed login limit; say so once in the log + app.Use(async (context, next) => + { + if (_untrustedProxyWarned == 0 + && context.Request.Headers.ContainsKey("X-Forwarded-For") + && !context.Request.Headers.ContainsKey("X-Original-For") + && System.Threading.Interlocked.Exchange(ref _untrustedProxyWarned, 1) == 0) + _newLog.LogWarning($"A request came through a reverse proxy at {(context.Connection.RemoteIpAddress.IsIPv4MappedToIPv6 ? context.Connection.RemoteIpAddress.MapToIPv4() : context.Connection.RemoteIpAddress)} that is not trusted, so users connecting through it all appear to come from that address and share one limit on failed login attempts. If this is your reverse proxy, add its address to the AYANOVA_TRUSTED_PROXIES setting"); + await next.Invoke(); }); diff --git a/server/AyaNova/util/ServerBootConfig.cs b/server/AyaNova/util/ServerBootConfig.cs index 29a33a41..d2dec973 100644 --- a/server/AyaNova/util/ServerBootConfig.cs +++ b/server/AyaNova/util/ServerBootConfig.cs @@ -107,6 +107,8 @@ namespace AyaNova.Util //SECURITY internal static string AYANOVA_SET_SUPERUSER_PW { get; set; } + //case 4732: reverse proxies on other computers whose X-Forwarded-For is trusted (addresses or networks, comma separated) + internal static string AYANOVA_TRUSTED_PROXIES { get; set; } //EMAIL //case 4724: the SMTP server's certificate is checked unless this is set (a mail server with a self-signed or internal certificate) @@ -189,6 +191,8 @@ namespace AyaNova.Util //backdoor back door password superuser reset AYANOVA_SET_SUPERUSER_PW = config.GetValue("AYANOVA_SET_SUPERUSER_PW"); + AYANOVA_TRUSTED_PROXIES = config.GetValue("AYANOVA_TRUSTED_PROXIES"); + //REPORT RENDERING //RENDER OVERRIDE URL FOR CORS ISSUES BEHIND IIS (case 4398)