diff --git a/.gitignore b/.gitignore
index 5bf25ccac6..ac86e8d3b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -101,3 +101,10 @@ src/scaffolding.config
# Visual Studio Code
.vscode
+
+# AI config
+.claude/
+CLAUDE.md
+
+# User-specific files
+.local
\ No newline at end of file
diff --git a/README.md b/README.md
index 2927d8dcf6..7c0837011a 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ It's also possible to [locally test containers built from PRs in GitHub Containe
### Infrastructure setup
If the instance is executed for the first time, it must set up the required infrastructure. To do so, once the instance is configured to use the selected transport and persister, run it in setup mode. This can be done by using the `Setup {instance name}` launch profile that is defined in
-the `launchSettings.json` file of each instance. When started in setup mode, the instance will start as usual, execute the setup process, and exit. At this point the instance can be run normally by using the non-setup launch profile.
+the `launchSettings.json` file of each instance. When started in setup mode, the instance will start as usual, execute the setup process, and exit. At this point the instance can be run normally by using the non-setup launch profile.
## Secrets
@@ -56,6 +56,21 @@ Running all tests all the times takes a lot of resources. Tests are filtered bas
NOTE: If no variable is defined all tests will be executed.
+## Security Configuration
+
+Documentation for configuring security features:
+
+- [HTTPS Configuration](docs/https-configuration.md) - Configure HTTPS/TLS for secure connections
+- [Forwarded Headers](docs/forwarded-headers.md) - Configure X-Forwarded-* header handling for reverse proxy scenarios
+- [Authentication](docs/authentication.md) - Configure authentication for the HTTP API
+
+Local testing guides:
+
+- [HTTPS Testing](docs/https-testing.md)
+- [Reverse Proxy Testing](docs/reverseproxy-testing.md)
+- [Forward Headers Testing](docs/forward-headers-testing.md)
+- [Authentication Testing](docs/authentication-testing.md)
+
## How to developer test the PowerShell Module
Steps:
diff --git a/docs/authentication-testing.md b/docs/authentication-testing.md
new file mode 100644
index 0000000000..91c6a1f91f
--- /dev/null
+++ b/docs/authentication-testing.md
@@ -0,0 +1,863 @@
+# Local Testing Authentication
+
+This guide explains how to test authentication configuration for ServiceControl instances. This approach uses curl to test authentication enforcement and configuration endpoints.
+
+## Prerequisites
+
+- ServiceControl built locally (see main README for build instructions)
+- **HTTPS configured** - Authentication should only be used over HTTPS. Configure HTTPS using one of the methods described in [HTTPS Configuration](https-configuration.md) before testing authentication scenarios.
+- **Identity Provider (IdP) configured** - For real authentication testing (Scenarios 7+), you need an OIDC provider configured with:
+ - An API application registration (for ServiceControl)
+ - A client application registration (for ServicePulse)
+ - API scopes configured and permissions granted
+ - See [Authentication Configuration](authentication.md#configuring-identity-providers) for setup instructions
+- curl (included with Windows 10/11, Git Bash, or WSL)
+- (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json`
+
+## Enabling Debug Logs
+
+To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance:
+
+```cmd
+rem ServiceControl Primary
+set SERVICECONTROL_LOGLEVEL=Debug
+
+rem ServiceControl.Audit
+set SERVICECONTROL_AUDIT_LOGLEVEL=Debug
+
+rem ServiceControl.Monitoring
+set MONITORING_LOGLEVEL=Debug
+```
+
+**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`)
+
+Debug logs will show detailed authentication flow information including token validation, claims processing, and authorization decisions.
+
+## Instance Reference
+
+| Instance | Project Directory | Default Port | Environment Variable Prefix |
+|---------------------------|---------------------------------|--------------|-----------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` |
+
+## How Authentication Works
+
+When authentication is enabled:
+
+1. All API requests must include a valid JWT bearer token in the `Authorization` header
+2. ServiceControl validates the token against the configured OIDC authority
+3. Requests without a valid token receive a `401 Unauthorized` response
+4. The `/api/authentication/configuration` endpoint returns authentication configuration for clients (like ServicePulse)
+
+## Configuration Methods
+
+Settings can be configured via:
+
+1. **Environment variables** (recommended for testing) - Easy to change between scenarios, no file edits needed
+2. **App.config** - Persisted settings, requires app restart after changes
+
+Both methods work identically. This guide uses environment variables for convenience during iterative testing.
+
+## Test Scenarios
+
+The following scenarios use ServiceControl (Primary) as an example. To test other instances, use the appropriate environment variable prefix and port.
+
+> **Important:** Set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session.
+>
+> **Tip:** Check the application startup logs to verify which settings were applied. The authentication configuration is logged at startup.
+
+### Scenario 1: Authentication Disabled (Default)
+
+Test the default behavior where authentication is disabled and all requests are allowed.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Test with curl (no authorization header):**
+
+```cmd
+curl http://localhost:33333/api | json
+```
+
+**Expected output:**
+
+```json
+{
+ "description": "The management backend for the Particular Service Platform",
+ ...
+}
+```
+
+Requests succeed without authentication because `Authentication.Enabled` defaults to `false`.
+
+**Check authentication configuration endpoint:**
+
+```cmd
+curl http://localhost:33333/api/authentication/configuration | json
+```
+
+**Expected output:**
+
+```json
+{
+ "enabled": false
+}
+```
+
+The configuration indicates authentication is disabled. Other fields are omitted when null.
+
+### Scenario 2: Authentication Enabled (No Token)
+
+Test that requests without a token are rejected when authentication is enabled.
+
+> **Note:** This scenario requires a valid OIDC authority URL. For testing authentication enforcement without a real provider, you can use any HTTP URL - the request will fail before token validation because no token is provided.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Test with curl (no authorization header):**
+
+```cmd
+curl -v http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 401 Unauthorized
+```
+
+Requests without a token are rejected with `401 Unauthorized`.
+
+> **Note:** The `/api` root endpoint and `/api/authentication/configuration` are marked as anonymous and will return 200 OK even with authentication enabled. Test protected endpoints like `/api/endpoints` to verify authentication enforcement.
+
+**Check authentication configuration endpoint (no auth required):**
+
+```cmd
+curl http://localhost:33333/api/authentication/configuration | json
+```
+
+**Expected output:**
+
+```json
+{
+ "enabled": true,
+ "clientId": "test-client-id",
+ "audience": "api://servicecontrol-test",
+ "apiScopes": "[\"api://servicecontrol-test/.default\"]"
+}
+```
+
+The authentication configuration endpoint is accessible without authentication and returns the configuration that clients need to authenticate. The `authority` field is omitted when `ServicePulse.Authority` is not explicitly set (it defaults to the main Authority for ServicePulse clients).
+
+### Scenario 3: Authentication with Invalid Token
+
+Test that requests with an invalid token are rejected.
+
+**Start ServiceControl with authentication enabled (same as Scenario 2):**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Test with curl (invalid token):**
+
+```cmd
+curl -v -H "Authorization: Bearer invalid-token-here" http://localhost:33333/api/endpoints 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 401 Unauthorized
+```
+
+Invalid tokens are rejected with `401 Unauthorized`.
+
+### Scenario 4: Anonymous Endpoints
+
+Test that anonymous endpoints remain accessible when authentication is enabled.
+
+**With ServiceControl still running from Scenario 2 or 3, test anonymous endpoints:**
+
+```cmd
+curl http://localhost:33333/api | json
+```
+
+**Expected output:**
+
+```json
+{
+ "description": "The management backend for the Particular Service Platform",
+ ...
+}
+```
+
+```cmd
+curl http://localhost:33333/api/authentication/configuration | json
+```
+
+**Expected output:**
+
+```json
+{
+ "enabled": true,
+ "clientId": "test-client-id",
+ "audience": "api://servicecontrol-test",
+ "apiScopes": "[\"api://servicecontrol-test/.default\"]"
+}
+```
+
+The following endpoints are marked as anonymous and accessible without authentication:
+
+| Endpoint | Purpose |
+|-------------------------------------|---------------------------------------------------|
+| `/api` | API root/discovery - returns available endpoints |
+| `/api/authentication/configuration` | Returns auth config for clients like ServicePulse |
+
+### Scenario 5: Validation Settings Warnings
+
+Test that disabling validation settings produces warnings in the logs.
+
+**Start ServiceControl with relaxed validation:**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/common/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=false
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=false
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Expected log output:**
+
+```text
+warn: Authentication.ValidateIssuer is set to false. This is not recommended for production environments...
+warn: Authentication.ValidateAudience is set to false. This is not recommended for production environments...
+```
+
+The application warns about insecure validation settings.
+
+### Scenario 6: Missing Required Settings
+
+Test that missing required settings prevent startup.
+
+**Start ServiceControl with missing authority:**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol-test
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=test-client-id
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol-test/.default"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Expected behavior:**
+
+The application fails to start with an error message:
+
+```text
+Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL...
+```
+
+### Scenario 7: Authentication with Valid Token (Real Identity Provider)
+
+Test end-to-end authentication with a valid token from a real OIDC provider.
+
+> **Prerequisites:** This scenario requires a configured OIDC provider (e.g., Microsoft Entra ID, Auth0, Okta).
+
+**Microsoft Entra ID Setup (one-time):**
+
+1. **Create an App Registration** for ServiceControl API:
+ - Go to Azure Portal > Microsoft Entra ID > App registrations
+ - Create a new registration (e.g., "ServiceControl API")
+ - Note the Application (client) ID and Directory (tenant) ID
+ - Under "Expose an API", add a scope (e.g., `access_as_user`)
+
+2. **Create an App Registration** for testing (or use ServicePulse's):
+ - Create another registration for the client application
+ - Under "API permissions", add permission to your ServiceControl API scope
+ - Under "Authentication", enable "Allow public client flows" for testing
+
+**Start ServiceControl with your Entra ID configuration:**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Get a test token using Azure CLI:**
+
+```cmd
+az login
+az account get-access-token --resource api://servicecontrol --query accessToken -o tsv
+```
+
+**Test with the token:**
+
+```cmd
+curl -H "Authorization: Bearer {token}" http://localhost:33333/api/endpoints | json
+```
+
+**Expected output:**
+
+```json
+[]
+```
+
+Requests with a valid token are processed successfully. The response will be an empty array if no endpoints are registered, or a list of endpoints if data exists.
+
+## Multi-Instance Scenarios
+
+The following scenarios test authentication behavior when the primary instance communicates with remote Audit and Monitoring instances.
+
+### Scenario 8: Scatter-Gather with Authentication (Token Forwarding)
+
+Test that the primary instance forwards authentication tokens to remote instances during scatter-gather operations.
+
+> **Background:** When a client queries endpoints like `/api/messages`, the primary instance may query remote Audit instances to aggregate results. The client's authorization token is forwarded to these remote instances.
+
+**Prerequisites:**
+
+- A configured OIDC provider with valid tokens
+- All instances configured with the **same** Authority and Audience settings
+
+**Terminal 1 - Start ServiceControl.Audit with authentication:**
+
+```cmd
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+cd src\ServiceControl.Audit
+dotnet run
+```
+
+**Terminal 2 - Start ServiceControl (Primary) with authentication and remote instance configured:**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}]
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Get a test token and query the primary instance:**
+
+```cmd
+az login
+set TOKEN=$(az account get-access-token --resource api://servicecontrol --query accessToken -o tsv)
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages | json
+```
+
+**How to verify token forwarding is working:**
+
+1. **Check the Audit instance logs (Terminal 1)** - When the request succeeds, you should see log entries showing the authenticated request was processed. Look for request logging that shows the `/api/messages` endpoint was called.
+
+2. **Check the response headers** - The aggregated response includes instance information:
+
+ ```cmd
+ curl -v -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages 2>&1 | findstr /C:"X-Particular"
+ ```
+
+ You should see headers indicating responses were received from remote instances.
+
+3. **Verify by stopping the Audit instance** - Stop the Audit instance and repeat the request. The response should now only contain local data, and the primary instance logs should show the remote is unavailable.
+
+4. **Test direct access to Audit instance** - Verify the Audit instance requires authentication independently:
+
+ ```cmd
+ REM Without token - should fail
+ curl -v https://localhost:44444/api/messages 2>&1 | findstr /C:"HTTP/"
+ REM Expected: < HTTP/1.1 401 Unauthorized
+
+ REM With token - should succeed
+ curl -H "Authorization: Bearer %TOKEN%" https://localhost:44444/api/messages | json
+ REM Expected: [] or list of messages
+ ```
+
+5. **Compare results** - If authentication forwarding is working correctly:
+ - Direct request to Audit with token: succeeds
+ - Direct request to Audit without token: fails with 401
+ - Request through Primary with token: succeeds and includes Audit data
+ - Request through Primary without token: fails with 401
+
+**Test with no token (should fail):**
+
+```cmd
+curl -v https://localhost:33333/api/messages 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 401 Unauthorized
+```
+
+### Scenario 9: Scatter-Gather with Mismatched Authentication Configuration
+
+Test that scatter-gather fails gracefully when remote instances have different authentication settings.
+
+**Terminal 1 - Start ServiceControl.Audit with DIFFERENT audience:**
+
+```cmd
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol-audit-different
+
+cd src\ServiceControl.Audit
+dotnet run
+```
+
+**Terminal 2 - Start ServiceControl (Primary):**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}]
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Query with a valid token for the primary instance:**
+
+```cmd
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages | json
+```
+
+**How to verify the mismatch is detected:**
+
+1. **Check the Audit instance logs (Terminal 1)** - You should see a 401 Unauthorized response logged, with details about the token validation failure (audience mismatch):
+
+ ```text
+ warn: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler
+ Bearer was not authenticated. Failure message: IDX10214: Audience validation failed...
+ ```
+
+2. **Check the Primary instance logs (Terminal 2)** - You should see the remote marked as temporarily unavailable:
+
+ ```text
+ warn: ... Remote instance at https://localhost:44444 returned status code Unauthorized
+ ```
+
+3. **Verify the remote status** - Check the remotes endpoint to confirm the Audit instance is marked as unavailable:
+
+ ```cmd
+ curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/configuration/remotes | json
+ ```
+
+ **Expected output:**
+
+ ```json
+ [
+ {
+ "api_uri": "https://localhost:44444",
+ "status": "unavailable"
+ }
+ ]
+ ```
+
+4. **Confirm direct access fails with the token** - The token is valid for Primary but not for Audit:
+
+ ```cmd
+ REM Direct to Audit - should fail (wrong audience)
+ curl -v -H "Authorization: Bearer %TOKEN%" https://localhost:44444/api/messages 2>&1 | findstr /C:"HTTP/"
+ REM Expected: < HTTP/1.1 401 Unauthorized
+ ```
+
+### Scenario 10: Remote Instance Health Checks with Authentication
+
+Test that the primary instance can check remote instance health when authentication is enabled.
+
+> **Note:** The health check queries the `/api` endpoint on remote instances. This endpoint is marked as anonymous and should be accessible without authentication.
+
+**Start both instances with authentication enabled (same configuration as Scenario 8).**
+
+**Check the remote instances configuration endpoint:**
+
+```cmd
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/configuration/remotes | json
+```
+
+**Expected output:**
+
+```json
+[
+ {
+ "api_uri": "https://localhost:44444",
+ "status": "online",
+ "version": "5.x.x"
+ }
+]
+```
+
+The health check should succeed because `/api` is an anonymous endpoint.
+
+### Scenario 11: Platform Connection Details with Authentication
+
+Test that platform connection details can be retrieved when authentication is enabled on remote instances.
+
+> **Note:** The primary instance queries `/api/connection` on remote instances to aggregate platform connection details. This endpoint may require authentication.
+
+**With both instances running (same as Scenario 8):**
+
+```cmd
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/connection | json
+```
+
+**Expected behavior:**
+
+The platform connection response includes connection details from both the primary and remote instances.
+
+### Scenario 12: Mixed Authentication Configuration (Primary Only)
+
+Test behavior when only the primary instance has authentication enabled, but remote instances do not.
+
+**Terminal 1 - Start ServiceControl.Audit WITHOUT authentication:**
+
+```cmd
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=
+
+cd src\ServiceControl.Audit
+dotnet run
+```
+
+**Terminal 2 - Start ServiceControl (Primary) WITH authentication:**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}]
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Query with a valid token:**
+
+```cmd
+curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/messages | json
+```
+
+**How to verify this mixed configuration works:**
+
+1. **Verify the Audit instance has no authentication** - Direct requests without a token should succeed:
+
+ ```cmd
+ REM Direct to Audit without token - should succeed (no auth required)
+ curl https://localhost:44444/api/messages | json
+ REM Expected: [] or list of messages
+ ```
+
+2. **Verify the Primary instance requires authentication** - Direct requests without a token should fail:
+
+ ```cmd
+ REM Direct to Primary without token - should fail
+ curl -v https://localhost:33333/api/messages 2>&1 | findstr /C:"HTTP/"
+ REM Expected: < HTTP/1.1 401 Unauthorized
+ ```
+
+3. **Check the Audit instance logs (Terminal 1)** - When queried through the Primary, you should see the request processed. The token is present in the request but ignored since authentication is disabled:
+
+ ```text
+ info: ... Processed request GET /api/messages
+ ```
+
+4. **Check the Primary instance logs (Terminal 2)** - You should see successful aggregation from the remote:
+
+ ```text
+ info: ... Successfully retrieved messages from remote https://localhost:44444
+ ```
+
+5. **Verify aggregation works** - The response from Primary should include data from both instances:
+
+ ```cmd
+ curl -H "Authorization: Bearer %TOKEN%" https://localhost:33333/api/configuration/remotes | json
+ ```
+
+ **Expected output:**
+
+ ```json
+ [
+ {
+ "api_uri": "https://localhost:44444",
+ "status": "online",
+ "version": "5.x.x"
+ }
+ ]
+ ```
+
+> **Security Note:** This mixed configuration is not recommended for production. If the primary requires authentication, remote instances should also require authentication to maintain consistent security.
+
+### Scenario 13: Mixed Authentication Configuration (Remotes Only)
+
+Test behavior when remote instances have authentication enabled, but the primary does not.
+
+**Terminal 1 - Start ServiceControl.Audit WITH authentication:**
+
+```cmd
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+cd src\ServiceControl.Audit
+dotnet run
+```
+
+**Terminal 2 - Start ServiceControl (Primary) WITHOUT authentication:**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://localhost:44444"}]
+
+cd src\ServiceControl
+dotnet run
+```
+
+**Query without a token:**
+
+```cmd
+curl https://localhost:33333/api/messages | json
+```
+
+**How to verify the degraded functionality:**
+
+1. **Verify the Primary instance has no authentication** - Direct requests without a token should succeed:
+
+ ```cmd
+ REM Direct to Primary without token - should succeed (no auth required)
+ curl https://localhost:33333/api | json
+ REM Expected: API root response
+ ```
+
+2. **Verify the Audit instance requires authentication** - Direct requests without a token should fail:
+
+ ```cmd
+ REM Direct to Audit without token - should fail
+ curl -v https://localhost:44444/api/messages 2>&1 | findstr /C:"HTTP/"
+ REM Expected: < HTTP/1.1 401 Unauthorized
+ ```
+
+3. **Check the Audit instance logs (Terminal 1)** - You should see 401 Unauthorized responses when the Primary tries to query it:
+
+ ```text
+ warn: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler
+ Bearer was not authenticated. Failure message: No token provided...
+ ```
+
+4. **Check the Primary instance logs (Terminal 2)** - You should see the remote marked as temporarily unavailable:
+
+ ```text
+ warn: ... Remote instance at https://localhost:44444 returned status code Unauthorized
+ ```
+
+5. **Verify the remote is marked unavailable:**
+
+ ```cmd
+ curl https://localhost:33333/api/configuration/remotes | json
+ ```
+
+ **Expected output:**
+
+ ```json
+ [
+ {
+ "api_uri": "https://localhost:44444",
+ "status": "unavailable"
+ }
+ ]
+ ```
+
+6. **Confirm scatter-gather returns partial results** - The response only contains local Primary data, not aggregated Audit data. Any endpoints or messages stored in the Audit instance will be missing from the response.
+
+> **Warning:** This configuration results in degraded functionality. Remote instances will be inaccessible for scatter-gather operations.
+
+### Scenario 14: Expired Token Forwarding
+
+Test how scatter-gather handles expired tokens being forwarded to remote instances.
+
+**With both instances running with authentication (same as Scenario 8):**
+
+**Use an expired token:**
+
+```cmd
+curl -v -H "Authorization: Bearer {expired-token}" https://localhost:33333/api/messages 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 401 Unauthorized
+```
+
+The primary instance rejects the expired token before any remote requests are made.
+
+## Known Limitations
+
+### Internal Service-to-Service Communication
+
+The following internal API calls from the primary instance to remote instances do **not** forward authentication headers:
+
+| Internal Call | Endpoint | Purpose |
+|---------------------|---------------------------------------------------------------|----------------------------------------|
+| Health Check | `GET /api` | Verify remote instance availability |
+| Configuration | `GET /api/configuration` | Retrieve remote instance configuration |
+| Platform Connection | `GET /api/connection` | Aggregate platform connection details |
+| License Throughput | `GET /api/endpoints`, `GET /api/endpoints/{name}/audit-count` | Collect audit throughput for licensing |
+
+**Implications:**
+
+- These endpoints must be accessible without authentication for multi-instance deployments to work
+- The `/api` endpoint is already marked as anonymous on all instances
+- The `/api/configuration` endpoint on Audit and Monitoring instances should allow anonymous access for inter-instance communication
+
+### Same Authentication Configuration Required
+
+When using scatter-gather with authentication enabled:
+
+- All instances (Primary, Audit, Monitoring) must use the **same** Authority and Audience
+- Client tokens must be valid for all instances
+- There is no service-to-service authentication mechanism; client tokens are forwarded directly
+
+### Token Forwarding Security Considerations
+
+- Client tokens are forwarded to remote instances in their entirety
+- Remote instances see the same token as the primary instance
+- Token scope/claims are not modified during forwarding
+
+## Testing Other Instances
+
+The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring:
+
+1. Use the appropriate environment variable prefix (see Instance Reference above)
+2. Use the corresponding project directory and port
+3. Note: Audit and Monitoring instances don't require ServicePulse settings
+
+| Instance | Project Directory | Port | Env Var Prefix |
+|---------------------------|---------------------------------|-------|-------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` |
+
+## Cleanup
+
+After testing, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE=
+set SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME=
+set SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY=
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=
+```
+
+**PowerShell:**
+
+```powershell
+$env:SERVICECONTROL_AUTHENTICATION_ENABLED = $null
+$env:SERVICECONTROL_AUTHENTICATION_AUTHORITY = $null
+$env:SERVICECONTROL_AUTHENTICATION_AUDIENCE = $null
+$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID = $null
+$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY = $null
+$env:SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES = $null
+$env:SERVICECONTROL_AUTHENTICATION_VALIDATEISSUER = $null
+$env:SERVICECONTROL_AUTHENTICATION_VALIDATEAUDIENCE = $null
+$env:SERVICECONTROL_AUTHENTICATION_VALIDATELIFETIME = $null
+$env:SERVICECONTROL_AUTHENTICATION_VALIDATEISSUERSIGNINGKEY = $null
+$env:SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA = $null
+```
+
+## See Also
+
+- [Authentication Configuration](authentication.md) - Configuration reference for authentication settings
+- [HTTPS Configuration](https-configuration.md) - HTTPS is recommended when authentication is enabled
+- [Forwarded Headers Testing](forward-headers-testing.md) - Testing forwarded headers
diff --git a/docs/authentication.md b/docs/authentication.md
new file mode 100644
index 0000000000..72b0e4968d
--- /dev/null
+++ b/docs/authentication.md
@@ -0,0 +1,219 @@
+# Authentication Configuration
+
+ServiceControl instances can be configured to require JWT authentication using OpenID Connect (OIDC). This enables integration with identity providers like Microsoft Entra ID (Azure AD), Okta, Auth0, and other OIDC-compliant providers.
+
+## Configuration
+
+ServiceControl instances can be configured via environment variables or App.config. Each instance type uses a different prefix.
+
+### Environment Variables
+
+| Instance | Prefix |
+|---------------------------|-------------------------|
+| ServiceControl (Primary) | `SERVICECONTROL_` |
+| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `MONITORING_` |
+
+#### Core Settings
+
+| Setting | Default | Description |
+|------------------------------------|---------|-------------------------------------------------------------------------------------------|
+| `{PREFIX}AUTHENTICATION_ENABLED` | `false` | Enable JWT authentication |
+| `{PREFIX}AUTHENTICATION_AUTHORITY` | (none) | OpenID Connect authority URL (e.g., `https://login.microsoftonline.com/{tenant-id}/v2.0`) |
+| `{PREFIX}AUTHENTICATION_AUDIENCE` | (none) | The audience identifier (typically your API identifier or client ID) |
+
+#### Validation Settings
+
+| Setting | Default | Description |
+|---------------------------------------------------|---------|------------------------------------------|
+| `{PREFIX}AUTHENTICATION_VALIDATEISSUER` | `true` | Validate the token issuer |
+| `{PREFIX}AUTHENTICATION_VALIDATEAUDIENCE` | `true` | Validate the token audience |
+| `{PREFIX}AUTHENTICATION_VALIDATELIFETIME` | `true` | Validate token expiration |
+| `{PREFIX}AUTHENTICATION_VALIDATEISSUERSIGNINGKEY` | `true` | Validate the signing key |
+| `{PREFIX}AUTHENTICATION_REQUIREHTTPSMETADATA` | `true` | Require HTTPS for OIDC metadata endpoint |
+
+#### ServicePulse Settings (Primary Instance Only)
+
+These settings are required on the primary ServiceControl instance to provide authentication configuration to ServicePulse clients.
+
+| Setting | Default | Description |
+|-------------------------------------------------|---------|--------------------------------------------------------------------------------------------------------|
+| `{PREFIX}AUTHENTICATION_SERVICEPULSE_CLIENTID` | (none) | Client ID for ServicePulse application |
+| `{PREFIX}AUTHENTICATION_SERVICEPULSE_AUTHORITY` | (none) | Authority URL for ServicePulse (defaults to main Authority if not set) |
+| `{PREFIX}AUTHENTICATION_SERVICEPULSE_APISCOPES` | (none) | JSON array of API scopes for ServicePulse to request (e.g., `["api://servicecontrol/access_as_user"]`) |
+
+### App.config
+
+| Instance | Key Prefix |
+|---------------------------|-------------------------|
+| ServiceControl (Primary) | `ServiceControl/` |
+| ServiceControl.Audit | `ServiceControl.Audit/` |
+| ServiceControl.Monitoring | `Monitoring/` |
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+> **Note:** The `ServicePulse.Authority` must be set explicitly. The `Audience` for ServicePulse is reused from the main `Authentication.Audience` setting.
+
+## Examples
+
+### Microsoft Entra ID (Azure AD)
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+```
+
+### Docker Example
+
+```cmd
+docker run -p 33333:33333 -e SERVICECONTROL_AUTHENTICATION_ENABLED=true -e SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -e SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id} -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 -e SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"] particular/servicecontrol:latest
+```
+
+### Audit and Monitoring Instances
+
+Audit and Monitoring instances don't require ServicePulse settings:
+
+```cmd
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol
+```
+
+```cmd
+set MONITORING_AUTHENTICATION_ENABLED=true
+set MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol
+```
+
+## How It Works
+
+When authentication is enabled:
+
+1. Most API requests must include a valid JWT bearer token in the `Authorization` header
+2. ServiceControl validates the token against the configured authority
+3. The token must have the correct audience and not be expired
+4. ServicePulse retrieves authentication configuration from the `/api/authentication/configuration` endpoint
+
+### Anonymous Endpoints
+
+The following endpoints are accessible without authentication, even when authentication is enabled:
+
+| Endpoint | Purpose |
+|-------------------------------------|----------------------------------------------------------------------|
+| `/api` | API root/discovery - returns available endpoints and API information |
+| `/api/instance-info` | Returns instance configuration information |
+| `/api/configuration` | Returns instance configuration information (alias) |
+| `/api/configuration/remotes` | Returns remote instance configurations for server-to-server fetching |
+| `/api/authentication/configuration` | Returns authentication configuration for clients like ServicePulse |
+
+These endpoints must remain accessible so clients can discover API capabilities and obtain the authentication configuration needed to acquire tokens.
+
+### Request Flow
+
+```mermaid
+flowchart TD
+ Client[Client Request] --> Header[Authorization: Bearer token]
+ Header --> Validate{ServiceControl validates token}
+
+ Validate --> |Valid| Process[Request processed]
+ Validate --> |Invalid| Reject[401 Unauthorized]
+
+ subgraph Validation
+ V1[Issuer matches Authority]
+ V2[Audience matches configured]
+ V3[Token not expired]
+ V4[Signature is valid]
+ end
+
+ Validate -.-> Validation
+```
+
+## Security Considerations
+
+### HTTPS Recommended
+
+When authentication is enabled, HTTPS is strongly recommended for production deployments. Without HTTPS, JWT tokens are transmitted in plain text and can be intercepted by attackers. Use either:
+
+- **Direct HTTPS**: Configure ServiceControl with a certificate (see [HTTPS Configuration](https-configuration.md))
+- **Reverse Proxy**: Terminate SSL at a reverse proxy (NGINX, Traefik, cloud load balancer)
+
+### Production Settings
+
+The default validation settings are recommended for production:
+
+| Setting | Recommendation |
+|----------------------------|----------------------------------------------------------|
+| `ValidateIssuer` | `true` - Prevents tokens from untrusted issuers |
+| `ValidateAudience` | `true` - Prevents tokens intended for other applications |
+| `ValidateLifetime` | `true` - Prevents expired tokens |
+| `ValidateIssuerSigningKey` | `true` - Ensures token signature is valid |
+| `RequireHttpsMetadata` | `true` - Ensures OIDC metadata is fetched securely |
+
+### Development Settings
+
+For local development with a test identity provider, you may need to relax some settings:
+
+```cmd
+set SERVICECONTROL_AUTHENTICATION_REQUIREHTTPSMETADATA=false
+```
+
+> **Warning:** Never disable validation settings in production. Doing so can expose your system to serious security vulnerabilities.
+
+### HTTPS Requirement
+
+When `RequireHttpsMetadata` is `true` (the default), the Authority URL must use HTTPS. This ensures that the OIDC metadata (including signing keys) is fetched over a secure connection.
+
+## Configuring Identity Providers
+
+### Microsoft Entra ID Setup
+
+1. Register an application in Azure AD for ServiceControl API
+2. Register a separate application for ServicePulse (SPA)
+3. Configure API permissions and expose an API scope
+4. Set the Authority to `https://login.microsoftonline.com/{tenant-id}/v2.0`
+5. Set the Audience to your API application ID URI (e.g., `api://servicecontrol`)
+
+### Other OIDC Providers
+
+ServiceControl works with any OIDC-compliant provider. Configure:
+
+- **Authority**: The issuer URL from your provider's OIDC discovery document
+- **Audience**: The identifier configured for your API in the provider
+
+## Testing Other Instances
+
+The primary ServiceControl instance requires ServicePulse settings because it serves the `/api/auth/config` endpoint that ServicePulse uses to configure its authentication. Audit and Monitoring instances only need the core authentication settings.
+
+| Instance | Requires ServicePulse Settings |
+|---------------------------|--------------------------------|
+| ServiceControl (Primary) | Yes |
+| ServiceControl.Audit | No |
+| ServiceControl.Monitoring | No |
+
+## See Also
+
+- [Forwarded Headers Configuration](forwarded-headers.md) - Configure forwarded headers when behind a reverse proxy
+- [HTTPS Configuration](https-configuration.md) - Configure direct HTTPS
diff --git a/docs/forward-headers-testing.md b/docs/forward-headers-testing.md
new file mode 100644
index 0000000000..cf3045b2e1
--- /dev/null
+++ b/docs/forward-headers-testing.md
@@ -0,0 +1,859 @@
+# Local Testing Forwarded Headers (Without NGINX)
+
+This guide explains how to test forwarded headers configuration for ServiceControl instances without using NGINX or Docker. This approach uses curl to manually send `X-Forwarded-*` headers directly to the instances.
+
+## Prerequisites
+
+- ServiceControl built locally (see main README for build instructions)
+- curl (included with Windows 10/11, Git Bash, or WSL)
+- (Optional) For formatted JSON output: `npm install -g json` then pipe curl output through `| json`
+- All commands assume you are in the respective project directory
+
+## Enabling Debug Logs
+
+To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance:
+
+```cmd
+rem ServiceControl Primary
+set SERVICECONTROL_LOGLEVEL=Debug
+
+rem ServiceControl.Audit
+set SERVICECONTROL_AUDIT_LOGLEVEL=Debug
+
+rem ServiceControl.Monitoring
+set MONITORING_LOGLEVEL=Debug
+```
+
+**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`)
+
+Debug logs will show detailed forwarded headers processing and trust evaluation information.
+
+## Instance Reference
+
+| Instance | Project Directory | Default Port | Environment Variable Prefix |
+|---------------------------|---------------------------------|--------------|-----------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` |
+
+> **Note:** Environment variables must include the instance prefix (e.g., `SERVICECONTROL_FORWARDEDHEADERS_ENABLED` for the primary instance).
+
+## How Forwarded Headers Work
+
+When a ServiceControl instance is behind a reverse proxy, the proxy sends headers to indicate the original request details:
+
+- `X-Forwarded-For` - Original client IP address
+- `X-Forwarded-Proto` - Original protocol (http/https)
+- `X-Forwarded-Host` - Original host header
+
+Each instance can be configured to trust these headers from specific proxies or trust all proxies.
+
+### Trust Evaluation Rules
+
+The middleware determines whether to process forwarded headers based on these rules:
+
+1. **If `TrustAllProxies` = true**: All requests are trusted, headers are always processed
+2. **If `TrustAllProxies` = false**: The caller's IP must match **either**:
+ - **KnownProxies**: Exact IP address match (e.g., `127.0.0.1`, `::1`)
+ - **KnownNetworks**: CIDR range match (e.g., `127.0.0.0/8`, `10.0.0.0/8`)
+
+> **Important:** KnownProxies and KnownNetworks use **OR logic** - a match in either grants trust. The check is against the **immediate caller's IP** (the proxy connecting to ServiceControl), not the original client IP from `X-Forwarded-For`.
+
+## Configuration Methods
+
+Settings can be configured via:
+
+1. **Environment variables** (recommended for testing) - Easy to change between scenarios, no file edits needed
+2. **App.config** - Persisted settings, requires app restart after changes
+
+Both methods work identically. This guide uses environment variables for convenience during iterative testing.
+
+## Test Scenarios
+
+The following scenarios use ServiceControl (Primary) as an example. To test other instances:
+
+1. Navigate to the instance's project directory
+2. Use the instance's default port in the curl commands
+3. Optionally use the instance-specific environment variable prefix
+
+> **Important:** Set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session and won't be seen if you run from Visual Studio or a different terminal.
+>
+> **Tip:** Check the application startup logs to verify which settings were applied. The forwarded headers configuration is logged at startup.
+
+### Scenario 0: Direct Access (No Proxy)
+
+Test a direct request without any forwarded headers, simulating access without a reverse proxy.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl (no forwarded headers):**
+
+```cmd
+curl http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+When no forwarded headers are sent, the request values remain unchanged.
+
+### Scenario 1: Default Behavior (With Headers)
+
+Test the default behavior when no forwarded headers environment variables are set, but headers are sent.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+By default, forwarded headers are **enabled** and **all proxies are trusted**. This means any client can spoof `X-Forwarded-*` headers. This is suitable for development but should be restricted in production by configuring `KnownProxies` or `KnownNetworks`.
+
+### Scenario 2: Trust All Proxies (Explicit)
+
+Explicitly enable trust all proxies (same as default, but explicit configuration).
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+The `scheme` is `https` (from `X-Forwarded-Proto`), `host` is `example.com` (from `X-Forwarded-Host`), and `remoteIpAddress` is `203.0.113.50` (from `X-Forwarded-For`) because all proxies are trusted. The `rawHeaders` are empty because the middleware consumed them.
+
+### Scenario 3: Known Proxies Only
+
+Only accept forwarded headers from specific IP addresses.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+> **Note:** Setting `SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES` automatically disables `SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES`. Both IPv4 (`127.0.0.1`) and IPv6 (`::1`) loopback addresses are included since curl may use either.
+
+**Test with curl (from localhost - should work):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["127.0.0.1", "::1"],
+ "knownNetworks": []
+ }
+}
+```
+
+Headers are applied because the request comes from localhost, which is in the known proxies list. The `rawHeaders` are empty because the middleware consumed them.
+
+### Scenario 4: Known Networks (CIDR)
+
+Trust all proxies within a network range.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128
+
+dotnet run
+```
+
+> **Note:** Both IPv4 (`127.0.0.0/8`) and IPv6 (`::1/128`) loopback networks are included since curl may use either.
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": [],
+ "knownNetworks": ["127.0.0.0/8", "::1/128"]
+ }
+}
+```
+
+Headers are applied because the request comes from localhost, which falls within the known networks. The `rawHeaders` are empty because the middleware consumed them.
+
+### Scenario 5: Unknown Proxy Rejected
+
+Configure a known proxy that doesn't match the request source to verify headers are ignored.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50",
+ "xForwardedProto": "https",
+ "xForwardedHost": "example.com"
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["192.168.1.100"],
+ "knownNetworks": []
+ }
+}
+```
+
+Headers are **ignored** because the request comes from localhost (`::1`), which is NOT in the known proxies list (`192.168.1.100`). Notice `scheme` is `http` (unchanged from original request). The `rawHeaders` still show the headers that were sent but not applied.
+
+### Scenario 6: Unknown Network Rejected
+
+Configure a known network that doesn't match the request source to verify headers are ignored.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/8,192.168.0.0/16
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50",
+ "xForwardedProto": "https",
+ "xForwardedHost": "example.com"
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": [],
+ "knownNetworks": ["10.0.0.0/8", "192.168.0.0/16"]
+ }
+}
+```
+
+Headers are **ignored** because the request comes from localhost (`::1`), which is NOT in the known networks (`10.0.0.0/8` or `192.168.0.0/16`). Notice `scheme` is `http` (unchanged from original request). The `rawHeaders` still show the headers that were sent but not applied.
+
+### Scenario 7: Forwarded Headers Disabled
+
+Completely disable forwarded headers processing.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50",
+ "xForwardedProto": "https",
+ "xForwardedHost": "example.com"
+ },
+ "configuration": {
+ "enabled": false,
+ "trustAllProxies": false,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+Headers are ignored because forwarded headers processing is disabled entirely. Notice `enabled` is `false` in the configuration.
+
+### Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values)
+
+Test how ServiceControl handles multiple proxies in the chain.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl (simulating a proxy chain):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+The `X-Forwarded-For` header contains multiple IPs representing the proxy chain. When `TrustAllProxies` is `true`, `ForwardLimit` is set to `null` (no limit), so the middleware processes all IPs and returns the original client IP (`203.0.113.50`).
+
+### Scenario 9: Proxy Chain with Known Proxies (ForwardLimit = 1)
+
+Test how ServiceControl handles multiple proxies when `TrustAllProxies` is `false`. In this case, `ForwardLimit` remains at its default of `1`, so only the last proxy IP is processed.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl (simulating a proxy chain):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "192.168.1.1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50, 10.0.0.1",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["127.0.0.1", "::1"],
+ "knownNetworks": []
+ }
+}
+```
+
+When `TrustAllProxies` is `false`, `ForwardLimit` remains at its default of `1`. The middleware only processes the rightmost IP from the chain (`192.168.1.1`). The remaining IPs (`203.0.113.50, 10.0.0.1`) stay in the `X-Forwarded-For` header. Compare this to Scenario 8 where `TrustAllProxies = true` returns the original client IP.
+
+### Scenario 10: Combined Known Proxies and Networks
+
+Test using both `KnownProxies` and `KnownNetworks` together.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["192.168.1.100"],
+ "knownNetworks": ["127.0.0.0/8", "::1/128"]
+ }
+}
+```
+
+Headers are applied because the request comes from localhost (`::1`), which falls within the `::1/128` network even though it's not in the `knownProxies` list.
+
+### Scenario 11: Partial Headers (Proto Only)
+
+Test that each forwarded header is processed independently. Only sending `X-Forwarded-Proto` should update the scheme while leaving host and remoteIpAddress unchanged.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+**Test with curl (only X-Forwarded-Proto):**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+Only the `scheme` changed to `https`. The `host` remains `localhost:33333` and `remoteIpAddress` remains `::1` because those headers weren't sent. Each header is processed independently.
+
+### Scenario 12: IPv4/IPv6 Mismatch
+
+Demonstrates a common misconfiguration where only IPv4 localhost is configured but curl uses IPv6. This scenario shows why you should include both `127.0.0.1` and `::1` in your configuration.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+
+dotnet run
+```
+
+> **Note:** Only IPv4 `127.0.0.1` is configured, not IPv6 `::1`.
+
+**Test with curl:**
+
+```cmd
+curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:33333/debug/request-info | json
+```
+
+**Expected output (if curl uses IPv6):**
+
+```json
+{
+ "processed": {
+ "scheme": "http",
+ "host": "localhost:33333",
+ "remoteIpAddress": "::1"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "203.0.113.50",
+ "xForwardedProto": "https",
+ "xForwardedHost": "example.com"
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["127.0.0.1"],
+ "knownNetworks": []
+ }
+}
+```
+
+Headers are **ignored** because the request comes from `::1` (IPv6), but only `127.0.0.1` (IPv4) is in the known proxies list. This is a common gotcha - always include both IPv4 and IPv6 loopback addresses when testing locally, or use CIDR notation like `127.0.0.0/8` and `::1/128`.
+
+> **Tip:** If your output shows headers were applied, curl is using IPv4. The behavior depends on your system's DNS resolution for `localhost`.
+
+## Debug Endpoint
+
+The `/debug/request-info` endpoint is only available in Development environment. It returns:
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "example.com",
+ "remoteIpAddress": "203.0.113.50"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": false,
+ "knownProxies": ["127.0.0.1"],
+ "knownNetworks": []
+ }
+}
+```
+
+| Section | Field | Description |
+|-----------------|-------------------|------------------------------------------------------------------|
+| `processed` | `scheme` | The request scheme after forwarded headers processing |
+| `processed` | `host` | The request host after forwarded headers processing |
+| `processed` | `remoteIpAddress` | The client IP after forwarded headers processing |
+| `rawHeaders` | `xForwardedFor` | Raw `X-Forwarded-For` header (empty if consumed by middleware) |
+| `rawHeaders` | `xForwardedProto` | Raw `X-Forwarded-Proto` header (empty if consumed by middleware) |
+| `rawHeaders` | `xForwardedHost` | Raw `X-Forwarded-Host` header (empty if consumed by middleware) |
+| `configuration` | `enabled` | Whether forwarded headers middleware is enabled |
+| `configuration` | `trustAllProxies` | Whether all proxies are trusted (security warning if true) |
+| `configuration` | `knownProxies` | List of trusted proxy IP addresses |
+| `configuration` | `knownNetworks` | List of trusted CIDR network ranges |
+
+### Key Diagnostic Questions
+
+1. **Were headers applied?** - If `rawHeaders` are empty but `processed` values changed, the middleware consumed and applied them
+2. **Why weren't headers applied?** - If `rawHeaders` still contain values, the middleware didn't trust the caller. Check `knownProxies` and `knownNetworks` in `configuration`
+3. **Is forwarded headers enabled?** - Check `configuration.enabled`
+
+## Cleanup
+
+After testing, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=
+```
+
+**PowerShell:**
+
+```powershell
+$env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS = $null
+```
+
+## Quick Reference: Testing Other Instances
+
+### ServiceControl.Audit
+
+```cmd
+cd src\ServiceControl.Audit
+set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1
+dotnet run
+```
+
+```cmd
+curl -H "X-Forwarded-Proto: https" http://localhost:44444/debug/request-info | json
+```
+
+### ServiceControl.Monitoring
+
+```cmd
+cd src\ServiceControl.Monitoring
+set MONITORING_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1
+dotnet run
+```
+
+```cmd
+curl -H "X-Forwarded-Proto: https" http://localhost:33633/debug/request-info | json
+```
+
+## Unit Tests
+
+Unit tests for the `ForwardedHeadersSettings` configuration class are located at:
+
+```text
+src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs
+```
+
+### Running the Tests
+
+```bash
+dotnet test src/ServiceControl.UnitTests/ServiceControl.UnitTests.csproj --filter "FullyQualifiedName~ForwardedHeadersSettingsTests"
+```
+
+### What the Tests Cover
+
+| Test | Purpose |
+|-------------------------------------------------------------------|--------------------------------------------------------------------|
+| `Should_parse_known_proxies_from_comma_separated_list` | Verifies parsing of multiple proxy IPs |
+| `Should_parse_known_proxies_to_ip_addresses` | Verifies `KnownProxies` property returns valid `IPAddress` objects |
+| `Should_ignore_invalid_ip_addresses` | Verifies invalid IPs are filtered out gracefully |
+| `Should_parse_known_networks_from_comma_separated_cidr` | Verifies CIDR notation parsing |
+| `Should_ignore_invalid_network_cidr` | Verifies invalid CIDR entries are filtered |
+| `Should_disable_trust_all_proxies_when_known_proxies_configured` | Verifies auto-disable behavior |
+| `Should_disable_trust_all_proxies_when_known_networks_configured` | Verifies auto-disable behavior |
+| `Should_default_to_enabled` | Verifies default value |
+| `Should_default_to_trust_all_proxies` | Verifies default value |
+| `Should_respect_explicit_disabled_setting` | Verifies explicit configuration |
+| `Should_handle_semicolon_separator_in_proxies` | Tests alternate separator |
+| `Should_trim_whitespace_from_proxy_entries` | Tests whitespace handling |
+
+## Acceptance Tests
+
+Acceptance tests for end-to-end forwarded headers behavior are located at:
+
+```text
+src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/
+src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/
+src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/
+```
+
+Each instance type has identical tests covering all scenarios.
+
+### Running the Tests
+
+```bash
+# ServiceControl (Primary)
+dotnet test src/ServiceControl.AcceptanceTests.RavenDB/ServiceControl.AcceptanceTests.RavenDB.csproj --filter "FullyQualifiedName~ForwardedHeaders"
+
+# ServiceControl.Audit
+dotnet test src/ServiceControl.Audit.AcceptanceTests/ServiceControl.Audit.AcceptanceTests.csproj --filter "FullyQualifiedName~ForwardedHeaders"
+
+# ServiceControl.Monitoring
+dotnet test src/ServiceControl.Monitoring.AcceptanceTests/ServiceControl.Monitoring.AcceptanceTests.csproj --filter "FullyQualifiedName~ForwardedHeaders"
+```
+
+### Scenarios Covered
+
+| Scenario | Test |
+|----------|--------------------------------------------------------|
+| 0 | `When_request_has_no_forwarded_headers` |
+| 1/2 | `When_forwarded_headers_are_sent` |
+| 3 | `When_known_proxies_are_configured` |
+| 4 | `When_known_networks_are_configured` |
+| 5 | `When_unknown_proxy_sends_headers` |
+| 6 | `When_unknown_network_sends_headers` |
+| 7 | `When_forwarded_headers_are_disabled` |
+| 8 | `When_proxy_chain_headers_are_sent` |
+| 9 | `When_proxy_chain_headers_are_sent_with_known_proxies` |
+| 10 | `When_combined_proxies_and_networks_are_configured` |
+| 11 | `When_only_proto_header_is_sent` |
+
+> **Note:** Scenario 12 (IPv4/IPv6 Mismatch) is not covered by acceptance tests because the test server's IP address (IPv4 vs IPv6) cannot be controlled reliably. The "untrusted proxy" behavior is already validated by Scenarios 5 and 6.
+
+## See Also
+
+- [Hosting Guide](hosting-guide.md) - Configuration reference for forwarded headers
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Testing with a real reverse proxy (NGINX)
+- [Testing Architecture](testing-architecture.md) - Overview of testing patterns in this repository
diff --git a/docs/forwarded-headers.md b/docs/forwarded-headers.md
new file mode 100644
index 0000000000..7c87f94fd0
--- /dev/null
+++ b/docs/forwarded-headers.md
@@ -0,0 +1,134 @@
+# Forwarded Headers Configuration
+
+When ServiceControl instances are deployed behind a reverse proxy (like NGINX, Traefik, or a cloud load balancer) that terminates SSL/TLS, you need to configure forwarded headers so ServiceControl correctly understands the original client request.
+
+> **⚠️ Security Warning:** The default configuration (`TrustAllProxies = true`) is suitable for development and trusted container environments only. For production deployments accessible from untrusted networks, always configure `KnownProxies` or `KnownNetworks` to restrict which sources can set forwarded headers. Failing to do so can allow attackers to spoof client IP addresses.
+
+## Configuration
+
+ServiceControl instances can be configured via environment variables or App.config. Each instance type uses a different prefix.
+
+### Environment Variables
+
+| Instance | Prefix |
+|---------------------------|-------------------------|
+| ServiceControl (Primary) | `SERVICECONTROL_` |
+| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `MONITORING_` |
+
+| Setting | Default | Description |
+|--------------------------------------------|---------|------------------------------------------------------------------|
+| `{PREFIX}FORWARDEDHEADERS_ENABLED` | `true` | Enable forwarded headers processing |
+| `{PREFIX}FORWARDEDHEADERS_TRUSTALLPROXIES` | `true` | Trust all proxies (auto-disabled if known proxies/networks set) |
+| `{PREFIX}FORWARDEDHEADERS_KNOWNPROXIES` | (none) | Comma-separated IP addresses of trusted proxies |
+| `{PREFIX}FORWARDEDHEADERS_KNOWNNETWORKS` | (none) | Comma-separated CIDR networks (e.g., `10.0.0.0/8,172.16.0.0/12`) |
+
+### App.config
+
+| Instance | Key Prefix |
+|---------------------------|-------------------------|
+| ServiceControl (Primary) | `ServiceControl/` |
+| ServiceControl.Audit | `ServiceControl.Audit/` |
+| ServiceControl.Monitoring | `Monitoring/` |
+
+```xml
+
+
+
+
+
+
+```
+
+## Examples
+
+### Trust all proxies (default, suitable for containers)
+
+```cmd
+docker run -p 33333:33333 -e SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true particular/servicecontrol:latest
+```
+
+### Restrict to specific proxies
+
+```cmd
+docker run -p 33333:33333 -e SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true -e SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,10.0.0.5 particular/servicecontrol:latest
+```
+
+### Restrict to specific networks
+
+```cmd
+docker run -p 33333:33333 -e SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true -e SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/8,172.16.0.0/12 particular/servicecontrol:latest
+```
+
+When `KNOWNPROXIES` or `KNOWNNETWORKS` are set, `TRUSTALLPROXIES` is automatically disabled.
+
+## What Headers Are Processed
+
+When enabled, ServiceControl processes:
+
+- `X-Forwarded-For` - Original client IP address
+- `X-Forwarded-Proto` - Original protocol (http/https)
+- `X-Forwarded-Host` - Original host header
+
+## HTTP to HTTPS Redirect
+
+When using a reverse proxy that terminates SSL, you can configure ServiceControl to redirect HTTP requests to HTTPS. This works in combination with forwarded headers:
+
+1. The reverse proxy forwards both HTTP and HTTPS requests to ServiceControl
+2. The proxy sets `X-Forwarded-Proto` to indicate the original protocol
+3. ServiceControl reads this header (via forwarded headers processing)
+4. If the original request was HTTP and redirect is enabled, ServiceControl returns a redirect to HTTPS
+
+To enable HTTP to HTTPS redirect:
+
+```cmd
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true
+set SERVICECONTROL_HTTPS_PORT=443
+```
+
+Or in App.config:
+
+```xml
+
+
+
+
+```
+
+## Proxy Chain Behavior (ForwardLimit)
+
+When processing `X-Forwarded-For` headers with multiple IPs (proxy chains), the behavior depends on trust configuration:
+
+| Configuration | ForwardLimit | Behavior |
+|---------------------------|-------------------|-----------------------------------------------|
+| `TrustAllProxies = true` | `null` (no limit) | Processes all IPs, returns original client IP |
+| `TrustAllProxies = false` | `1` (default) | Processes only the last proxy IP |
+
+For example, with `X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1`:
+
+- **TrustAllProxies = true**: Returns `203.0.113.50` (original client)
+- **TrustAllProxies = false**: Returns `192.168.1.1` (last proxy)
+
+## Security Considerations
+
+By default, `TrustAllProxies` is `true`, which is suitable for container deployments where the proxy is trusted infrastructure. For production deployments with untrusted networks, consider restricting to known proxies or networks to prevent header spoofing attacks.
+
+### Forwarded Headers Behavior
+
+When the proxy is trusted:
+
+- `Request.Scheme` will be set from `X-Forwarded-Proto` (e.g., `https`)
+- `Request.Host` will be set from `X-Forwarded-Host` (e.g., `servicecontrol.example.com`)
+- Client IP will be available from `X-Forwarded-For`
+
+When the proxy is **not** trusted (incorrect `KnownProxies`):
+
+- `X-Forwarded-*` headers are **ignored** (not applied to the request)
+- `Request.Scheme` remains `http`
+- `Request.Host` remains the internal hostname
+- The request is still processed (not blocked)
+
+## See Also
+
+- [Forwarded Headers Testing](forward-headers-testing.md) - Test forwarded headers configuration with curl
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Guide for testing with NGINX reverse proxy locally
diff --git a/docs/hosting-guide.md b/docs/hosting-guide.md
new file mode 100644
index 0000000000..9b73c75601
--- /dev/null
+++ b/docs/hosting-guide.md
@@ -0,0 +1,737 @@
+# ServiceControl Production Hosting Guide
+
+This guide covers hosting and security configuration for ServiceControl in production environments. All scenarios assume HTTPS and authentication are required.
+
+---
+
+## Configuration Basics
+
+### Instance Types and Prefixes
+
+ServiceControl consists of three deployable instances:
+
+| Instance | Purpose | Config Prefix | Env Var Prefix | Default Port |
+|---------------------------|--------------------------------------|-------------------------|-------------------------|--------------|
+| ServiceControl (Primary) | Error handling, retries, heartbeats | `ServiceControl/` | `SERVICECONTROL_` | 33333 |
+| ServiceControl.Audit | Audit message ingestion and querying | `ServiceControl.Audit/` | `SERVICECONTROL_AUDIT_` | 44444 |
+| ServiceControl.Monitoring | Endpoint performance monitoring | `Monitoring/` | `MONITORING_` | 33633 |
+
+### Configuration Methods
+
+Settings can be configured via:
+
+- **App.config** - Recommended for Windows service deployments
+- **Environment variables** - Recommended for containers
+
+### Host and Port Configuration
+
+Configure the hostname and port that each instance listens on:
+
+**App.config:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Environment variables:**
+
+```cmd
+rem ServiceControl Primary
+set SERVICECONTROL_HOSTNAME=localhost
+set SERVICECONTROL_PORT=33333
+
+rem ServiceControl.Audit
+set SERVICECONTROL_AUDIT_HOSTNAME=localhost
+set SERVICECONTROL_AUDIT_PORT=44444
+
+rem ServiceControl.Monitoring
+set MONITORING_HOSTNAME=localhost
+set MONITORING_PORT=33633
+```
+
+> **Note:** Use `localhost` or `+` (all interfaces) for the hostname. When behind a reverse proxy, use `localhost` and configure the proxy to forward to the appropriate port.
+
+### Remote Instances Configuration
+
+The Primary instance must be configured to communicate with Audit instances for scatter-gather operations (aggregating data across instances):
+
+**App.config:**
+
+```xml
+
+
+
+
+```
+
+**Environment variables:**
+
+```cmd
+rem Single Audit instance
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit:44444/api"}]
+```
+
+For multiple Audit instances:
+
+**App.config:**
+
+```xml
+
+```
+
+**Environment variables:**
+
+```cmd
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit1:44444/api"},{"api_uri":"https://servicecontrol-audit2:44444/api"}]
+```
+
+> **Important:** When authentication is enabled, all instances (Primary, Audit, Monitoring) must use the **same** Identity Provider (IdP) Authority and Audience settings. Client tokens are forwarded to remote instances during scatter-gather operations.
+
+---
+
+## Production Deployment Scenarios
+
+### Scenario 1: Reverse Proxy with Authentication
+
+A reverse proxy (NGINX, IIS, cloud load balancer) handles SSL/TLS termination, and ServiceControl validates JWT tokens.
+
+**Architecture:**
+
+```text
+Client → HTTPS → Reverse Proxy → HTTP → ServiceControl
+ (SSL termination) (JWT validation)
+```
+
+**Security Features:**
+
+| Feature | Status |
+|-------------------------|---------------------------------|
+| JWT Authentication | ✅ Enabled |
+| Kestrel HTTPS | ❌ Disabled (handled by proxy) |
+| HTTPS Redirection | ✅ Enabled (optional) |
+| HSTS | ❌ Disabled (configure at proxy) |
+| Restricted CORS Origins | ✅ Enabled |
+| Forwarded Headers | ✅ Enabled |
+| Restricted Proxy Trust | ✅ Enabled |
+
+> **Note:** HTTPS redirection is optional in this scenario. The reverse proxy typically handles HTTP to HTTPS redirection at its layer. However, enabling it at ServiceControl provides defense-in-depth - if an HTTP request somehow bypasses the proxy and reaches ServiceControl directly, it will be redirected to the HTTPS URL. This requires configuring `Https.Port` to specify the external HTTPS port used by the proxy.
+
+#### ServiceControl Primary Configuration
+
+**App.config:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Environment variables:**
+
+```cmd
+rem Host and Port
+set SERVICECONTROL_HOSTNAME=localhost
+set SERVICECONTROL_PORT=33333
+
+rem Remote Audit Instance(s)
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit/api"}]
+
+rem Authentication
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+rem ServicePulse client configuration (Primary instance only)
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+
+rem Forwarded headers - trust only your reverse proxy
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=false
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5
+rem Or use CIDR notation:
+rem set SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/24
+
+rem HTTP to HTTPS redirect (optional)
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true
+set SERVICECONTROL_HTTPS_PORT=443
+
+rem Restrict CORS
+set SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse
+```
+
+#### ServiceControl.Audit Configuration
+
+**App.config:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Environment variables:**
+
+```cmd
+rem Host and Port
+set SERVICECONTROL_AUDIT_HOSTNAME=localhost
+set SERVICECONTROL_AUDIT_PORT=44444
+
+rem Authentication (same Authority and Audience as Primary)
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+rem Forwarded headers
+set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_TRUSTALLPROXIES=false
+set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5
+
+rem Restrict CORS
+set SERVICECONTROL_AUDIT_CORS_ALLOWEDORIGINS=https://servicepulse
+```
+
+#### ServiceControl.Monitoring Configuration
+
+**App.config:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Environment variables:**
+
+```cmd
+rem Host and Port
+set MONITORING_HOSTNAME=localhost
+set MONITORING_PORT=33633
+
+rem Authentication (same Authority and Audience as Primary)
+set MONITORING_AUTHENTICATION_ENABLED=true
+set MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+rem Forwarded headers
+set MONITORING_FORWARDEDHEADERS_ENABLED=true
+set MONITORING_FORWARDEDHEADERS_TRUSTALLPROXIES=false
+set MONITORING_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5
+
+rem Restrict CORS
+set MONITORING_CORS_ALLOWEDORIGINS=https://servicepulse
+```
+
+---
+
+### Scenario 2: Direct HTTPS with Authentication
+
+Kestrel handles TLS directly without a reverse proxy. Suitable for simpler deployments or when a reverse proxy is not available.
+
+**Architecture:**
+
+```text
+Client → HTTPS → ServiceControl (Kestrel)
+ (TLS + JWT validation)
+```
+
+**Security Features:**
+
+| Feature | Status |
+|-------------------------|-----------------------|
+| JWT Authentication | ✅ Enabled |
+| Kestrel HTTPS | ✅ Enabled |
+| HSTS | ✅ Enabled |
+| Restricted CORS Origins | ✅ Enabled |
+| Forwarded Headers | ❌ Disabled (no proxy) |
+| Restricted Proxy Trust | N/A |
+
+> **Note:** HTTPS redirection is not configured in this scenario because clients connect directly over HTTPS. There is no HTTP endpoint exposed that would need to redirect. HTTPS redirection is only useful when a reverse proxy handles SSL termination and ServiceControl needs to redirect HTTP requests to the proxy's HTTPS endpoint.
+
+#### Primary Instance Configuration (Direct HTTPS)
+
+**App.config:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Environment variables:**
+
+```cmd
+rem Host and Port
+set SERVICECONTROL_HOSTNAME=servicecontrol
+set SERVICECONTROL_PORT=33333
+
+rem Remote Audit Instance(s)
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit:44444/api"}]
+
+rem Kestrel HTTPS
+set SERVICECONTROL_HTTPS_ENABLED=true
+set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol.pfx
+set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=your-certificate-password
+set SERVICECONTROL_HTTPS_ENABLEHSTS=true
+set SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS=31536000
+
+rem Authentication
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+rem ServicePulse client configuration
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+
+rem Restrict CORS
+set SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse
+
+rem No forwarded headers (no proxy)
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false
+```
+
+#### Audit Instance Configuration (Direct HTTPS)
+
+**App.config:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Environment variables:**
+
+```cmd
+rem Host and Port
+set SERVICECONTROL_AUDIT_HOSTNAME=servicecontrol-audit
+set SERVICECONTROL_AUDIT_PORT=44444
+
+rem Kestrel HTTPS
+set SERVICECONTROL_AUDIT_HTTPS_ENABLED=true
+set SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol-audit.pfx
+set SERVICECONTROL_AUDIT_HTTPS_CERTIFICATEPASSWORD=your-certificate-password
+set SERVICECONTROL_AUDIT_HTTPS_ENABLEHSTS=true
+
+rem Authentication
+set SERVICECONTROL_AUDIT_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUDIT_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+rem Restrict CORS
+set SERVICECONTROL_AUDIT_CORS_ALLOWEDORIGINS=https://servicepulse
+
+rem No forwarded headers
+set SERVICECONTROL_AUDIT_FORWARDEDHEADERS_ENABLED=false
+```
+
+#### Monitoring Instance Configuration (Direct HTTPS)
+
+**App.config:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Environment variables:**
+
+```cmd
+rem Host and Port
+set MONITORING_HOSTNAME=servicecontrol-monitoring
+set MONITORING_PORT=33633
+
+rem Kestrel HTTPS
+set MONITORING_HTTPS_ENABLED=true
+set MONITORING_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol-monitoring.pfx
+set MONITORING_HTTPS_CERTIFICATEPASSWORD=your-certificate-password
+set MONITORING_HTTPS_ENABLEHSTS=true
+
+rem Authentication
+set MONITORING_AUTHENTICATION_ENABLED=true
+set MONITORING_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set MONITORING_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+rem Restrict CORS
+set MONITORING_CORS_ALLOWEDORIGINS=https://servicepulse
+
+rem No forwarded headers
+set MONITORING_FORWARDEDHEADERS_ENABLED=false
+```
+
+---
+
+### Scenario 3: End-to-End Encryption with Reverse Proxy
+
+For environments requiring encryption of internal traffic. The reverse proxy terminates external TLS and re-encrypts traffic to ServiceControl over HTTPS.
+
+**Architecture:**
+
+```text
+Client → HTTPS → Reverse Proxy → HTTPS → ServiceControl (Kestrel)
+ (TLS termination) (TLS + JWT validation)
+```
+
+**Security Features:**
+
+| Feature | Status |
+|----------------------------|--------------------------|
+| JWT Authentication | ✅ Enabled |
+| Kestrel HTTPS | ✅ Enabled |
+| HTTPS Redirection | N/A (no HTTP endpoint) |
+| HSTS | N/A (configure at proxy) |
+| Restricted CORS Origins | ✅ Enabled |
+| Forwarded Headers | ✅ Enabled |
+| Restricted Proxy Trust | ✅ Enabled |
+| Internal Traffic Encrypted | ✅ Yes |
+
+> **Note:** HTTPS redirection and HSTS are not applicable in this scenario because ServiceControl only exposes an HTTPS endpoint (Kestrel HTTPS is enabled). There is no HTTP endpoint to redirect from. The reverse proxy is responsible for redirecting external HTTP requests to HTTPS and sending HSTS headers to browsers. Compare this to Scenario 1, where Kestrel HTTPS is disabled and ServiceControl exposes an HTTP endpoint - in that case, HTTPS redirection can optionally be enabled as defense-in-depth.
+
+#### Primary Instance Configuration (End-to-End Encryption)
+
+**App.config:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Environment variables:**
+
+```cmd
+rem Host and Port
+set SERVICECONTROL_HOSTNAME=localhost
+set SERVICECONTROL_PORT=33333
+
+rem Remote Audit Instance(s)
+set SERVICECONTROL_REMOTEINSTANCES=[{"api_uri":"https://servicecontrol-audit/api"}]
+
+rem Kestrel HTTPS for internal encryption
+set SERVICECONTROL_HTTPS_ENABLED=true
+set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol-internal.pfx
+set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=your-certificate-password
+
+rem Authentication
+set SERVICECONTROL_AUTHENTICATION_ENABLED=true
+set SERVICECONTROL_AUTHENTICATION_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_AUDIENCE=api://servicecontrol
+
+rem ServicePulse client configuration
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_CLIENTID={servicepulse-client-id}
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0
+set SERVICECONTROL_AUTHENTICATION_SERVICEPULSE_APISCOPES=["api://servicecontrol/access_as_user"]
+
+rem Forwarded headers - trust only your reverse proxy
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=false
+set SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES=10.0.0.5
+
+rem Restrict CORS
+set SERVICECONTROL_CORS_ALLOWEDORIGINS=https://servicepulse
+```
+
+> **Note:** Audit and Monitoring instances follow the same pattern. See Scenario 1 for the authentication and forwarded headers configuration, and add the HTTPS settings as shown above.
+
+---
+
+## Certificate Management
+
+### Certificate Requirements
+
+- Use certificates from a trusted Certificate Authority (CA) for production
+- For internal deployments, an internal/enterprise CA is acceptable
+- Certificates must include the hostname in the Subject Alternative Name (SAN)
+- Minimum key size: 2048-bit RSA or 256-bit ECC
+
+### Certificate Formats
+
+ServiceControl supports PFX (PKCS#12) certificate files:
+
+```xml
+
+
+```
+
+### Certificate Storage Best Practices
+
+1. **File permissions**: Restrict access to certificate files to the service account running ServiceControl
+2. **Password protection**: Use strong passwords for PFX files
+3. **Secure storage**: Store certificates in a secure location with appropriate access controls
+4. **Avoid source control**: Never commit certificates or passwords to source control
+
+### Certificate Renewal
+
+1. Obtain a new certificate before the current one expires
+2. Replace the certificate file at the configured path
+3. Restart the ServiceControl service to load the new certificate
+4. Verify HTTPS connectivity after restart
+
+---
+
+## Configuration Reference
+
+### Authentication Settings
+
+| Setting | Type | Default | Description |
+|-------------------------------------------|--------|---------|-------------------------------------------------------------|
+| `Authentication.Enabled` | bool | `false` | Enable JWT Bearer authentication |
+| `Authentication.Authority` | string | - | OpenID Connect authority URL (required when enabled) |
+| `Authentication.Audience` | string | - | Expected audience for tokens (required when enabled) |
+| `Authentication.ValidateIssuer` | bool | `true` | Validate token issuer |
+| `Authentication.ValidateAudience` | bool | `true` | Validate token audience |
+| `Authentication.ValidateLifetime` | bool | `true` | Validate token expiration |
+| `Authentication.ValidateIssuerSigningKey` | bool | `true` | Validate token signing key |
+| `Authentication.RequireHttpsMetadata` | bool | `true` | Require HTTPS for metadata endpoint |
+| `Authentication.ServicePulse.ClientId` | string | - | OAuth client ID for ServicePulse (Primary only) |
+| `Authentication.ServicePulse.Authority` | string | - | Authority URL for ServicePulse (defaults to main Authority) |
+| `Authentication.ServicePulse.ApiScopes` | string | - | API scopes for ServicePulse to request |
+
+### HTTPS Settings
+
+| Setting | Type | Default | Description |
+|-------------------------------|--------|------------|--------------------------------------------------------|
+| `Https.Enabled` | bool | `false` | Enable Kestrel HTTPS with certificate |
+| `Https.CertificatePath` | string | - | Path to PFX certificate file |
+| `Https.CertificatePassword` | string | - | Certificate password |
+| `Https.RedirectHttpToHttps` | bool | `false` | Redirect HTTP requests to HTTPS |
+| `Https.Port` | int | - | HTTPS port for redirects (required with reverse proxy) |
+| `Https.EnableHsts` | bool | `false` | Enable HTTP Strict Transport Security |
+| `Https.HstsMaxAgeSeconds` | int | `31536000` | HSTS max-age in seconds (1 year) |
+| `Https.HstsIncludeSubDomains` | bool | `false` | Include subdomains in HSTS |
+
+### Forwarded Headers Settings
+
+> **⚠️ Security Warning:** Never set `TrustAllProxies` to `true` in production when ServiceControl is accessible from untrusted networks. This can allow attackers to spoof client IP addresses and bypass security controls.
+
+| Setting | Type | Default | Description |
+|------------------------------------|--------|---------|-----------------------------------------------|
+| `ForwardedHeaders.Enabled` | bool | `true` | Enable forwarded headers processing |
+| `ForwardedHeaders.TrustAllProxies` | bool | `true` | Trust X-Forwarded-* from any source |
+| `ForwardedHeaders.KnownProxies` | string | - | Comma-separated list of trusted proxy IPs |
+| `ForwardedHeaders.KnownNetworks` | string | - | Comma-separated list of trusted CIDR networks |
+
+> **Note:** If `KnownProxies` or `KnownNetworks` are configured, `TrustAllProxies` is automatically set to `false`.
+
+### CORS Settings
+
+| Setting | Type | Default | Description |
+|-----------------------|--------|---------|-----------------------------------------|
+| `Cors.AllowAnyOrigin` | bool | `true` | Allow requests from any origin |
+| `Cors.AllowedOrigins` | string | - | Comma-separated list of allowed origins |
+
+> **Note:** If `AllowedOrigins` is configured, `AllowAnyOrigin` is automatically set to `false`.
+
+### Host Settings
+
+| Setting | Type | Default | Description |
+|------------|--------|-------------|-----------------------|
+| `Hostname` | string | `localhost` | Hostname to listen on |
+| `Port` | int | varies | Port to listen on |
+
+### Remote Instance Settings (Primary Only)
+
+| Setting | Type | Default | Description |
+|-------------------|--------|---------|------------------------------------------|
+| `RemoteInstances` | string | - | JSON array of remote Audit instance URIs |
+
+---
+
+## Scenario Comparison Matrix
+
+| Feature | Reverse Proxy + Auth | Direct HTTPS + Auth | End-to-End Encryption |
+|--------------------------------|:--------------------:|:-------------------:|:---------------------:|
+| **JWT Authentication** | ✅ | ✅ | ✅ |
+| **Kestrel HTTPS** | ❌ | ✅ | ✅ |
+| **HTTPS Redirection** | ✅ (optional) | ✅ | ❌ (at proxy) |
+| **HSTS** | ❌ (at proxy) | ✅ | ❌ (at proxy) |
+| **Restricted CORS** | ✅ | ✅ | ✅ |
+| **Forwarded Headers** | ✅ | ❌ | ✅ |
+| **Restricted Proxy Trust** | ✅ | N/A | ✅ |
+| **Internal Traffic Encrypted** | ❌ | ✅ | ✅ |
+| | | | |
+| **Requires Reverse Proxy** | Yes | No | Yes |
+| **Certificate Management** | At proxy only | At ServiceControl | Both |
+
+---
+
+## See Also
+
+- [Authentication Configuration](authentication.md) - Detailed authentication setup guide
+- [HTTPS Configuration](https-configuration.md) - Detailed HTTPS setup guide
+- [Forwarded Headers Configuration](forwarded-headers.md) - Forwarded headers reference
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Local testing with NGINX
+- [Authentication Testing](authentication-testing.md) - Testing authentication scenarios
diff --git a/docs/https-configuration.md b/docs/https-configuration.md
new file mode 100644
index 0000000000..9dfdf0680a
--- /dev/null
+++ b/docs/https-configuration.md
@@ -0,0 +1,144 @@
+# HTTPS Configuration
+
+ServiceControl instances can be configured to use HTTPS directly, enabling encrypted connections without relying on a reverse proxy for SSL termination.
+
+## Configuration
+
+ServiceControl instances can be configured via environment variables or App.config. Each instance type uses a different prefix.
+
+### Environment Variables
+
+| Instance | Prefix |
+|---------------------------|-------------------------|
+| ServiceControl (Primary) | `SERVICECONTROL_` |
+| ServiceControl.Audit | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `MONITORING_` |
+
+| Setting | Default | Description |
+|---------------------------------------|------------|----------------------------------------------------------------|
+| `{PREFIX}HTTPS_ENABLED` | `false` | Enable HTTPS with Kestrel |
+| `{PREFIX}HTTPS_CERTIFICATEPATH` | (none) | Path to the certificate file (.pfx) |
+| `{PREFIX}HTTPS_CERTIFICATEPASSWORD` | (none) | Password for the certificate file |
+| `{PREFIX}HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP requests to HTTPS |
+| `{PREFIX}HTTPS_PORT` | (none) | HTTPS port for redirect (required for reverse proxy scenarios) |
+| `{PREFIX}HTTPS_ENABLEHSTS` | `false` | Enable HTTP Strict Transport Security |
+| `{PREFIX}HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age in seconds (default: 1 year) |
+| `{PREFIX}HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS policy |
+
+### App.config
+
+| Instance | Key Prefix |
+|---------------------------|-------------------------|
+| ServiceControl (Primary) | `ServiceControl/` |
+| ServiceControl.Audit | `ServiceControl.Audit/` |
+| ServiceControl.Monitoring | `Monitoring/` |
+
+```xml
+
+
+
+
+
+
+```
+
+## Examples
+
+### Direct HTTPS with certificate
+
+```cmd
+set SERVICECONTROL_HTTPS_ENABLED=true
+set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\certs\servicecontrol.pfx
+set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=mycertpassword
+```
+
+### Docker Example
+
+```cmd
+docker run -p 33333:33333 -e SERVICECONTROL_HTTPS_ENABLED=true -e SERVICECONTROL_HTTPS_CERTIFICATEPATH=/certs/servicecontrol.pfx -e SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=mycertpassword -v C:\certs:/certs:ro particular/servicecontrol:latest
+```
+
+### Reverse proxy with HTTP to HTTPS redirect
+
+When using a reverse proxy that terminates SSL:
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true
+set SERVICECONTROL_HTTPS_PORT=443
+```
+
+## Security Considerations
+
+### Certificate Password Security
+
+The certificate password is read as plain text from configuration. To minimize security risks:
+
+#### Option 1: Use a certificate without a password (Recommended)
+
+If the certificate file is protected with proper file system permissions, a password may not be necessary:
+
+```bash
+# Export certificate without password protection
+openssl pkcs12 -in cert-with-password.pfx -out cert-no-password.pfx -nodes
+```
+
+Then restrict file access:
+
+- **Windows:** Grant read access only to the service account running ServiceControl
+- **Linux/Container:** Set file permissions to `400` (owner read only)
+
+#### Option 2: Use platform secrets management
+
+For container and cloud deployments, use the platform's secrets management instead of plain environment variables:
+
+| Platform | Secrets Solution |
+|----------|------------------|
+| Kubernetes | [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) mounted as environment variables |
+| Docker Swarm | [Docker Secrets](https://docs.docker.com/engine/swarm/secrets/) |
+| Azure | [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/) with managed identity |
+| AWS | [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) or [SSM Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) |
+
+#### Option 3: Restrict file system access
+
+If you must use a password-protected certificate:
+
+- Never commit certificates or passwords to source control
+- Restrict read access to the certificate file to only the ServiceControl service account
+- Use environment variables rather than App.config (environment variables are not persisted to disk)
+- Consider using Windows DPAPI or similar platform-specific encryption for config files
+
+### Certificate File Security
+
+- Store certificate files securely with appropriate file permissions
+- Rotate certificates before expiration
+- Use certificates from a trusted Certificate Authority for production
+- Never commit certificate files to source control
+
+### HSTS Considerations
+
+- HSTS should not be tested on localhost because browsers cache the policy, which could break other local development
+- HSTS is disabled in Development environment (ASP.NET Core excludes localhost by default)
+- HSTS can be configured at either the reverse proxy level or in ServiceControl (but not both)
+- HSTS is cached by browsers, so test carefully before enabling in production
+- Start with a short max-age during initial deployment
+- Consider the impact on subdomains before enabling `includeSubDomains`
+- To test HSTS locally, use the [NGINX reverse proxy setup](reverseproxy-testing.md) with a custom hostname
+
+### HTTP to HTTPS Redirect
+
+The `HTTPS_REDIRECTHTTPTOHTTPS` setting is intended for use with a reverse proxy that handles both HTTP and HTTPS traffic. When enabled:
+
+- The redirect uses HTTP 307 (Temporary Redirect) to preserve the request method
+- The reverse proxy must forward both HTTP and HTTPS requests to ServiceControl
+- ServiceControl will redirect HTTP requests to HTTPS based on the `X-Forwarded-Proto` header
+- **Important:** You must also set `HTTPS_PORT` to specify the HTTPS port for the redirect URL
+
+> **Note:** When running ServiceControl directly without a reverse proxy, the application only listens on a single protocol (HTTP or HTTPS). To test HTTP-to-HTTPS redirection locally, use the [NGINX reverse proxy setup](reverseproxy-testing.md).
+
+## See Also
+
+- [HTTPS Testing](https-testing.md) - Guide for testing HTTPS locally during development
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Testing with NGINX reverse proxy (HSTS, HTTP to HTTPS redirect)
+- [Forwarded Headers Configuration](forwarded-headers.md) - Configure forwarded headers when behind a reverse proxy
diff --git a/docs/https-testing.md b/docs/https-testing.md
new file mode 100644
index 0000000000..9e57ca5484
--- /dev/null
+++ b/docs/https-testing.md
@@ -0,0 +1,262 @@
+# Local Testing with Direct HTTPS
+
+This guide provides scenario-based tests for ServiceControl's direct HTTPS features. Use this to verify Kestrel HTTPS behavior without a reverse proxy.
+
+> **Note:** HTTP to HTTPS redirection (`RedirectHttpToHttps`) is designed for reverse proxy scenarios where the proxy forwards HTTP requests to ServiceControl. When running with direct HTTPS, ServiceControl only binds to a single port (HTTPS). To test HTTP to HTTPS redirection, see [Reverse Proxy Testing](reverseproxy-testing.md).
+
+## Instance Reference
+
+| Instance | Project Directory | Default Port | Environment Variable Prefix | App.config Key Prefix |
+|---------------------------|---------------------------------|--------------|-----------------------------|-------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` | `ServiceControl/` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` | `ServiceControl.Audit/` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` | `Monitoring/` |
+
+> **Note:** Environment variables must include the instance prefix (e.g., `SERVICECONTROL_HTTPS_ENABLED` for the primary instance).
+
+## Prerequisites
+
+- [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates
+- ServiceControl built locally (see main README for build instructions)
+- curl (included with Windows 10/11, Git Bash, or WSL)
+
+## Enabling Debug Logs
+
+To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance:
+
+```cmd
+rem ServiceControl Primary
+set SERVICECONTROL_LOGLEVEL=Debug
+
+rem ServiceControl.Audit
+set SERVICECONTROL_AUDIT_LOGLEVEL=Debug
+
+rem ServiceControl.Monitoring
+set MONITORING_LOGLEVEL=Debug
+```
+
+**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`)
+
+Debug logs will show detailed HTTPS configuration and certificate loading information.
+
+### Installing mkcert
+
+**Windows (using Chocolatey):**
+
+```powershell
+choco install mkcert
+```
+
+**Windows (using Scoop):**
+
+```powershell
+scoop install mkcert
+```
+
+**macOS (using Homebrew):**
+
+```bash
+brew install mkcert
+```
+
+**Linux:**
+
+```bash
+# Debian/Ubuntu
+sudo apt install libnss3-tools
+# Then download from https://github.com/FiloSottile/mkcert/releases
+
+# Arch Linux
+sudo pacman -S mkcert
+```
+
+After installing, run `mkcert -install` to install the local CA in your system trust store.
+
+## Setup
+
+### Step 1: Create the Local Development Folder
+
+Create a `.local` folder in the repository root (this folder is gitignored):
+
+```bash
+mkdir .local
+mkdir .local/certs
+```
+
+### Step 2: Generate PFX Certificates
+
+Kestrel requires certificates in PFX format. Use mkcert to generate them:
+
+```bash
+# Install mkcert's root CA (one-time setup)
+mkcert -install
+
+# Navigate to the certs folder
+cd .local/certs
+
+# Generate PFX certificate for localhost
+mkcert -p12-file localhost.pfx -pkcs12 localhost 127.0.0.1 ::1 servicecontrol servicecontrol-audit servicecontrol-monitor
+```
+
+When prompted for a password, you can use an empty password by pressing Enter, or set a password (e.g., `changeit`) and note it for the configuration step.
+
+## Test Scenarios
+
+All scenarios use environment variables for configuration. Run each scenario from the `src/ServiceControl` directory.
+
+### Scenario 1: Basic HTTPS Connectivity
+
+Verify that HTTPS is working with a valid certificate.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_HTTPS_ENABLED=true
+set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx
+set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=changeit
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false
+
+dotnet run
+```
+
+**Test with curl:**
+
+```cmd
+curl --ssl-no-revoke -v https://localhost:33333/api 2>&1 | findstr /C:"HTTP/" /C:"SSL"
+```
+
+> **Note:** The `--ssl-no-revoke` flag is required on Windows because mkcert certificates don't have CRL distribution points, causing `CRYPT_E_NO_REVOCATION_CHECK` errors.
+
+**Expected output:**
+
+```text
+* schannel: SSL/TLS connection renegotiated
+< HTTP/1.1 200 OK
+```
+
+The request succeeds over HTTPS. The exact SSL output varies by curl version and platform, but you should see `HTTP/1.1 200 OK` confirming success.
+
+### Scenario 2: HTTP Disabled (HTTPS Only)
+
+Verify that HTTP requests fail when only HTTPS is enabled.
+
+**Cleanup and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_HTTPS_ENABLED=true
+set SERVICECONTROL_HTTPS_CERTIFICATEPATH=C:\path\to\ServiceControl\.local\certs\localhost.pfx
+set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=changeit
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=false
+
+dotnet run
+```
+
+**Test with curl (HTTP):**
+
+```cmd
+curl http://localhost:33333/api
+```
+
+**Expected output:**
+
+```text
+curl: (52) Empty reply from server
+```
+
+HTTP requests fail because Kestrel is listening for HTTPS but receives plaintext HTTP, which it cannot process. The server closes the connection without responding.
+
+## HTTPS Configuration Reference
+
+| App.config Key | Environment Variable (Primary) | Default | Description |
+|-------------------------------|----------------------------------------------|------------|------------------------------------------------------|
+| `Https.Enabled` | `SERVICECONTROL_HTTPS_ENABLED` | `false` | Enable Kestrel HTTPS |
+| `Https.CertificatePath` | `SERVICECONTROL_HTTPS_CERTIFICATEPATH` | - | Path to PFX certificate file |
+| `Https.CertificatePassword` | `SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD` | - | Certificate password (empty string for no password) |
+| `Https.RedirectHttpToHttps` | `SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP requests to HTTPS (reverse proxy only) |
+| `Https.EnableHsts` | `SERVICECONTROL_HTTPS_ENABLEHSTS` | `false` | Enable HTTP Strict Transport Security |
+| `Https.HstsMaxAgeSeconds` | `SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) |
+| `Https.HstsIncludeSubDomains` | `SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS |
+
+> **Note:** For other instances, replace the `SERVICECONTROL_` prefix with the appropriate instance prefix (see Instance Reference table).
+>
+> **Note:** HSTS is not tested locally because ASP.NET Core excludes localhost from HSTS by default (to prevent accidentally caching HSTS during development). HSTS will work correctly in production with non-localhost hostnames.
+
+## Testing Other Instances
+
+The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring:
+
+1. Use the appropriate environment variable prefix (see Instance Reference above)
+2. Use the corresponding project directory and port
+
+| Instance | Project Directory | Port | Env Var Prefix |
+|---------------------------|---------------------------------|-------|-------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `MONITORING_` |
+
+## Troubleshooting
+
+### Certificate not found
+
+Ensure the `CertificatePath` is an absolute path and the file exists.
+
+### Certificate password incorrect
+
+If you set a password when generating the PFX, ensure it matches `CertificatePassword` in the config.
+
+### Certificate errors in browser/curl
+
+1. Ensure mkcert's root CA is installed: `mkcert -install`
+2. Restart your browser after installing the root CA
+
+### CRYPT_E_NO_REVOCATION_CHECK error in curl
+
+Windows curl fails to check certificate revocation for mkcert certificates because they don't have CRL distribution points. Use the `--ssl-no-revoke` flag:
+
+```cmd
+curl --ssl-no-revoke https://localhost:33333/api
+```
+
+### Port already in use
+
+Ensure no other process is using the ServiceControl ports (33333, 44444, 33633).
+
+## Cleanup
+
+After testing, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICECONTROL_HTTPS_ENABLED=
+set SERVICECONTROL_HTTPS_CERTIFICATEPATH=
+set SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+set SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS=
+set SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS=
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+```
+
+**PowerShell:**
+
+```powershell
+$env:SERVICECONTROL_HTTPS_ENABLED = $null
+$env:SERVICECONTROL_HTTPS_CERTIFICATEPATH = $null
+$env:SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD = $null
+$env:SERVICECONTROL_HTTPS_ENABLEHSTS = $null
+$env:SERVICECONTROL_HTTPS_HSTSMAXAGESECONDS = $null
+$env:SERVICECONTROL_HTTPS_HSTSINCLUDESUBDOMAINS = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null
+```
+
+## See Also
+
+- [Hosting Guide](hosting-guide.md) - Detailed configuration reference for all deployment scenarios
+- [Reverse Proxy Testing](reverseproxy-testing.md) - Testing with a reverse proxy (NGINX)
+- [Forwarded Headers Testing](forward-headers-testing.md) - Testing forwarded headers without a reverse proxy
diff --git a/docs/reverseproxy-testing.md b/docs/reverseproxy-testing.md
new file mode 100644
index 0000000000..121b4b8809
--- /dev/null
+++ b/docs/reverseproxy-testing.md
@@ -0,0 +1,554 @@
+# Local Testing with NGINX Reverse Proxy
+
+This guide provides scenario-based tests for ServiceControl instances behind an NGINX reverse proxy. Use this to verify:
+
+- SSL/TLS termination at the reverse proxy
+- Forwarded headers handling (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`)
+- HTTP to HTTPS redirection
+- HSTS (HTTP Strict Transport Security)
+- WebSocket support (SignalR)
+
+## Instance Reference
+
+| Instance | Project Directory | Default Port | Hostname | Environment Variable Prefix |
+|---------------------------|---------------------------------|--------------|------------------------------------|-----------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | 33333 | `servicecontrol.localhost` | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | 44444 | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | 33633 | `servicecontrol-monitor.localhost` | `MONITORING_` |
+
+## Prerequisites
+
+- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running
+- [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates
+- ServiceControl built locally (see main README for build instructions)
+- curl (included with Windows 10/11, Git Bash, or WSL)
+
+## Enabling Debug Logs
+
+To enable detailed logging for troubleshooting, set the `LogLevel` environment variable before starting each instance:
+
+```cmd
+rem ServiceControl Primary
+set SERVICECONTROL_LOGLEVEL=Debug
+
+rem ServiceControl.Audit
+set SERVICECONTROL_AUDIT_LOGLEVEL=Debug
+
+rem ServiceControl.Monitoring
+set MONITORING_LOGLEVEL=Debug
+```
+
+**Valid log levels:** `Trace`, `Debug`, `Information` (or `Info`), `Warning` (or `Warn`), `Error`, `Critical` (or `Fatal`), `None` (or `Off`)
+
+Debug logs will show detailed request processing information including forwarded headers handling and HTTPS redirection.
+
+### Installing mkcert
+
+**Windows (using Chocolatey):**
+
+```cmd
+choco install mkcert
+```
+
+**Windows (using Scoop):**
+
+```cmd
+scoop install mkcert
+```
+
+After installing, run `mkcert -install` to install the local CA in your system trust store.
+
+## Setup
+
+### Step 1: Create the Local Development Folder
+
+Create a `.local` folder in the repository root (this folder is gitignored):
+
+```cmd
+mkdir .local
+mkdir .local\certs
+```
+
+### Step 2: Generate SSL Certificates
+
+Use mkcert to generate trusted local development certificates:
+
+```cmd
+mkcert -install
+cd .local\certs
+mkcert -cert-file local-platform.pem -key-file local-platform-key.pem servicecontrol.localhost servicecontrol-audit.localhost servicecontrol-monitor.localhost localhost
+```
+
+### Step 3: Create Docker Compose Configuration
+
+Create `.local/compose.yml`:
+
+```yaml
+services:
+ reverse-proxy-servicecontrol:
+ image: nginx:alpine
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./certs/local-platform.pem:/etc/nginx/certs/local.pem:ro
+ - ./certs/local-platform-key.pem:/etc/nginx/certs/local-key.pem:ro
+```
+
+### Step 4: Create NGINX Configuration
+
+Create `.local/nginx.conf`:
+
+```nginx
+events { worker_connections 1024; }
+
+http {
+ # WebSocket support: set connection to 'upgrade' if Upgrade header present
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ # Shared SSL Settings
+ ssl_certificate /etc/nginx/certs/local.pem;
+ ssl_certificate_key /etc/nginx/certs/local-key.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+
+ # ServiceControl (Primary) - HTTPS
+ server {
+ listen 443 ssl;
+ server_name servicecontrol.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:33333;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl (Primary) - HTTP (for testing HTTP-to-HTTPS redirect)
+ server {
+ listen 80;
+ server_name servicecontrol.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:33333;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl.Audit - HTTPS
+ server {
+ listen 443 ssl;
+ server_name servicecontrol-audit.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:44444;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl.Audit - HTTP (for testing HTTP-to-HTTPS redirect)
+ server {
+ listen 80;
+ server_name servicecontrol-audit.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:44444;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl.Monitoring - HTTPS
+ server {
+ listen 443 ssl;
+ server_name servicecontrol-monitor.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:33633;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+
+ # ServiceControl.Monitoring - HTTP (for testing HTTP-to-HTTPS redirect)
+ server {
+ listen 80;
+ server_name servicecontrol-monitor.localhost;
+
+ location / {
+ proxy_pass http://host.docker.internal:33633;
+
+ # WebSocket Support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Forwarded Headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ }
+ }
+}
+```
+
+### Step 5: Configure Hosts File
+
+Add the following entries to your hosts file (`C:\Windows\System32\drivers\etc\hosts`):
+
+```text
+127.0.0.1 servicecontrol.localhost
+127.0.0.1 servicecontrol-audit.localhost
+127.0.0.1 servicecontrol-monitor.localhost
+```
+
+### Step 6: Start the NGINX Reverse Proxy
+
+From the repository root:
+
+```cmd
+docker compose -f .local/compose.yml up -d
+```
+
+### Step 7: Final Directory Structure
+
+After completing the setup, your `.local` folder should look like:
+
+```text
+.local/
+├── compose.yml
+├── nginx.conf
+└── certs/
+ ├── local-platform.pem
+ └── local-platform-key.pem
+```
+
+## Test Scenarios
+
+> **Important:** ServiceControl must be running before testing. A 502 Bad Gateway error means NGINX cannot reach ServiceControl.
+> **Note:** Use `TRUSTALLPROXIES=true` for local Docker testing. The NGINX container's IP address varies based on Docker's network configuration (e.g., `172.x.x.x`), making it impractical to specify a fixed `KNOWNPROXIES` value.
+
+### Scenario 1: HTTPS Access
+
+Verify that HTTPS is working through the reverse proxy.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+
+cd src\ServiceControl
+dotnet run --no-launch-profile
+```
+
+**Test with curl:**
+
+```cmd
+curl -k -v https://servicecontrol.localhost/api 2>&1 | findstr /C:"HTTP/"
+```
+
+**Expected output:**
+
+```text
+< HTTP/1.1 200 OK
+```
+
+The request succeeds over HTTPS through the NGINX reverse proxy.
+
+### Scenario 2: Forwarded Headers Processing
+
+Verify that forwarded headers are being processed correctly.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+
+cd src\ServiceControl
+dotnet run --no-launch-profile
+```
+
+**Test with curl:**
+
+```cmd
+curl -k https://servicecontrol.localhost/debug/request-info | json
+```
+
+**Expected output:**
+
+```json
+{
+ "processed": {
+ "scheme": "https",
+ "host": "servicecontrol.localhost",
+ "remoteIpAddress": "172.x.x.x"
+ },
+ "rawHeaders": {
+ "xForwardedFor": "",
+ "xForwardedProto": "",
+ "xForwardedHost": ""
+ },
+ "configuration": {
+ "enabled": true,
+ "trustAllProxies": true,
+ "knownProxies": [],
+ "knownNetworks": []
+ }
+}
+```
+
+The key indicators that forwarded headers are working:
+
+- `processed.scheme` is `https` (from `X-Forwarded-Proto`)
+- `processed.host` is `servicecontrol.localhost` (from `X-Forwarded-Host`)
+- `rawHeaders` are empty because the middleware consumed them (trusted proxy)
+
+### Scenario 3: HTTP to HTTPS Redirect
+
+Verify that HTTP requests are redirected to HTTPS.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=true
+set SERVICECONTROL_HTTPS_PORT=443
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+
+cd src\ServiceControl
+dotnet run --no-launch-profile
+```
+
+**Test with curl:**
+
+```cmd
+curl -v http://servicecontrol.localhost/api 2>&1 | findstr /i location
+```
+
+**Expected output:**
+
+```text
+< Location: https://servicecontrol.localhost/api
+```
+
+HTTP requests are redirected to HTTPS with a 307 (Temporary Redirect) status.
+
+### Scenario 4: HSTS
+
+Verify that the HSTS header is included in HTTPS responses.
+
+> **Note:** HSTS is disabled in Development environment. You must use `--no-launch-profile` to prevent launchSettings.json from overriding it.
+
+**Clear environment variables and start ServiceControl:**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=true
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=true
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=true
+
+cd src\ServiceControl
+dotnet run --environment Production --no-launch-profile
+```
+
+**Test with curl:**
+
+```cmd
+curl -k -v https://servicecontrol.localhost/api 2>&1 | findstr /i strict-transport-security
+```
+
+**Expected output:**
+
+```text
+< Strict-Transport-Security: max-age=31536000
+```
+
+The HSTS header is present with the default max-age of 1 year.
+
+## Testing Other Instances
+
+The scenarios above use ServiceControl (Primary). To test ServiceControl.Audit or ServiceControl.Monitoring:
+
+1. Use the appropriate environment variable prefix (see Configuration Reference below)
+2. Use the corresponding project directory and hostname
+
+| Instance | Project Directory | Hostname | Env Var Prefix |
+|---------------------------|---------------------------------|------------------------------------|-------------------------|
+| ServiceControl (Primary) | `src\ServiceControl` | `servicecontrol.localhost` | `SERVICECONTROL_` |
+| ServiceControl.Audit | `src\ServiceControl.Audit` | `servicecontrol-audit.localhost` | `SERVICECONTROL_AUDIT_` |
+| ServiceControl.Monitoring | `src\ServiceControl.Monitoring` | `servicecontrol-monitor.localhost` | `MONITORING_` |
+
+## Configuration Reference
+
+| Environment Variable | Default | Description |
+|---------------------------------------------|------------|---------------------------------------------|
+| `{PREFIX}_FORWARDEDHEADERS_ENABLED` | `true` | Enable forwarded headers processing |
+| `{PREFIX}_FORWARDEDHEADERS_TRUSTALLPROXIES` | `true` | Trust all proxies |
+| `{PREFIX}_FORWARDEDHEADERS_KNOWNPROXIES` | - | Comma-separated list of trusted proxy IPs |
+| `{PREFIX}_FORWARDEDHEADERS_KNOWNNETWORKS` | - | Comma-separated list of trusted CIDR ranges |
+| `{PREFIX}_HTTPS_REDIRECTHTTPTOHTTPS` | `false` | Redirect HTTP to HTTPS |
+| `{PREFIX}_HTTPS_PORT` | - | HTTPS port for redirect |
+| `{PREFIX}_HTTPS_ENABLEHSTS` | `false` | Enable HSTS |
+| `{PREFIX}_HTTPS_HSTSMAXAGESECONDS` | `31536000` | HSTS max-age (1 year) |
+| `{PREFIX}_HTTPS_HSTSINCLUDESUBDOMAINS` | `false` | Include subdomains in HSTS |
+
+Where `{PREFIX}` is:
+
+- `SERVICECONTROL` for ServiceControl (Primary)
+- `SERVICECONTROL_AUDIT` for ServiceControl.Audit
+- `MONITORING` for ServiceControl.Monitoring
+
+## Cleanup
+
+### Stop NGINX
+
+```cmd
+docker compose -f .local/compose.yml down
+```
+
+### Clear Environment Variables
+
+After testing, clear the environment variables:
+
+**Command Prompt (cmd):**
+
+```cmd
+set SERVICECONTROL_FORWARDEDHEADERS_ENABLED=
+set SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES=
+set SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS=
+set SERVICECONTROL_HTTPS_PORT=
+set SERVICECONTROL_HTTPS_ENABLEHSTS=
+```
+
+**PowerShell:**
+
+```powershell
+$env:SERVICECONTROL_FORWARDEDHEADERS_ENABLED = $null
+$env:SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES = $null
+$env:SERVICECONTROL_HTTPS_REDIRECTHTTPTOHTTPS = $null
+$env:SERVICECONTROL_HTTPS_PORT = $null
+$env:SERVICECONTROL_HTTPS_ENABLEHSTS = $null
+```
+
+### Remove Hosts Entries (Optional)
+
+If you no longer need the hostnames, remove these entries from your hosts file (`C:\Windows\System32\drivers\etc\hosts`):
+
+```text
+127.0.0.1 servicecontrol.localhost
+127.0.0.1 servicecontrol-audit.localhost
+127.0.0.1 servicecontrol-monitor.localhost
+```
+
+## Troubleshooting
+
+### 502 Bad Gateway
+
+This error means NGINX cannot reach ServiceControl. Check:
+
+1. ServiceControl is running (`dotnet run` in the appropriate project directory)
+2. ServiceControl is accessible directly: `curl http://localhost:33333/api`
+3. Docker Desktop is running and `host.docker.internal` resolves correctly
+
+### "Connection refused" errors
+
+Ensure ServiceControl instances are running and listening on the expected ports:
+
+- ServiceControl (Primary): 33333
+- ServiceControl.Audit: 44444
+- ServiceControl.Monitoring: 33633
+
+### Headers not being applied
+
+1. Verify `FORWARDEDHEADERS_ENABLED` is `true`
+2. Verify `FORWARDEDHEADERS_TRUSTALLPROXIES` is `true` (for local Docker testing)
+3. Use the `/debug/request-info` endpoint to check current settings
+
+### Certificate errors in browser
+
+1. Ensure mkcert's root CA is installed: `mkcert -install`
+2. Restart your browser after installing the root CA
+
+### Docker networking issues
+
+If using Docker Desktop on Windows with WSL2:
+
+- Ensure `host.docker.internal` resolves correctly
+- Check that the ServiceControl ports are not blocked by Windows Firewall
+
+### Debug endpoint not available
+
+The `/debug/request-info` endpoint is only available when running in Development environment (the default when using `dotnet run`).
+
+## See Also
+
+- [Hosting Guide](hosting-guide.md) - Configuration reference for all deployment scenarios
+- [Forwarded Headers Testing](forward-headers-testing.md) - Testing forwarded headers without a reverse proxy
diff --git a/docs/testing-architecture.md b/docs/testing-architecture.md
new file mode 100644
index 0000000000..ea1d5000e7
--- /dev/null
+++ b/docs/testing-architecture.md
@@ -0,0 +1,527 @@
+# Testing Architecture
+
+This document provides a comprehensive overview of the testing architecture in ServiceControl. It is intended to help developers understand how tests are structured and where to add tests for new functionality.
+
+For a summary of test types, see [testing.md](testing.md). For manual testing scenarios, see [testing-scenarios.md](testing-scenarios.md).
+
+## Test Projects Overview
+
+The repository contains 28 test projects organized into several categories:
+
+### Unit Test Projects
+
+| Project | Purpose |
+|-----------------------------------------------|---------------------------------|
+| `ServiceControl.UnitTests` | Primary instance unit tests |
+| `ServiceControl.Audit.UnitTests` | Audit instance unit tests |
+| `ServiceControl.Monitoring.UnitTests` | Monitoring instance unit tests |
+| `ServiceControl.Infrastructure.Tests` | Shared infrastructure tests |
+| `ServiceControl.Config.Tests` | WPF configuration UI tests |
+| `ServiceControlInstaller.Engine.UnitTests` | Windows service installer tests |
+| `ServiceControlInstaller.Packaging.UnitTests` | Packaging utilities tests |
+| `Particular.LicensingComponent.UnitTests` | Licensing component tests |
+
+### Persistence Test Projects
+
+| Project | Purpose |
+|--------------------------------------------------|------------------------------------|
+| `ServiceControl.Persistence.Tests` | Abstract persistence layer tests |
+| `ServiceControl.Persistence.Tests.RavenDB` | RavenDB persistence implementation |
+| `ServiceControl.Persistence.Tests.InMemory` | In-memory persistence tests |
+| `ServiceControl.Audit.Persistence.Tests` | Audit persistence abstractions |
+| `ServiceControl.Audit.Persistence.Tests.RavenDB` | Audit RavenDB tests |
+
+### Acceptance Test Projects
+
+| Project | Purpose |
+|------------------------------------------------|----------------------------------------------|
+| `ServiceControl.AcceptanceTests` | Primary instance shared acceptance test code |
+| `ServiceControl.AcceptanceTests.RavenDB` | Primary instance with RavenDB |
+| `ServiceControl.Audit.AcceptanceTests` | Audit instance shared acceptance test code |
+| `ServiceControl.Audit.AcceptanceTests.RavenDB` | Audit with RavenDB |
+| `ServiceControl.Monitoring.AcceptanceTests` | Monitoring instance acceptance tests |
+| `ServiceControl.MultiInstance.AcceptanceTests` | Multi-instance integration tests |
+
+### Transport Test Projects
+
+| Project | Filter Value |
+|----------------------------------------------------------------------|------------------------------|
+| `ServiceControl.Transports.Tests` | Default (Learning Transport) |
+| `ServiceControl.Transports.ASBS.Tests` | AzureServiceBus |
+| `ServiceControl.Transports.ASQ.Tests` | AzureStorageQueues |
+| `ServiceControl.Transports.Msmq.Tests` | MSMQ |
+| `ServiceControl.Transports.PostgreSql.Tests` | PostgreSql |
+| `ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests` | RabbitMQ |
+| `ServiceControl.Transports.RabbitMQClassicDirectRouting.Tests` | RabbitMQ |
+| `ServiceControl.Transports.RabbitMQQuorumConventionalRouting.Tests` | RabbitMQ |
+| `ServiceControl.Transports.RabbitMQQuorumDirectRouting.Tests` | RabbitMQ |
+| `ServiceControl.Transports.SqlServer.Tests` | SqlServer |
+| `ServiceControl.Transports.SQS.Tests` | SQS |
+
+## Testing Framework and Conventions
+
+All projects use:
+
+- **Framework**: NUnit 3.x
+- **Test Adapter**: NUnit3TestAdapter
+- **SDK**: Microsoft.NET.Test.Sdk
+- **Target Framework**: `net8.0` (Windows-specific tests use `net8.0-windows`)
+
+### Test Class Structure
+
+```csharp
+[TestFixture]
+[FixtureLifeCycle(LifeCycle.InstancePerTestCase)] // Each test gets fresh instance
+public class MyTests
+{
+ [SetUp]
+ public async Task Setup()
+ {
+ // Per-test initialization
+ }
+
+ [TearDown]
+ public async Task TearDown()
+ {
+ // Per-test cleanup
+ }
+
+ [Test]
+ public async Task Should_do_something()
+ {
+ // Arrange, Act, Assert
+ }
+}
+```
+
+### Approval Testing
+
+Used for API contracts and serialization verification:
+
+```csharp
+[Test]
+public void VerifyApiContract()
+{
+ var result = GetApiContract();
+ Approver.Verify(result);
+}
+```
+
+Baseline files stored in `ApprovalFiles/` directories with naming pattern: `{TestName}.{Method}.approved.txt`
+
+## Transport Filtering System
+
+Tests can be filtered by transport using the `ServiceControl_TESTS_FILTER` environment variable.
+
+### Filter Attributes
+
+Located in `src/TestHelper/IncludeInTestsAttribute.cs`:
+
+| Attribute | Filter Value |
+|--------------------------------------|--------------------|
+| `[IncludeInDefaultTests]` | Default |
+| `[IncludeInAzureServiceBusTests]` | AzureServiceBus |
+| `[IncludeInAzureStorageQueuesTests]` | AzureStorageQueues |
+| `[IncludeInMsmqTests]` | MSMQ |
+| `[IncludeInPostgreSqlTests]` | PostgreSql |
+| `[IncludeInRabbitMQTests]` | RabbitMQ |
+| `[IncludeInSqlServerTests]` | SqlServer |
+| `[IncludeInAmazonSqsTests]` | SQS |
+
+### Usage
+
+Apply at assembly level to include entire test project:
+
+```csharp
+[assembly: IncludeInDefaultTests()]
+```
+
+Run filtered tests:
+
+```powershell
+$env:ServiceControl_TESTS_FILTER = "Default"
+dotnet test src/ServiceControl.sln
+```
+
+## Base Classes and Utilities
+
+### Unit Test Base Classes
+
+For simple unit tests, no special base class is required. Use standard NUnit patterns.
+
+### Persistence Test Base Class
+
+Location: `src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs`
+
+```csharp
+[TestFixture]
+[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
+public abstract class PersistenceTestBase
+{
+ protected PersistenceSettings PersistenceSettings { get; }
+ protected IErrorMessageDataStore ErrorStore { get; }
+ protected IRetryDocumentDataStore RetryStore { get; }
+ protected IBodyStorage BodyStorage { get; }
+ protected IRetryBatchesDataStore RetryBatchesStore { get; }
+ protected IMessageRedirectsDataStore MessageRedirectsDataStore { get; }
+ protected IMonitoringDataStore MonitoringDataStore { get; }
+ protected ICustomChecksDataStore CustomChecks { get; }
+ protected IArchiveMessages ArchiveMessages { get; }
+ protected IEventLogDataStore EventLogDataStore { get; }
+ protected IServiceProvider ServiceProvider { get; }
+
+ // Async setup/teardown with embedded database management
+ [SetUp]
+ public async Task Setup();
+
+ [TearDown]
+ public async Task TearDown();
+}
+```
+
+### RavenDB Persistence Test Base
+
+Location: `src/ServiceControl.Persistence.Tests.RavenDB/RavenPersistenceTestBase.cs`
+
+Extends `PersistenceTestBase` with direct RavenDB access:
+
+```csharp
+public abstract class RavenPersistenceTestBase : PersistenceTestBase
+{
+ protected IDocumentStore DocumentStore { get; }
+ protected IRavenSessionProvider SessionProvider { get; }
+
+ // Debug helper - blocks test to inspect embedded database
+ protected void BlockToInspectDatabase();
+}
+```
+
+### Acceptance Test Base Class
+
+Location: `src/ServiceControl.AcceptanceTests/TestSupport/AcceptanceTest.cs`
+
+```csharp
+public abstract class AcceptanceTest : NServiceBusAcceptanceTest
+{
+ protected HttpClient HttpClient { get; }
+ protected JsonSerializerOptions SerializerOptions { get; }
+ protected IDomainEvents DomainEvents { get; }
+ protected Action SetSettings { get; set; }
+ protected Action CustomConfiguration { get; set; }
+ protected Action CustomizeHostBuilder { get; set; }
+
+ // Create a test scenario
+ protected IScenarioWithEndpointBehavior Define()
+ where T : ScenarioContext, new();
+}
+```
+
+### Transport Test Base Class
+
+Location: `src/ServiceControl.Transports.Tests/TransportTestFixture.cs`
+
+```csharp
+public abstract class TransportTestFixture
+{
+ protected TransportTestsConfiguration Configuration { get; }
+
+ // Setup test transport infrastructure
+ protected Task ProvisionQueues(params string[] queueNames);
+
+ // Start listening for messages
+ protected Task StartQueueIngestor(string queueName);
+
+ // Monitor queue depth
+ protected Task StartQueueLengthProvider(Action callback);
+
+ // Send and receive test messages
+ protected Task SendAndReceiveMessages(int messageCount);
+}
+```
+
+## Test Infrastructure Components
+
+### Shared Embedded RavenDB Server
+
+Location: `src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs`
+
+Provides singleton embedded RavenDB server with:
+
+- Semaphore-based concurrency control
+- Automatic database cleanup
+- Dynamic port assignment
+- Test database isolation with GUID-based names
+
+### Port Utility
+
+Location: `src/TestHelper/PortUtility.cs`
+
+Finds available ports for test services:
+
+```csharp
+var port = PortUtility.FindAvailablePort(startingPort: 33333);
+```
+
+### App Settings Fixture
+
+Location: Various test projects, `AppSettingsFixture.cs`
+
+One-time assembly setup that loads `app.config` settings into `ConfigurationManager`.
+
+## Directory Structure Within Test Projects
+
+Unit test projects are typically organized by domain:
+
+```text
+ServiceControl.UnitTests/
+├── API/ # API controller tests
+├── Recoverability/ # Retry and recovery logic
+├── Infrastructure/ # Extension and utility tests
+├── Monitoring/ # Monitoring component tests
+├── Notifications/ # Notification infrastructure
+├── Licensing/ # License validation tests
+├── BodyStorage/ # Message body storage
+├── ExternalIntegrations/ # External system integration
+├── ApprovalFiles/ # Approval test baselines
+└── ...
+```
+
+## Adding Tests for New Functionality
+
+### Decision Tree: Where Should My Test Go?
+
+```text
+Is it testing a single class/method in isolation?
+├─ Yes → Unit Tests (ServiceControl.UnitTests, etc.)
+│
+├─ No → Does it require persistence?
+│ ├─ Yes → Persistence Tests (ServiceControl.Persistence.Tests.*)
+│ │
+│ └─ No → Does it require transport infrastructure?
+│ ├─ Yes → Transport Tests (ServiceControl.Transports.*.Tests)
+│ │
+│ └─ No → Does it require full ServiceControl instance?
+│ ├─ Yes → Does it involve multiple instances?
+│ │ ├─ Yes → MultiInstance.AcceptanceTests
+│ │ └─ No → AcceptanceTests (Primary/Audit/Monitoring)
+│ │
+│ └─ No → Unit Tests with mocks
+```
+
+### Example: Adding Tests for Forward Headers Configuration
+
+For a feature like forward headers, tests focus on the **settings/parsing logic**. The middleware itself is a thin wrapper around ASP.NET Core's `UseForwardedHeaders()` and doesn't require separate unit testing.
+
+#### Unit Tests for Configuration Parsing
+
+Location: `src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs`
+
+```csharp
+///
+/// Tests for ForwardedHeadersSettings which is shared infrastructure code
+/// used by all three instance types. Testing with one namespace is sufficient.
+///
+[TestFixture]
+public class ForwardedHeadersSettingsTests
+{
+ static readonly SettingsRootNamespace TestNamespace = new("ServiceControl");
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Clean up environment variables after each test
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", null);
+ }
+
+ [Test]
+ public void Should_parse_known_proxies_from_comma_separated_list()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,10.0.0.5");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2));
+ }
+}
+```
+
+#### End-to-End Testing
+
+For middleware configuration like forward headers, end-to-end behavior is best verified through:
+
+1. **Manual testing** with curl - documented in [forward-headers-testing.md](forward-headers-testing.md)
+2. **Acceptance tests** (optional) - only if automated verification is needed
+
+The middleware extension (`UseServiceControlForwardedHeaders`) is configuration wiring that delegates to ASP.NET Core's built-in middleware. Unit testing it would require mocking `WebApplication` and would essentially test ASP.NET Core rather than our code.
+
+### Example: Adding Tests for API Endpoints
+
+#### Unit Test for Controller Logic
+
+```csharp
+[TestFixture]
+public class MyControllerTests
+{
+ [Test]
+ public async Task Get_should_return_expected_data()
+ {
+ var mockDataStore = new Mock();
+ mockDataStore.Setup(x => x.GetData()).ReturnsAsync(expectedData);
+
+ var controller = new MyController(mockDataStore.Object);
+ var result = await controller.Get();
+
+ Assert.That(result, Is.EqualTo(expectedData));
+ }
+}
+```
+
+#### Acceptance Test for Full API Flow
+
+```csharp
+[TestFixture]
+public class When_calling_my_api_endpoint : AcceptanceTest
+{
+ [Test]
+ public async Task Should_return_correct_response()
+ {
+ await Define()
+ .WithEndpoint()
+ .Done(async c =>
+ {
+ var response = await HttpClient.GetAsync("/api/my-endpoint");
+ if (response.IsSuccessStatusCode)
+ {
+ c.Response = await response.Content.ReadAsStringAsync();
+ return true;
+ }
+ return false;
+ })
+ .Run();
+
+ Assert.That(context.Response, Is.Not.Null);
+ }
+
+ class MyContext : ScenarioContext
+ {
+ public string Response { get; set; }
+ }
+}
+```
+
+## Test Patterns and Best Practices
+
+### Async-First Testing
+
+All test setup, execution, and teardown support async patterns:
+
+```csharp
+[Test]
+public async Task Should_handle_async_operation()
+{
+ var result = await SomeAsyncOperation();
+ Assert.That(result, Is.True);
+}
+```
+
+### Instance Per Test Case
+
+Use `[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]` for test isolation:
+
+```csharp
+[TestFixture]
+[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
+public class MyTests
+{
+ // Each test gets a fresh instance of this class
+}
+```
+
+### Dependency Injection in Tests
+
+Access services through `IServiceProvider`:
+
+```csharp
+protected IServiceProvider ServiceProvider { get; }
+
+[Test]
+public void Should_resolve_service()
+{
+ var myService = ServiceProvider.GetRequiredService();
+ // Use service
+}
+```
+
+### Using WaitUntil for Async Verification
+
+```csharp
+await WaitUntil(async () =>
+{
+ var result = await CheckCondition();
+ return result.IsReady;
+}, timeoutInSeconds: 30);
+```
+
+### Test Timeout Handling
+
+Transport tests use configured timeouts:
+
+```csharp
+protected TimeSpan TestTimeout => TimeSpan.FromSeconds(60);
+```
+
+## Running Tests
+
+### All Tests
+
+```bash
+dotnet test src/ServiceControl.sln
+```
+
+### By Transport Filter
+
+```powershell
+$env:ServiceControl_TESTS_FILTER = "Default"
+dotnet test src/ServiceControl.sln
+```
+
+### Specific Project
+
+```bash
+dotnet test src/ServiceControl.UnitTests/ServiceControl.UnitTests.csproj
+```
+
+### Single Test by Name
+
+```bash
+dotnet test src/ServiceControl.UnitTests/ServiceControl.UnitTests.csproj --filter "FullyQualifiedName~MyTestMethodName"
+```
+
+### With Verbose Output
+
+```bash
+dotnet test src/ServiceControl.sln --logger "console;verbosity=detailed"
+```
+
+## Environment Variables for Transport Tests
+
+| Transport | Environment Variable |
+|----------------------|-------------------------------------------------------------|
+| SQL Server | `ServiceControl_TransportTests_SQL_ConnectionString` |
+| Azure Service Bus | `ServiceControl_TransportTests_ASBS_ConnectionString` |
+| Azure Storage Queues | `ServiceControl_TransportTests_ASQ_ConnectionString` |
+| RabbitMQ | `ServiceControl_TransportTests_RabbitMQ_ConnectionString` |
+| AWS SQS | `ServiceControl_TransportTests_SQS_*` |
+| PostgreSQL | `ServiceControl_TransportTests_PostgreSql_ConnectionString` |
+
+## Summary
+
+When adding tests for new functionality:
+
+1. **Start with unit tests** for isolated logic (configuration parsing, algorithms, helpers)
+2. **Add persistence tests** if the feature involves data storage
+3. **Add acceptance tests** for end-to-end API and behavior verification
+4. **Add transport tests** if the feature involves transport-specific behavior
+5. Follow existing patterns in similar test files
+6. Use appropriate base classes to reduce boilerplate
+7. Ensure tests run with the Default filter for CI compatibility
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index ddb7d493c5..d29f860a49 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -7,7 +7,7 @@
-
+
@@ -17,6 +17,8 @@
+
+
@@ -28,6 +30,8 @@
+
+
@@ -84,6 +88,7 @@
+
diff --git a/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs
new file mode 100644
index 0000000000..e18b272d2e
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersAssertions.cs
@@ -0,0 +1,271 @@
+namespace ServiceControl.AcceptanceTesting.ForwardedHeaders
+{
+ using System.Net.Http;
+ using System.Text.Json;
+ using System.Threading.Tasks;
+ using NUnit.Framework;
+
+ ///
+ /// Shared assertion helpers for forwarded headers acceptance tests.
+ /// Used across all instance types (Primary, Audit, Monitoring).
+ ///
+ public static class ForwardedHeadersAssertions
+ {
+ ///
+ /// Fetches request info from the debug endpoint with optional custom forwarded headers.
+ ///
+ /// The HTTP client to use
+ /// JSON serializer options
+ /// X-Forwarded-For header value
+ /// X-Forwarded-Proto header value
+ /// X-Forwarded-Host header value
+ /// Test-only: Simulates the request coming from this IP address.
+ /// Used to test ForwardedHeaders behavior with KnownProxies/KnownNetworks configurations.
+ public static async Task GetRequestInfo(
+ HttpClient httpClient,
+ JsonSerializerOptions serializerOptions,
+ string xForwardedFor = null,
+ string xForwardedProto = null,
+ string xForwardedHost = null,
+ string testRemoteIp = null)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/debug/request-info");
+
+ if (!string.IsNullOrEmpty(xForwardedFor))
+ {
+ request.Headers.Add("X-Forwarded-For", xForwardedFor);
+ }
+ if (!string.IsNullOrEmpty(xForwardedProto))
+ {
+ request.Headers.Add("X-Forwarded-Proto", xForwardedProto);
+ }
+ if (!string.IsNullOrEmpty(xForwardedHost))
+ {
+ request.Headers.Add("X-Forwarded-Host", xForwardedHost);
+ }
+ if (!string.IsNullOrEmpty(testRemoteIp))
+ {
+ request.Headers.Add("X-Test-Remote-IP", testRemoteIp);
+ }
+
+ var response = await httpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(content, serializerOptions);
+ }
+
+ ///
+ /// Asserts Scenario 0: Direct Access (No Proxy)
+ /// When no forwarded headers are sent, the request values should remain unchanged.
+ ///
+ public static void AssertDirectAccessWithNoForwardedHeaders(RequestInfoResponse requestInfo)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ // Processed values should reflect the direct request (no proxy transformation)
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(requestInfo.Processed.Host, Is.Not.Null.And.Not.Empty);
+
+ // Raw headers should be empty since no forwarded headers were sent
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Default configuration: enabled with trust all proxies
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True);
+ }
+
+ ///
+ /// Asserts Scenario 1/2: Default Behavior with Headers (TrustAllProxies = true)
+ /// When forwarded headers are sent and all proxies are trusted, headers should be applied.
+ ///
+ public static void AssertHeadersAppliedWhenTrustAllProxies(
+ RequestInfoResponse requestInfo,
+ string expectedScheme,
+ string expectedHost,
+ string expectedRemoteIp)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ // Processed values should reflect the forwarded headers
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+ Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost));
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedRemoteIp));
+
+ // Raw headers should be empty because middleware consumed them
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Configuration should show trust all proxies
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True);
+ }
+
+ ///
+ /// Asserts Scenario 11: Partial Headers (Proto Only)
+ /// When only X-Forwarded-Proto is sent, only scheme should change.
+ ///
+ public static void AssertPartialHeadersApplied(
+ RequestInfoResponse requestInfo,
+ string expectedScheme)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ // Only scheme should be changed
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+
+ // Host should remain original (not changed to a forwarded value)
+ // In test environment this will be the test server host, not a forwarded host like "example.com"
+ Assert.That(requestInfo.Processed.Host, Is.Not.Null.And.Not.Empty);
+ Assert.That(requestInfo.Processed.Host, Does.Not.Contain("example.com"));
+
+ // RemoteIpAddress should NOT be a forwarded IP (203.0.113.50)
+ // In test server environment it may be null, localhost, or machine-specific
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.Null.Or.Not.EqualTo("203.0.113.50"));
+
+ // Configuration should show trust all proxies
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True);
+ }
+
+ ///
+ /// Asserts Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values)
+ /// When TrustAllProxies is true and multiple IPs are in X-Forwarded-For,
+ /// the original client IP (first in the chain) should be returned.
+ ///
+ public static void AssertProxyChainProcessedWithTrustAllProxies(
+ RequestInfoResponse requestInfo,
+ string expectedOriginalClientIp,
+ string expectedScheme,
+ string expectedHost)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ // When TrustAllProxies=true, ForwardLimit=null, so middleware processes all IPs
+ // and returns the original client IP (first in the chain)
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+ Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost));
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedOriginalClientIp));
+
+ // Raw headers should be empty because middleware consumed them
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Configuration should show trust all proxies
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.True);
+ }
+
+ ///
+ /// Asserts Scenario 3/4/10: Headers Applied with Known Proxies/Networks
+ /// When the caller IP matches KnownProxies or KnownNetworks, headers should be applied.
+ ///
+ public static void AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ RequestInfoResponse requestInfo,
+ string expectedScheme,
+ string expectedHost,
+ string expectedRemoteIp)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ // Headers should be applied because caller is trusted
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+ Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost));
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedRemoteIp));
+
+ // Raw headers should be empty because middleware consumed them
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Configuration should show TrustAllProxies=false (auto-disabled when proxies/networks configured)
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.False);
+ }
+
+ ///
+ /// Asserts Scenario 5/6: Headers Ignored when Proxy/Network Not Trusted
+ /// When the caller IP does NOT match KnownProxies or KnownNetworks, headers should be ignored.
+ ///
+ public static void AssertHeadersIgnoredWhenProxyNotTrusted(
+ RequestInfoResponse requestInfo,
+ string sentXForwardedFor,
+ string sentXForwardedProto,
+ string sentXForwardedHost)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ // Headers should NOT be applied - values should remain unchanged from direct request
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo("http"));
+ // Host should remain the test server host, not the forwarded host
+ Assert.That(requestInfo.Processed.Host, Does.Not.Contain("example.com"));
+
+ // Raw headers should still contain the sent values (not consumed by middleware)
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.EqualTo(sentXForwardedFor));
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.EqualTo(sentXForwardedProto));
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.EqualTo(sentXForwardedHost));
+
+ // Configuration should show TrustAllProxies=false
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.False);
+ }
+
+ ///
+ /// Asserts Scenario 7: Forwarded Headers Disabled
+ /// When forwarded headers processing is disabled, headers should be ignored regardless of trust.
+ ///
+ public static void AssertHeadersIgnoredWhenDisabled(
+ RequestInfoResponse requestInfo,
+ string sentXForwardedFor,
+ string sentXForwardedProto,
+ string sentXForwardedHost)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ // Headers should NOT be applied - values should remain unchanged
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo("http"));
+ Assert.That(requestInfo.Processed.Host, Does.Not.Contain("example.com"));
+
+ // Raw headers should still contain the sent values (middleware disabled)
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.EqualTo(sentXForwardedFor));
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.EqualTo(sentXForwardedProto));
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.EqualTo(sentXForwardedHost));
+
+ // Configuration should show Enabled=false
+ Assert.That(requestInfo.Configuration.Enabled, Is.False);
+ }
+
+ ///
+ /// Asserts Scenario 9: Proxy Chain with ForwardLimit=1 (Known Proxies)
+ /// When TrustAllProxies=false, ForwardLimit=1, so only the last proxy IP is processed.
+ ///
+ public static void AssertProxyChainWithForwardLimitOne(
+ RequestInfoResponse requestInfo,
+ string expectedLastProxyIp,
+ string expectedScheme,
+ string expectedHost,
+ string expectedRemainingForwardedFor)
+ {
+ Assert.That(requestInfo, Is.Not.Null);
+
+ // When TrustAllProxies=false, ForwardLimit=1, so only last IP is processed
+ Assert.That(requestInfo.Processed.Scheme, Is.EqualTo(expectedScheme));
+ Assert.That(requestInfo.Processed.Host, Is.EqualTo(expectedHost));
+ Assert.That(requestInfo.Processed.RemoteIpAddress, Is.EqualTo(expectedLastProxyIp));
+
+ // X-Forwarded-For should contain remaining IPs (not fully consumed)
+ Assert.That(requestInfo.RawHeaders.XForwardedFor, Is.EqualTo(expectedRemainingForwardedFor));
+ // Proto and Host are fully consumed
+ Assert.That(requestInfo.RawHeaders.XForwardedProto, Is.Empty);
+ Assert.That(requestInfo.RawHeaders.XForwardedHost, Is.Empty);
+
+ // Configuration should show TrustAllProxies=false
+ Assert.That(requestInfo.Configuration.Enabled, Is.True);
+ Assert.That(requestInfo.Configuration.TrustAllProxies, Is.False);
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs
new file mode 100644
index 0000000000..0b087e47ab
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/ForwardedHeadersTestConfiguration.cs
@@ -0,0 +1,136 @@
+namespace ServiceControl.AcceptanceTesting.ForwardedHeaders
+{
+ using System;
+
+ ///
+ /// Helper class to configure ForwardedHeaders environment variables for acceptance tests.
+ /// Environment variables must be set before the ServiceControl instance starts.
+ ///
+ public class ForwardedHeadersTestConfiguration : IDisposable
+ {
+ readonly string envVarPrefix;
+ bool disposed;
+
+ ///
+ /// Creates a new forwarded headers test configuration.
+ ///
+ /// The instance type (determines environment variable prefix)
+ public ForwardedHeadersTestConfiguration(ServiceControlInstanceType instanceType)
+ {
+ envVarPrefix = instanceType switch
+ {
+ ServiceControlInstanceType.Primary => "SERVICECONTROL_",
+ ServiceControlInstanceType.Audit => "SERVICECONTROL_AUDIT_",
+ ServiceControlInstanceType.Monitoring => "MONITORING_",
+ _ => throw new ArgumentOutOfRangeException(nameof(instanceType))
+ };
+ }
+
+ ///
+ /// Configures forwarded headers to be disabled.
+ ///
+ public ForwardedHeadersTestConfiguration WithForwardedHeadersDisabled()
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "false");
+ return this;
+ }
+
+ ///
+ /// Configures forwarded headers to trust all proxies (default behavior).
+ ///
+ public ForwardedHeadersTestConfiguration WithTrustAllProxies()
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true");
+ SetEnvironmentVariable("FORWARDEDHEADERS_TRUSTALLPROXIES", "true");
+ return this;
+ }
+
+ ///
+ /// Configures forwarded headers with specific known proxies.
+ /// Setting known proxies automatically disables TrustAllProxies.
+ ///
+ /// Comma-separated list of trusted proxy IP addresses (e.g., "127.0.0.1,::1")
+ public ForwardedHeadersTestConfiguration WithKnownProxies(string proxies)
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true");
+ SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNPROXIES", proxies);
+ return this;
+ }
+
+ ///
+ /// Configures forwarded headers with specific known networks.
+ /// Setting known networks automatically disables TrustAllProxies.
+ ///
+ /// Comma-separated list of trusted CIDR networks (e.g., "127.0.0.0/8,::1/128")
+ public ForwardedHeadersTestConfiguration WithKnownNetworks(string networks)
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true");
+ SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNNETWORKS", networks);
+ return this;
+ }
+
+ ///
+ /// Configures forwarded headers with both known proxies and networks.
+ ///
+ /// Comma-separated list of trusted proxy IP addresses
+ /// Comma-separated list of trusted CIDR networks
+ public ForwardedHeadersTestConfiguration WithKnownProxiesAndNetworks(string proxies, string networks)
+ {
+ SetEnvironmentVariable("FORWARDEDHEADERS_ENABLED", "true");
+ SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNPROXIES", proxies);
+ SetEnvironmentVariable("FORWARDEDHEADERS_KNOWNNETWORKS", networks);
+ return this;
+ }
+
+ ///
+ /// Applies the configuration by ensuring environment variables are set.
+ /// This should be called before the ServiceControl instance starts.
+ ///
+ public void Apply()
+ {
+ // Configuration is already applied via the With* methods
+ // This method exists for explicit apply semantics if needed
+ }
+
+ ///
+ /// Clears all forwarded headers environment variables.
+ /// Called automatically on Dispose.
+ ///
+ public void ClearConfiguration()
+ {
+ ClearEnvironmentVariable("FORWARDEDHEADERS_ENABLED");
+ ClearEnvironmentVariable("FORWARDEDHEADERS_TRUSTALLPROXIES");
+ ClearEnvironmentVariable("FORWARDEDHEADERS_KNOWNPROXIES");
+ ClearEnvironmentVariable("FORWARDEDHEADERS_KNOWNNETWORKS");
+ }
+
+ void SetEnvironmentVariable(string name, string value)
+ {
+ Environment.SetEnvironmentVariable(envVarPrefix + name, value);
+ }
+
+ void ClearEnvironmentVariable(string name)
+ {
+ Environment.SetEnvironmentVariable(envVarPrefix + name, null);
+ }
+
+ public void Dispose()
+ {
+ if (!disposed)
+ {
+ ClearConfiguration();
+ disposed = true;
+ }
+ }
+ }
+
+ ///
+ /// Identifies the ServiceControl instance type for environment variable prefix selection.
+ ///
+ public enum ServiceControlInstanceType
+ {
+ Primary,
+ Audit,
+ Monitoring
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs
new file mode 100644
index 0000000000..e8c8148536
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTesting/ForwardedHeaders/RequestInfoResponse.cs
@@ -0,0 +1,60 @@
+namespace ServiceControl.AcceptanceTesting.ForwardedHeaders
+{
+ using System.Text.Json.Serialization;
+
+ ///
+ /// Response DTO for the /debug/request-info endpoint.
+ /// Used by forwarded headers acceptance tests to verify request processing.
+ /// Shared across all instance types (Primary, Audit, Monitoring).
+ ///
+ public class RequestInfoResponse
+ {
+ [JsonPropertyName("processed")]
+ public ProcessedInfo Processed { get; set; }
+
+ [JsonPropertyName("rawHeaders")]
+ public RawHeadersInfo RawHeaders { get; set; }
+
+ [JsonPropertyName("configuration")]
+ public ConfigurationInfo Configuration { get; set; }
+ }
+
+ public class ProcessedInfo
+ {
+ [JsonPropertyName("scheme")]
+ public string Scheme { get; set; }
+
+ [JsonPropertyName("host")]
+ public string Host { get; set; }
+
+ [JsonPropertyName("remoteIpAddress")]
+ public string RemoteIpAddress { get; set; }
+ }
+
+ public class RawHeadersInfo
+ {
+ [JsonPropertyName("xForwardedFor")]
+ public string XForwardedFor { get; set; }
+
+ [JsonPropertyName("xForwardedProto")]
+ public string XForwardedProto { get; set; }
+
+ [JsonPropertyName("xForwardedHost")]
+ public string XForwardedHost { get; set; }
+ }
+
+ public class ConfigurationInfo
+ {
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; }
+
+ [JsonPropertyName("trustAllProxies")]
+ public bool TrustAllProxies { get; set; }
+
+ [JsonPropertyName("knownProxies")]
+ public string[] KnownProxies { get; set; }
+
+ [JsonPropertyName("knownNetworks")]
+ public string[] KnownNetworks { get; set; }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
new file mode 100644
index 0000000000..ad25d04784
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
@@ -0,0 +1,65 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 10: Combined Known Proxies and Networks from local-forward-headers-testing.md
+ /// When both KnownProxies and KnownNetworks are configured, matching either grants trust.
+ ///
+ class When_combined_proxies_and_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure both proxies (that don't match localhost) and networks (that include localhost)
+ // The localhost should match via the networks, proving OR logic
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownProxiesAndNetworks("192.168.1.100", "127.0.0.0/8,::1/128");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_network_but_not_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows both proxies and networks
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
new file mode 100644
index 0000000000..b7573e3dcc
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
@@ -0,0 +1,60 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 7: Forwarded Headers Disabled from local-forward-headers-testing.md
+ /// When forwarded headers processing is disabled, headers should be ignored regardless of trust.
+ ///
+ class When_forwarded_headers_are_disabled : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Disable forwarded headers processing entirely
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithForwardedHeadersDisabled();
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_disabled()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenDisabled(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
new file mode 100644
index 0000000000..ad2b795f67
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
@@ -0,0 +1,44 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 1/2: Default Behavior with Headers from local-forward-headers-testing.md
+ /// When forwarded headers are sent and TrustAllProxies is true (default), headers should be applied.
+ ///
+ class When_forwarded_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Headers_should_be_applied_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWhenTrustAllProxies(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
new file mode 100644
index 0000000000..7ffdae9756
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 4: Known Networks (CIDR) from local-forward-headers-testing.md
+ /// When KnownNetworks are configured and the caller IP falls within, headers should be applied.
+ ///
+ class When_known_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known networks to include localhost CIDR ranges (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownNetworks("127.0.0.0/8,::1/128");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_network()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known networks
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
new file mode 100644
index 0000000000..fd026c3810
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 3: Known Proxies Only from local-forward-headers-testing.md
+ /// When KnownProxies are configured and the caller IP matches, headers should be applied.
+ ///
+ class When_known_proxies_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known proxies to include localhost addresses (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownProxies("127.0.0.1,::1");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known proxies
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("127.0.0.1").Or.Contain("::1"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
new file mode 100644
index 0000000000..7fd110d497
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
@@ -0,0 +1,38 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 11: Partial Headers (Proto Only) from local-forward-headers-testing.md
+ /// When only X-Forwarded-Proto is sent, only scheme should change.
+ ///
+ class When_only_proto_header_is_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Only_scheme_should_be_changed()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedProto: "https");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertPartialHeadersApplied(requestInfo, expectedScheme: "https");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
new file mode 100644
index 0000000000..124de74a5c
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
@@ -0,0 +1,48 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values) from local-forward-headers-testing.md
+ /// When TrustAllProxies is true and X-Forwarded-For contains multiple IPs (proxy chain),
+ /// the original client IP (first in the chain) should be returned.
+ ///
+ class When_proxy_chain_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Original_client_ip_should_be_returned_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected: 203.0.113.50 (original client)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainProcessedWithTrustAllProxies(
+ requestInfo,
+ expectedOriginalClientIp: "203.0.113.50",
+ expectedScheme: "https",
+ expectedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
new file mode 100644
index 0000000000..f3ba0c46a7
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
@@ -0,0 +1,64 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 9: Proxy Chain with Known Proxies (ForwardLimit=1) from local-forward-headers-testing.md
+ /// When TrustAllProxies=false (known proxies configured), ForwardLimit=1, so only the last proxy IP is processed.
+ ///
+ class When_proxy_chain_headers_are_sent_with_known_proxies : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known proxies to include localhost (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownProxies("127.0.0.1,::1");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Only_last_proxy_ip_should_be_processed_when_forward_limit_is_one()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected with ForwardLimit=1: 192.168.1.1 (last proxy in chain)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainWithForwardLimitOne(
+ requestInfo,
+ expectedLastProxyIp: "192.168.1.1",
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemainingForwardedFor: "203.0.113.50,10.0.0.1");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
new file mode 100644
index 0000000000..a8f38bc405
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
@@ -0,0 +1,40 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 0: Direct Access (No Proxy) from local-forward-headers-testing.md
+ /// When no forwarded headers are sent, the request values should remain unchanged.
+ ///
+ class When_request_has_no_forwarded_headers : AcceptanceTest
+ {
+ [Test]
+ public async Task Request_values_should_remain_unchanged()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ var result = await this.TryGet("/debug/request-info");
+ if (result.HasResult)
+ {
+ requestInfo = result.Item;
+ return true;
+ }
+ return false;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertDirectAccessWithNoForwardedHeaders(requestInfo);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
new file mode 100644
index 0000000000..516fd99e24
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
@@ -0,0 +1,68 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 6: Unknown Network Rejected from local-forward-headers-testing.md
+ /// When KnownNetworks are configured but the caller IP does NOT fall within, headers should be ignored.
+ ///
+ class When_unknown_network_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known networks that do NOT include localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownNetworks("10.0.0.0/8,192.168.0.0/16");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_networks()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (not in known networks)
+ // The known networks are 10.0.0.0/8 and 192.168.0.0/16, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the networks (203.0.113.1 is NOT in these networks)
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("192.168.0.0/16"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
new file mode 100644
index 0000000000..1e9dbf3baa
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
@@ -0,0 +1,67 @@
+namespace ServiceControl.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 5: Unknown Proxy Rejected from local-forward-headers-testing.md
+ /// When KnownProxies are configured but the caller IP does NOT match, headers should be ignored.
+ ///
+ class When_unknown_proxy_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure a known proxy that does NOT match localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Primary)
+ .WithKnownProxies("192.168.1.100");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (not in known proxies)
+ // The known proxy is 192.168.1.100, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the trusted proxy (203.0.113.1 is NOT this proxy)
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
index 8ac0a4454a..5ee232a57b 100644
--- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
+++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
@@ -2,6 +2,7 @@
{
using System;
using System.IO;
+ using System.Net;
using System.Net.Http;
using System.Runtime.Loader;
using System.Text.Json;
@@ -120,14 +121,31 @@ async Task InitializeServiceControl(ScenarioContext context)
EnvironmentName = Environments.Development
});
hostBuilder.AddServiceControl(settings, configuration);
- hostBuilder.AddServiceControlApi();
+ hostBuilder.AddServiceControlApi(settings.CorsSettings);
hostBuilder.AddServiceControlTesting(settings);
hostBuilderCustomization(hostBuilder);
host = hostBuilder.Build();
- host.UseServiceControl();
+
+ // Test middleware: Set RemoteIpAddress from X-Test-Remote-IP header
+ // This must run BEFORE UseServiceControl (which adds ForwardedHeaders middleware)
+ // so that the ForwardedHeaders middleware can properly check KnownProxies/KnownNetworks
+ host.Use(async (context, next) =>
+ {
+ if (context.Request.Headers.TryGetValue("X-Test-Remote-IP", out var testIpHeader))
+ {
+ var testIpValue = testIpHeader.ToString();
+ if (IPAddress.TryParse(testIpValue, out var testIp))
+ {
+ context.Connection.RemoteIpAddress = testIp;
+ }
+ }
+ await next();
+ });
+
+ host.UseServiceControl(settings.ForwardedHeadersSettings, settings.HttpsSettings);
await host.StartAsync();
DomainEvents = host.Services.GetRequiredService();
// Bring this back and look into the base address of the client
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
new file mode 100644
index 0000000000..91b127d951
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
@@ -0,0 +1,65 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 10: Combined Known Proxies and Networks from local-forward-headers-testing.md
+ /// When both KnownProxies and KnownNetworks are configured, matching either grants trust.
+ ///
+ class When_combined_proxies_and_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure both proxies (that don't match localhost) and networks (that include localhost)
+ // The localhost should match via the networks, proving OR logic
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownProxiesAndNetworks("192.168.1.100", "127.0.0.0/8,::1/128");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_network_but_not_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows both proxies and networks
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
new file mode 100644
index 0000000000..859b74e38b
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
@@ -0,0 +1,60 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 7: Forwarded Headers Disabled from local-forward-headers-testing.md
+ /// When forwarded headers processing is disabled, headers should be ignored regardless of trust.
+ ///
+ class When_forwarded_headers_are_disabled : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Disable forwarded headers processing entirely
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithForwardedHeadersDisabled();
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_disabled()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenDisabled(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
new file mode 100644
index 0000000000..452609c1e6
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
@@ -0,0 +1,44 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 1/2: Default Behavior with Headers from local-forward-headers-testing.md
+ /// When forwarded headers are sent and TrustAllProxies is true (default), headers should be applied.
+ ///
+ class When_forwarded_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Headers_should_be_applied_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWhenTrustAllProxies(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
new file mode 100644
index 0000000000..49b0793b21
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 4: Known Networks (CIDR) from local-forward-headers-testing.md
+ /// When KnownNetworks are configured and the caller IP falls within, headers should be applied.
+ ///
+ class When_known_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known networks to include localhost CIDR ranges (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownNetworks("127.0.0.0/8,::1/128");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_network()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known networks
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
new file mode 100644
index 0000000000..bb81a6bb8c
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 3: Known Proxies Only from local-forward-headers-testing.md
+ /// When KnownProxies are configured and the caller IP matches, headers should be applied.
+ ///
+ class When_known_proxies_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known proxies to include localhost addresses (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownProxies("127.0.0.1,::1");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known proxies
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("127.0.0.1").Or.Contain("::1"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
new file mode 100644
index 0000000000..c5531e6ee7
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
@@ -0,0 +1,38 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 11: Partial Headers (Proto Only) from local-forward-headers-testing.md
+ /// When only X-Forwarded-Proto is sent, only scheme should change.
+ ///
+ class When_only_proto_header_is_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Only_scheme_should_be_changed()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedProto: "https");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertPartialHeadersApplied(requestInfo, expectedScheme: "https");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
new file mode 100644
index 0000000000..ef25aa2be0
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
@@ -0,0 +1,48 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values) from local-forward-headers-testing.md
+ /// When TrustAllProxies is true and X-Forwarded-For contains multiple IPs (proxy chain),
+ /// the original client IP (first in the chain) should be returned.
+ ///
+ class When_proxy_chain_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Original_client_ip_should_be_returned_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Audit
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected: 203.0.113.50 (original client)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainProcessedWithTrustAllProxies(
+ requestInfo,
+ expectedOriginalClientIp: "203.0.113.50",
+ expectedScheme: "https",
+ expectedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
new file mode 100644
index 0000000000..7d0c0e8b71
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
@@ -0,0 +1,64 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 9: Proxy Chain with Known Proxies (ForwardLimit=1) from local-forward-headers-testing.md
+ /// When TrustAllProxies=false (known proxies configured), ForwardLimit=1, so only the last proxy IP is processed.
+ ///
+ class When_proxy_chain_headers_are_sent_with_known_proxies : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known proxies to include localhost (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownProxies("127.0.0.1,::1");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Only_last_proxy_ip_should_be_processed_when_forward_limit_is_one()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Audit
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected with ForwardLimit=1: 192.168.1.1 (last proxy in chain)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainWithForwardLimitOne(
+ requestInfo,
+ expectedLastProxyIp: "192.168.1.1",
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemainingForwardedFor: "203.0.113.50,10.0.0.1");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
new file mode 100644
index 0000000000..a32f6708f9
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
@@ -0,0 +1,40 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 0: Direct Access (No Proxy) from local-forward-headers-testing.md
+ /// When no forwarded headers are sent, the request values should remain unchanged.
+ ///
+ class When_request_has_no_forwarded_headers : AcceptanceTest
+ {
+ [Test]
+ public async Task Request_values_should_remain_unchanged()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ var result = await this.TryGet("/debug/request-info");
+ if (result.HasResult)
+ {
+ requestInfo = result.Item;
+ return true;
+ }
+ return false;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertDirectAccessWithNoForwardedHeaders(requestInfo);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
new file mode 100644
index 0000000000..dc84189f55
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
@@ -0,0 +1,68 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 6: Unknown Network Rejected from local-forward-headers-testing.md
+ /// When KnownNetworks are configured but the caller IP does NOT fall within, headers should be ignored.
+ ///
+ class When_unknown_network_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known networks that do NOT include localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownNetworks("10.0.0.0/8,192.168.0.0/16");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_networks()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known networks)
+ // The known networks are 10.0.0.0/8 and 192.168.0.0/16, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the networks (203.0.113.1 is NOT in these networks)
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("192.168.0.0/16"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
new file mode 100644
index 0000000000..7518ba311a
--- /dev/null
+++ b/src/ServiceControl.Audit.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
@@ -0,0 +1,67 @@
+namespace ServiceControl.Audit.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 5: Unknown Proxy Rejected from local-forward-headers-testing.md
+ /// When KnownProxies are configured but the caller IP does NOT match, headers should be ignored.
+ ///
+ class When_unknown_proxy_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure a known proxy that does NOT match localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Audit)
+ .WithKnownProxies("192.168.1.100");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known proxies)
+ // The known proxy is 192.168.1.100, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the trusted proxy (203.0.113.1 is NOT this proxy)
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
index e0b41effe6..a7d7e27f9c 100644
--- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
+++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
@@ -4,6 +4,7 @@ namespace ServiceControl.Audit.AcceptanceTests.TestSupport
using System.Collections.Generic;
using System.Configuration;
using System.IO;
+ using System.Net;
using System.Net.Http;
using System.Runtime.Loader;
using System.Text.Json;
@@ -129,14 +130,31 @@ async Task InitializeServiceControl(ScenarioContext context)
return criticalErrorContext.Stop(cancellationToken);
}, settings, configuration);
- hostBuilder.AddServiceControlAuditApi();
+ hostBuilder.AddServiceControlAuditApi(settings.CorsSettings);
hostBuilder.AddServiceControlAuditTesting(settings);
hostBuilderCustomization(hostBuilder);
host = hostBuilder.Build();
- host.UseServiceControlAudit();
+
+ // Test middleware: Set RemoteIpAddress from X-Test-Remote-IP header
+ // This must run BEFORE UseServiceControlAudit (which adds ForwardedHeaders middleware)
+ // so that the ForwardedHeaders middleware can properly check KnownProxies/KnownNetworks
+ host.Use(async (context, next) =>
+ {
+ if (context.Request.Headers.TryGetValue("X-Test-Remote-IP", out var testIpHeader))
+ {
+ var testIpValue = testIpHeader.ToString();
+ if (IPAddress.TryParse(testIpValue, out var testIp))
+ {
+ context.Connection.RemoteIpAddress = testIp;
+ }
+ }
+ await next();
+ });
+
+ host.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings);
await host.StartAsync();
ServiceProvider = host.Services;
InstanceTestServer = host.GetTestServer();
diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
index 7113c06339..df1471b757 100644
--- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
+++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
@@ -3,6 +3,39 @@
"LogLevel": "Information",
"LogPath": "C:\\Logs"
},
+ "OpenIdConnectSettings": {
+ "enabled": false,
+ "authority": null,
+ "audience": null,
+ "validateIssuer": false,
+ "validateAudience": false,
+ "validateLifetime": false,
+ "validateIssuerSigningKey": false,
+ "requireHttpsMetadata": false,
+ "servicePulseAuthority": null,
+ "servicePulseClientId": null,
+ "servicePulseApiScopes": null
+ },
+ "ForwardedHeadersSettings": {
+ "Enabled": true,
+ "TrustAllProxies": true,
+ "KnownProxiesRaw": [],
+ "KnownNetworks": []
+ },
+ "HttpsSettings": {
+ "Enabled": false,
+ "CertificatePath": null,
+ "CertificatePassword": null,
+ "RedirectHttpToHttps": false,
+ "HttpsPort": null,
+ "EnableHsts": false,
+ "HstsMaxAgeSeconds": 31536000,
+ "HstsIncludeSubDomains": false
+ },
+ "CorsSettings": {
+ "AllowAnyOrigin": true,
+ "AllowedOrigins": []
+ },
"MessageFilter": null,
"ValidateConfiguration": true,
"RootUrl": "http://localhost:8888/",
diff --git a/src/ServiceControl.Audit/App.config b/src/ServiceControl.Audit/App.config
index 9452cd7e00..9c67595328 100644
--- a/src/ServiceControl.Audit/App.config
+++ b/src/ServiceControl.Audit/App.config
@@ -25,6 +25,46 @@ These settings are only here so that we can debug ServiceControl while developin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs
index e769a4863b..728a1b9a98 100644
--- a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs
+++ b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs
@@ -3,6 +3,8 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using NServiceBus;
+ using ServiceControl.Hosting.Auth;
+ using ServiceControl.Hosting.Https;
using Settings;
using WebApi;
@@ -15,15 +17,20 @@ public override async Task Execute(HostArguments args, Settings settings)
assemblyScanner.ExcludeAssemblies("ServiceControl.Plugin");
var hostBuilder = WebApplication.CreateBuilder();
+
+ hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
+ hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlAudit((_, __) =>
{
//Do nothing. The transports in NSB 8 are designed to handle broker outages. Audit ingestion will be paused when broker is unavailable.
return Task.CompletedTask;
}, settings, endpointConfiguration);
- hostBuilder.AddServiceControlAuditApi();
+ hostBuilder.AddServiceControlAuditApi(settings.CorsSettings);
var app = hostBuilder.Build();
- app.UseServiceControlAudit();
+ app.UseServiceControlAudit(settings.ForwardedHeadersSettings, settings.HttpsSettings);
+ app.UseServiceControlAuthentication(authenticationEnabled: settings.OpenIdConnectSettings.Enabled);
+
await app.RunAsync(settings.RootUrl);
}
}
diff --git a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs
index dd409f0334..8f085fa2a0 100644
--- a/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs
+++ b/src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs
@@ -17,6 +17,11 @@ public Settings(string transportType = null, string persisterType = null, Loggin
{
LoggingSettings = loggingSettings ?? new(SettingsRootNamespace);
+ OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration, requireServicePulseSettings: false);
+ ForwardedHeadersSettings = new ForwardedHeadersSettings(SettingsRootNamespace);
+ HttpsSettings = new HttpsSettings(SettingsRootNamespace);
+ CorsSettings = new CorsSettings(SettingsRootNamespace);
+
// Overwrite the instance name if it is specified in ENVVAR, reg, or config file -- LEGACY SETTING NAME
InstanceName = SettingsReader.Read(SettingsRootNamespace, "InternalQueueName", InstanceName);
@@ -92,6 +97,14 @@ void LoadAuditQueueInformation()
public LoggingSettings LoggingSettings { get; }
+ public OpenIdConnectSettings OpenIdConnectSettings { get; }
+
+ public ForwardedHeadersSettings ForwardedHeadersSettings { get; }
+
+ public HttpsSettings HttpsSettings { get; }
+
+ public CorsSettings CorsSettings { get; }
+
//HINT: acceptance tests only
public Func MessageFilter { get; set; }
@@ -108,7 +121,8 @@ public string RootUrl
suffix = $"{VirtualDirectory}/";
}
- return $"http://{Hostname}:{Port}/{suffix}";
+ var scheme = HttpsSettings.Enabled ? "https" : "http";
+ return $"{scheme}://{Hostname}:{Port}/{suffix}";
}
}
diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs
index c5b024930d..e0ac607f57 100644
--- a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs
+++ b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs
@@ -1,16 +1,26 @@
namespace ServiceControl.Audit.Infrastructure.WebApi
{
using Microsoft.AspNetCore.Cors.Infrastructure;
+ using ServiceControl.Infrastructure;
static class Cors
{
- public static CorsPolicy GetDefaultPolicy()
+ public static CorsPolicy GetDefaultPolicy(CorsSettings settings)
{
var builder = new CorsPolicyBuilder();
- builder.AllowAnyOrigin();
+ if (settings.AllowAnyOrigin)
+ {
+ builder.AllowAnyOrigin();
+ }
+ else if (settings.AllowedOrigins.Count > 0)
+ {
+ builder.WithOrigins([.. settings.AllowedOrigins]);
+ builder.AllowCredentials();
+ }
+
builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version"]);
- builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept"]);
+ builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]);
builder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH"]);
return builder.Build();
diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
index 4eaa203c64..638041d4b1 100644
--- a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
+++ b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs
@@ -4,12 +4,13 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+ using ServiceControl.Infrastructure;
static class HostApplicationBuilderExtensions
{
- public static void AddServiceControlAuditApi(this IHostApplicationBuilder builder)
+ public static void AddServiceControlAuditApi(this IHostApplicationBuilder builder, CorsSettings corsSettings)
{
- builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy()));
+ builder.Services.AddCors(options => options.AddDefaultPolicy(Cors.GetDefaultPolicy(corsSettings)));
// We're not explicitly adding Gzip here because it's already in the default list of supported compressors
builder.Services.AddResponseCompression();
diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs
index 064122234d..376d6f638b 100644
--- a/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs
+++ b/src/ServiceControl.Audit/Infrastructure/WebApi/RootController.cs
@@ -1,6 +1,7 @@
namespace ServiceControl.Audit.Infrastructure.WebApi
{
using Configuration;
+ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Settings;
@@ -16,6 +17,7 @@ public RootController(Settings settings)
[Route("")]
[HttpGet]
+ [AllowAnonymous]
public OkObjectResult Urls()
{
var baseUrl = Request.GetDisplayUrl();
@@ -43,6 +45,7 @@ public OkObjectResult Urls()
[Route("instance-info")]
[Route("configuration")]
[HttpGet]
+ [AllowAnonymous]
public OkObjectResult Config()
{
object content = new
diff --git a/src/ServiceControl.Audit/Properties/launchSettings.json b/src/ServiceControl.Audit/Properties/launchSettings.json
index 8600c4f462..127d979906 100644
--- a/src/ServiceControl.Audit/Properties/launchSettings.json
+++ b/src/ServiceControl.Audit/Properties/launchSettings.json
@@ -3,6 +3,7 @@
"ServiceControl.Audit": {
"commandName": "Project",
"launchBrowser": false,
+ "applicationUrl": "http://0.0.0.0:44444",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/src/ServiceControl.Audit/WebApplicationExtensions.cs b/src/ServiceControl.Audit/WebApplicationExtensions.cs
index e12b2a8465..76785dd77d 100644
--- a/src/ServiceControl.Audit/WebApplicationExtensions.cs
+++ b/src/ServiceControl.Audit/WebApplicationExtensions.cs
@@ -2,13 +2,16 @@ namespace ServiceControl.Audit;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.HttpOverrides;
+using ServiceControl.Hosting.ForwardedHeaders;
+using ServiceControl.Hosting.Https;
+using ServiceControl.Infrastructure;
public static class WebApplicationExtensions
{
- public static void UseServiceControlAudit(this WebApplication app)
+ public static void UseServiceControlAudit(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings)
{
- app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All });
+ app.UseServiceControlForwardedHeaders(forwardedHeadersSettings);
+ app.UseServiceControlHttps(httpsSettings);
app.UseResponseCompression();
app.UseMiddleware();
app.UseHttpLogging();
diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs
new file mode 100644
index 0000000000..b3e4b32c97
--- /dev/null
+++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs
@@ -0,0 +1,144 @@
+namespace ServiceControl.Hosting.Auth
+{
+ using System;
+ using System.Text.Json;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.Authentication.JwtBearer;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.Extensions.Hosting;
+ using Microsoft.IdentityModel.Tokens;
+ using ServiceControl.Infrastructure;
+
+ public static class HostApplicationBuilderExtensions
+ {
+ public static void AddServiceControlAuthentication(this IHostApplicationBuilder hostBuilder, OpenIdConnectSettings oidcSettings)
+ {
+ if (!oidcSettings.Enabled)
+ {
+ return;
+ }
+
+ hostBuilder.Services.AddAuthentication(options =>
+ {
+ options.DefaultScheme = "Bearer";
+ options.DefaultChallengeScheme = "Bearer";
+ })
+ .AddJwtBearer("Bearer", options =>
+ {
+ options.Authority = oidcSettings.Authority;
+ // Configure token validation parameters
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = oidcSettings.ValidateIssuer,
+ ValidateAudience = oidcSettings.ValidateAudience,
+ ValidateLifetime = oidcSettings.ValidateLifetime,
+ ValidateIssuerSigningKey = oidcSettings.ValidateIssuerSigningKey,
+ ValidAudience = oidcSettings.Audience,
+ ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew
+ };
+ options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata;
+ // Don't map inbound claims to legacy Microsoft claim types
+ options.MapInboundClaims = false;
+
+ // Custom error response handling for better client experience
+ options.Events = new JwtBearerEvents
+ {
+ OnAuthenticationFailed = context =>
+ {
+ if (context.Exception is SecurityTokenExpiredException)
+ {
+ context.Response.Headers.Append("X-Token-Expired", "true");
+ }
+ return Task.CompletedTask;
+ },
+ OnChallenge = context =>
+ {
+ // Skip if response already started or already handled
+ if (context.Response.HasStarted || context.Handled)
+ {
+ return Task.CompletedTask;
+ }
+
+ context.HandleResponse();
+ context.Response.StatusCode = 401;
+ context.Response.ContentType = "application/json";
+
+ var errorResponse = new AuthErrorResponse
+ {
+ Error = "unauthorized",
+ Message = GetErrorMessage(context)
+ };
+
+ return context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, JsonSerializerOptions));
+ },
+ OnForbidden = context =>
+ {
+ // Skip if response already started
+ if (context.Response.HasStarted)
+ {
+ return Task.CompletedTask;
+ }
+
+ context.Response.StatusCode = 403;
+ context.Response.ContentType = "application/json";
+
+ var errorResponse = new AuthErrorResponse
+ {
+ Error = "forbidden",
+ Message = "You do not have permission to access this resource."
+ };
+
+ return context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, JsonSerializerOptions));
+ }
+ };
+ });
+
+ hostBuilder.Services.AddAuthorization(configure =>
+ configure.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
+ .RequireAuthenticatedUser()
+ .Build());
+ }
+
+ static string GetErrorMessage(JwtBearerChallengeContext context)
+ {
+ if (context.AuthenticateFailure is SecurityTokenExpiredException)
+ {
+ return "The token has expired. Please obtain a new token and retry.";
+ }
+
+ if (context.AuthenticateFailure is SecurityTokenInvalidSignatureException)
+ {
+ return "The token signature is invalid.";
+ }
+
+ if (context.AuthenticateFailure is SecurityTokenInvalidAudienceException)
+ {
+ return "The token audience is invalid.";
+ }
+
+ if (context.AuthenticateFailure is SecurityTokenInvalidIssuerException)
+ {
+ return "The token issuer is invalid.";
+ }
+
+ if (context.AuthenticateFailure != null)
+ {
+ return "The token is invalid.";
+ }
+
+ return "Authentication required. Please provide a valid Bearer token.";
+ }
+
+ static readonly JsonSerializerOptions JsonSerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+ }
+
+ class AuthErrorResponse
+ {
+ public string Error { get; set; }
+ public string Message { get; set; }
+ }
+}
diff --git a/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs
new file mode 100644
index 0000000000..68005c40b1
--- /dev/null
+++ b/src/ServiceControl.Hosting/Auth/WebApplicationExtensions.cs
@@ -0,0 +1,18 @@
+namespace ServiceControl.Hosting.Auth
+{
+ using Microsoft.AspNetCore.Builder;
+
+ public static class WebApplicationExtensions
+ {
+ public static void UseServiceControlAuthentication(this WebApplication app, bool authenticationEnabled = false)
+ {
+ if (!authenticationEnabled)
+ {
+ return;
+ }
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+ }
+ }
+}
diff --git a/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs
new file mode 100644
index 0000000000..bbf49d01c5
--- /dev/null
+++ b/src/ServiceControl.Hosting/ForwardedHeaders/WebApplicationExtensions.cs
@@ -0,0 +1,87 @@
+namespace ServiceControl.Hosting.ForwardedHeaders;
+
+using System.Linq;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.Extensions.Hosting;
+using ServiceControl.Infrastructure;
+
+public static class WebApplicationExtensions
+{
+ public static void UseServiceControlForwardedHeaders(this WebApplication app, ForwardedHeadersSettings settings)
+ {
+ // Register debug endpoint first (before early return) so it's always available in Development
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapGet("/debug/request-info", (HttpContext context) =>
+ {
+ var remoteIp = context.Connection.RemoteIpAddress;
+
+ // Processed values (after ForwardedHeaders middleware, if enabled)
+ var scheme = context.Request.Scheme;
+ var host = context.Request.Host.ToString();
+ var remoteIpAddress = remoteIp?.ToString();
+
+ // Raw forwarded headers (what remains after middleware processing)
+ // Note: When ForwardedHeaders middleware processes headers from a trusted proxy,
+ // it consumes (removes) them from the request headers
+ var xForwardedFor = context.Request.Headers["X-Forwarded-For"].ToString();
+ var xForwardedProto = context.Request.Headers["X-Forwarded-Proto"].ToString();
+ var xForwardedHost = context.Request.Headers["X-Forwarded-Host"].ToString();
+
+ // Configuration
+ var knownProxies = settings.KnownProxies.Select(p => p.ToString()).ToArray();
+ var knownNetworks = settings.KnownNetworks.ToArray();
+
+ return new
+ {
+ processed = new { scheme, host, remoteIpAddress },
+ rawHeaders = new { xForwardedFor, xForwardedProto, xForwardedHost },
+ configuration = new
+ {
+ enabled = settings.Enabled,
+ trustAllProxies = settings.TrustAllProxies,
+ knownProxies,
+ knownNetworks
+ }
+ };
+ });
+ }
+
+ if (!settings.Enabled)
+ {
+ return;
+ }
+
+ var options = new ForwardedHeadersOptions
+ {
+ ForwardedHeaders = ForwardedHeaders.All
+ };
+
+ // Clear default loopback-only restrictions
+ options.KnownProxies.Clear();
+ options.KnownNetworks.Clear();
+
+ if (settings.TrustAllProxies)
+ {
+ // Trust all proxies: remove hop limit
+ options.ForwardLimit = null;
+ }
+ else
+ {
+ // Only trust explicitly configured proxies and networks
+ foreach (var proxy in settings.KnownProxies)
+ {
+ options.KnownProxies.Add(proxy);
+ }
+
+ foreach (var network in settings.KnownNetworks)
+ {
+ options.KnownNetworks.Add(IPNetwork.Parse(network));
+ }
+ }
+
+ app.UseForwardedHeaders(options);
+ }
+}
diff --git a/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs
new file mode 100644
index 0000000000..af7e63e938
--- /dev/null
+++ b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs
@@ -0,0 +1,53 @@
+namespace ServiceControl.Hosting.Https;
+
+using System;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.HttpsPolicy;
+using Microsoft.Extensions.DependencyInjection;
+using ServiceControl.Infrastructure;
+
+public static class HostApplicationBuilderExtensions
+{
+ public static void AddServiceControlHttps(this WebApplicationBuilder hostBuilder, HttpsSettings settings)
+ {
+ if (settings.EnableHsts)
+ {
+ hostBuilder.Services.Configure(options =>
+ {
+ options.MaxAge = TimeSpan.FromSeconds(settings.HstsMaxAgeSeconds);
+ options.IncludeSubDomains = settings.HstsIncludeSubDomains;
+ });
+ }
+
+ if (settings.RedirectHttpToHttps && settings.HttpsPort.HasValue)
+ {
+ hostBuilder.Services.AddHttpsRedirection(options =>
+ {
+ options.HttpsPort = settings.HttpsPort.Value;
+ });
+ }
+
+ if (settings.Enabled)
+ {
+ hostBuilder.WebHost.ConfigureKestrel(kestrel =>
+ {
+ kestrel.ConfigureHttpsDefaults(httpsOptions =>
+ {
+ httpsOptions.ServerCertificate = LoadCertificate(settings);
+ });
+ });
+ }
+ }
+
+ static X509Certificate2 LoadCertificate(HttpsSettings settings)
+ {
+ if (string.IsNullOrEmpty(settings.CertificatePassword))
+ {
+ return new X509Certificate2(settings.CertificatePath);
+ }
+
+ return new X509Certificate2(settings.CertificatePath, settings.CertificatePassword);
+ }
+}
diff --git a/src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs b/src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs
new file mode 100644
index 0000000000..1123a9b3a8
--- /dev/null
+++ b/src/ServiceControl.Hosting/Https/WebApplicationExtensions.cs
@@ -0,0 +1,21 @@
+namespace ServiceControl.Hosting.Https;
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Hosting;
+using ServiceControl.Infrastructure;
+
+public static class WebApplicationExtensions
+{
+ public static void UseServiceControlHttps(this WebApplication app, HttpsSettings settings)
+ {
+ if (settings.EnableHsts && !app.Environment.IsDevelopment())
+ {
+ app.UseHsts();
+ }
+
+ if (settings.RedirectHttpToHttps)
+ {
+ app.UseHttpsRedirection();
+ }
+ }
+}
diff --git a/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj b/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj
index 074686312c..bf3d228fcf 100644
--- a/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj
+++ b/src/ServiceControl.Hosting/ServiceControl.Hosting.csproj
@@ -5,8 +5,18 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Infrastructure/CorsSettings.cs b/src/ServiceControl.Infrastructure/CorsSettings.cs
new file mode 100644
index 0000000000..44718b8050
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/CorsSettings.cs
@@ -0,0 +1,80 @@
+namespace ServiceControl.Infrastructure;
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+using ServiceControl.Configuration;
+
+public class CorsSettings
+{
+ readonly ILogger logger = LoggerUtil.CreateStaticLogger();
+
+ public CorsSettings(SettingsRootNamespace rootNamespace)
+ {
+ // Default to allowing any origin for backwards compatibility
+ AllowAnyOrigin = SettingsReader.Read(rootNamespace, "Cors.AllowAnyOrigin", true);
+
+ var allowedOriginsValue = SettingsReader.Read(rootNamespace, "Cors.AllowedOrigins");
+ if (!string.IsNullOrWhiteSpace(allowedOriginsValue))
+ {
+ AllowedOrigins = ParseOrigins(allowedOriginsValue);
+
+ // If specific origins are configured, disable AllowAnyOrigin
+ if (AllowedOrigins.Count > 0 && AllowAnyOrigin)
+ {
+ logger.LogInformation("Cors.AllowedOrigins configured, setting AllowAnyOrigin to false");
+ AllowAnyOrigin = false;
+ }
+ }
+
+ LogConfiguration();
+ }
+
+ ///
+ /// When true, allows requests from any origin. Default is true for backwards compatibility.
+ ///
+ public bool AllowAnyOrigin { get; private set; }
+
+ ///
+ /// List of specific origins to allow when AllowAnyOrigin is false.
+ ///
+ public List AllowedOrigins { get; } = [];
+
+ List ParseOrigins(string value)
+ {
+ var origins = new List();
+ var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var part in parts)
+ {
+ if (Uri.TryCreate(part, UriKind.Absolute, out var uri))
+ {
+ // Normalize: use origin format (scheme://host:port)
+ var origin = $"{uri.Scheme}://{uri.Authority}";
+ origins.Add(origin);
+ }
+ else
+ {
+ logger.LogWarning("Invalid origin URL in Cors.AllowedOrigins: '{InvalidOrigin}'", part);
+ }
+ }
+
+ return origins;
+ }
+
+ void LogConfiguration()
+ {
+ logger.LogInformation("CORS configuration:");
+ logger.LogInformation(" AllowAnyOrigin: {AllowAnyOrigin}", AllowAnyOrigin);
+
+ if (AllowedOrigins.Count > 0)
+ {
+ logger.LogInformation(" AllowedOrigins: {AllowedOrigins}", string.Join(", ", AllowedOrigins));
+ }
+
+ if (AllowAnyOrigin)
+ {
+ logger.LogWarning("Cors.AllowAnyOrigin is true. Any website can make requests to this API. Consider configuring Cors.AllowedOrigins for production environments.");
+ }
+ }
+}
diff --git a/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs b/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs
new file mode 100644
index 0000000000..852868f228
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/ForwardedHeadersSettings.cs
@@ -0,0 +1,131 @@
+namespace ServiceControl.Infrastructure;
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using ServiceControl.Configuration;
+
+public class ForwardedHeadersSettings
+{
+ readonly ILogger logger = LoggerUtil.CreateStaticLogger();
+
+ public ForwardedHeadersSettings(SettingsRootNamespace rootNamespace)
+ {
+ Enabled = SettingsReader.Read(rootNamespace, "ForwardedHeaders.Enabled", true);
+
+ // Default to trusting all proxies for backwards compatibility
+ // Customers can set this to false and configure KnownProxies/KnownNetworks for better security
+ TrustAllProxies = SettingsReader.Read(rootNamespace, "ForwardedHeaders.TrustAllProxies", true);
+
+ var knownProxiesValue = SettingsReader.Read(rootNamespace, "ForwardedHeaders.KnownProxies");
+ if (!string.IsNullOrWhiteSpace(knownProxiesValue))
+ {
+ KnownProxiesRaw = ParseAndValidateIPAddresses(knownProxiesValue);
+ }
+
+ var knownNetworksValue = SettingsReader.Read(rootNamespace, "ForwardedHeaders.KnownNetworks");
+ if (!string.IsNullOrWhiteSpace(knownNetworksValue))
+ {
+ KnownNetworks = ParseNetworks(knownNetworksValue);
+ }
+
+ // If proxies or networks are explicitly configured, disable TrustAllProxies
+ if ((KnownProxiesRaw.Count > 0 || KnownNetworks.Count > 0) && TrustAllProxies)
+ {
+ logger.LogInformation("KnownProxies or KnownNetworks configured, setting TrustAllProxies to false");
+ TrustAllProxies = false;
+ }
+
+ LogConfiguration();
+ }
+
+ public bool Enabled { get; }
+
+ public bool TrustAllProxies { get; private set; }
+
+ // Store as strings for serialization compatibility, parse to IPAddress when needed
+ public List KnownProxiesRaw { get; } = [];
+
+ public List KnownNetworks { get; } = [];
+
+ // Parse IPAddresses on demand to avoid serialization issues
+ [JsonIgnore]
+ public IEnumerable KnownProxies
+ {
+ get
+ {
+ foreach (var raw in KnownProxiesRaw)
+ {
+ if (IPAddress.TryParse(raw, out var address))
+ {
+ yield return address;
+ }
+ }
+ }
+ }
+
+ List ParseAndValidateIPAddresses(string value)
+ {
+ var addresses = new List();
+ var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var part in parts)
+ {
+ if (IPAddress.TryParse(part, out _))
+ {
+ addresses.Add(part);
+ }
+ else
+ {
+ logger.LogWarning("Invalid IP address in ForwardedHeaders.KnownProxies: '{InvalidAddress}'", part);
+ }
+ }
+
+ return addresses;
+ }
+
+ List ParseNetworks(string value)
+ {
+ var networks = new List();
+ var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var part in parts)
+ {
+ // Basic validation - should contain a /
+ if (part.Contains('/'))
+ {
+ networks.Add(part);
+ }
+ else
+ {
+ logger.LogWarning("Invalid network CIDR in ForwardedHeaders.KnownNetworks (expected format: '10.0.0.0/8'): '{InvalidNetwork}'", part);
+ }
+ }
+
+ return networks;
+ }
+
+ void LogConfiguration()
+ {
+ logger.LogInformation("Forwarded headers configuration:");
+ logger.LogInformation(" Enabled: {Enabled}", Enabled);
+ logger.LogInformation(" TrustAllProxies: {TrustAllProxies}", TrustAllProxies);
+
+ if (KnownProxiesRaw.Count > 0)
+ {
+ logger.LogInformation(" KnownProxies: {KnownProxies}", string.Join(", ", KnownProxiesRaw));
+ }
+
+ if (KnownNetworks.Count > 0)
+ {
+ logger.LogInformation(" KnownNetworks: {KnownNetworks}", string.Join(", ", KnownNetworks));
+ }
+
+ if (TrustAllProxies)
+ {
+ logger.LogWarning("ForwardedHeaders.TrustAllProxies is true. Any client can spoof X-Forwarded-* headers. Consider configuring KnownProxies or KnownNetworks for production environments.");
+ }
+ }
+}
diff --git a/src/ServiceControl.Infrastructure/HttpsSettings.cs b/src/ServiceControl.Infrastructure/HttpsSettings.cs
new file mode 100644
index 0000000000..d613d2447d
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/HttpsSettings.cs
@@ -0,0 +1,94 @@
+namespace ServiceControl.Infrastructure;
+
+using System;
+using System.IO;
+using Microsoft.Extensions.Logging;
+using ServiceControl.Configuration;
+
+public class HttpsSettings
+{
+ readonly ILogger logger = LoggerUtil.CreateStaticLogger();
+
+ public HttpsSettings(SettingsRootNamespace rootNamespace)
+ {
+ // Kestrel HTTPS - disabled by default for backwards compatibility
+ Enabled = SettingsReader.Read(rootNamespace, "Https.Enabled", false);
+
+ if (Enabled)
+ {
+ CertificatePath = SettingsReader.Read(rootNamespace, "Https.CertificatePath");
+ CertificatePassword = SettingsReader.Read(rootNamespace, "Https.CertificatePassword");
+
+ ValidateCertificateConfiguration();
+ }
+
+ // HTTPS redirection - disabled by default for backwards compatibility
+ RedirectHttpToHttps = SettingsReader.Read(rootNamespace, "Https.RedirectHttpToHttps", false);
+ HttpsPort = SettingsReader.Read(rootNamespace, "Https.Port", null);
+
+ // HSTS - disabled by default, only applies in non-development environments
+ EnableHsts = SettingsReader.Read(rootNamespace, "Https.EnableHsts", false);
+ HstsMaxAgeSeconds = SettingsReader.Read(rootNamespace, "Https.HstsMaxAgeSeconds", 31536000); // 1 year default
+ HstsIncludeSubDomains = SettingsReader.Read(rootNamespace, "Https.HstsIncludeSubDomains", false);
+
+ LogConfiguration();
+ }
+
+ ///
+ /// When true, Kestrel will be configured to listen on HTTPS using the specified certificate.
+ ///
+ public bool Enabled { get; }
+
+ ///
+ /// Path to the HTTPS certificate file (.pfx or .pem).
+ /// Required when Https.Enabled is true.
+ ///
+ public string CertificatePath { get; }
+
+ ///
+ /// Password for the HTTPS certificate.
+ /// Can be null for certificates without a password.
+ ///
+ public string CertificatePassword { get; }
+
+ public bool RedirectHttpToHttps { get; }
+
+ public int? HttpsPort { get; }
+
+ public bool EnableHsts { get; }
+
+ public int HstsMaxAgeSeconds { get; }
+
+ public bool HstsIncludeSubDomains { get; }
+
+ void ValidateCertificateConfiguration()
+ {
+ if (string.IsNullOrWhiteSpace(CertificatePath))
+ {
+ throw new InvalidOperationException(
+ "Https.Enabled is true but Https.CertificatePath is not configured. " +
+ "Please specify the path to a valid HTTPS certificate file (.pfx or .pem).");
+ }
+
+ if (!File.Exists(CertificatePath))
+ {
+ throw new InvalidOperationException(
+ $"Https.CertificatePath '{CertificatePath}' does not exist. " +
+ "Please specify a valid path to an HTTPS certificate file.");
+ }
+ }
+
+ void LogConfiguration()
+ {
+ logger.LogInformation("HTTPS configuration:");
+
+ logger.LogInformation(" Enabled: {Enabled}", Enabled);
+ logger.LogInformation(" CertificatePath: {CertificatePath}", CertificatePath);
+ logger.LogInformation(" CertificatePassword: {CertificatePassword}", string.IsNullOrEmpty(CertificatePassword) ? "(not set)" : "(set)");
+ logger.LogInformation(" RedirectHttpToHttps: {RedirectHttpToHttps}", RedirectHttpToHttps);
+ logger.LogInformation(" HttpsPort: {HttpsPort}", HttpsPort?.ToString() ?? "(not set)");
+ logger.LogInformation(" EnableHsts: {EnableHsts}", EnableHsts);
+ logger.LogInformation(" HstsMaxAgeSeconds: {HstsMaxAgeSeconds}", HstsMaxAgeSeconds);
+ logger.LogInformation(" HstsIncludeSubDomains: {HstsIncludeSubDomains}", HstsIncludeSubDomains);
+ }
+}
diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs
new file mode 100644
index 0000000000..02601c2168
--- /dev/null
+++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs
@@ -0,0 +1,166 @@
+namespace ServiceControl.Infrastructure;
+
+using System;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using ServiceControl.Configuration;
+
+public class OpenIdConnectSettings
+{
+ readonly ILogger logger = LoggerUtil.CreateStaticLogger();
+
+ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateConfiguration, bool requireServicePulseSettings = true)
+ {
+ Enabled = SettingsReader.Read(rootNamespace, "Authentication.Enabled", false);
+
+ if (!Enabled)
+ {
+ return;
+ }
+
+ Authority = SettingsReader.Read(rootNamespace, "Authentication.Authority");
+ Audience = SettingsReader.Read(rootNamespace, "Authentication.Audience");
+ ValidateIssuer = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuer", true);
+ ValidateAudience = SettingsReader.Read(rootNamespace, "Authentication.ValidateAudience", true);
+ ValidateLifetime = SettingsReader.Read(rootNamespace, "Authentication.ValidateLifetime", true);
+ ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true);
+ RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true);
+
+ // ServicePulse settings are only needed for the primary ServiceControl instance
+ if (requireServicePulseSettings)
+ {
+ ServicePulseClientId = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ClientId");
+ ServicePulseApiScopes = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.ApiScopes");
+ ServicePulseAuthority = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.Authority");
+ }
+
+ if (validateConfiguration)
+ {
+ Validate(requireServicePulseSettings);
+ }
+ }
+
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; }
+
+ [JsonPropertyName("authority")]
+ public string Authority { get; }
+
+ [JsonPropertyName("audience")]
+ public string Audience { get; }
+
+ [JsonPropertyName("validateIssuer")]
+ public bool ValidateIssuer { get; }
+
+ [JsonPropertyName("validateAudience")]
+ public bool ValidateAudience { get; }
+
+ [JsonPropertyName("validateLifetime")]
+ public bool ValidateLifetime { get; }
+
+ [JsonPropertyName("validateIssuerSigningKey")]
+ public bool ValidateIssuerSigningKey { get; }
+
+ [JsonPropertyName("requireHttpsMetadata")]
+ public bool RequireHttpsMetadata { get; }
+
+ [JsonPropertyName("servicePulseAuthority")]
+ public string ServicePulseAuthority { get; }
+
+ [JsonPropertyName("servicePulseClientId")]
+ public string ServicePulseClientId { get; }
+
+ [JsonPropertyName("servicePulseApiScopes")]
+ public string ServicePulseApiScopes { get; }
+
+ void Validate(bool requireServicePulseSettings)
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(Authority))
+ {
+ var message = "Authentication.Authority is required when authentication is enabled. Please provide a valid OpenID Connect authority URL (e.g., https://login.microsoftonline.com/{tenant-id}/v2.0)";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (!Uri.TryCreate(Authority, UriKind.Absolute, out var authorityUri))
+ {
+ var message = $"Authentication.Authority must be a valid absolute URI. Current value: '{Authority}'";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (RequireHttpsMetadata && authorityUri.Scheme != Uri.UriSchemeHttps)
+ {
+ var message = $"Authentication.Authority must use HTTPS when RequireHttpsMetadata is true. Current value: '{Authority}'. Either use HTTPS or set Authentication.RequireHttpsMetadata to false (not recommended for production)";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (string.IsNullOrWhiteSpace(Audience))
+ {
+ var message = "Authentication.Audience is required when authentication is enabled. Please provide a valid audience identifier (typically your API identifier or client ID)";
+ logger.LogCritical(message);
+ throw new Exception(message);
+ }
+
+ if (!ValidateIssuer)
+ {
+ logger.LogWarning("Authentication.ValidateIssuer is set to false. This is not recommended for production environments as it may allow tokens from untrusted issuers");
+ }
+
+ if (!ValidateAudience)
+ {
+ logger.LogWarning("Authentication.ValidateAudience is set to false. This is not recommended for production environments as it may allow tokens intended for other applications");
+ }
+
+ if (!ValidateLifetime)
+ {
+ logger.LogWarning("Authentication.ValidateLifetime is set to false. This is not recommended as it may allow expired tokens to be accepted");
+ }
+
+ if (!ValidateIssuerSigningKey)
+ {
+ logger.LogWarning("Authentication.ValidateIssuerSigningKey is set to false. This is a serious security risk and should only be used in development environments");
+ }
+
+ // ServicePulse settings are only required for the primary ServiceControl instance
+ if (requireServicePulseSettings)
+ {
+ if (string.IsNullOrWhiteSpace(ServicePulseClientId))
+ {
+ throw new Exception("Authentication.ServicePulse.ClientId is required when authentication is enabled on the primary ServiceControl instance.");
+ }
+
+ if (string.IsNullOrWhiteSpace(ServicePulseApiScopes))
+ {
+ throw new Exception("Authentication.ServicePulse.ApiScopes is required when authentication is enabled on the primary ServiceControl instance.");
+ }
+
+ if (ServicePulseAuthority != null && !Uri.TryCreate(ServicePulseAuthority, UriKind.Absolute, out _))
+ {
+ throw new Exception("Authentication.ServicePulse.Authority must be a valid absolute URI if provided.");
+ }
+ }
+
+ logger.LogInformation("Authentication configuration validated successfully");
+ logger.LogInformation(" Authority: {Authority}", Authority);
+ logger.LogInformation(" Audience: {Audience}", Audience);
+ logger.LogInformation(" ValidateIssuer: {ValidateIssuer}", ValidateIssuer);
+ logger.LogInformation(" ValidateAudience: {ValidateAudience}", ValidateAudience);
+ logger.LogInformation(" ValidateLifetime: {ValidateLifetime}", ValidateLifetime);
+ logger.LogInformation(" ValidateIssuerSigningKey: {ValidateIssuerSigningKey}", ValidateIssuerSigningKey);
+ logger.LogInformation(" RequireHttpsMetadata: {RequireHttpsMetadata}", RequireHttpsMetadata);
+
+ if (requireServicePulseSettings)
+ {
+ logger.LogInformation(" ServicePulseClientId: {ServicePulseClientId}", ServicePulseClientId);
+ logger.LogInformation(" ServicePulseAuthority: {ServicePulseAuthority}", ServicePulseAuthority);
+ logger.LogInformation(" ServicePulseApiScopes: {ServicePulseApiScopes}", ServicePulseApiScopes);
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
new file mode 100644
index 0000000000..564226d6d3
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs
@@ -0,0 +1,65 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 10: Combined Known Proxies and Networks from local-forward-headers-testing.md
+ /// When both KnownProxies and KnownNetworks are configured, matching either grants trust.
+ ///
+ class When_combined_proxies_and_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure both proxies (that don't match localhost) and networks (that include localhost)
+ // The localhost should match via the networks, proving OR logic
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownProxiesAndNetworks("192.168.1.100", "127.0.0.0/8,::1/128");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_network_but_not_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows both proxies and networks
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
new file mode 100644
index 0000000000..77c1122458
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_disabled.cs
@@ -0,0 +1,60 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 7: Forwarded Headers Disabled from local-forward-headers-testing.md
+ /// When forwarded headers processing is disabled, headers should be ignored regardless of trust.
+ ///
+ class When_forwarded_headers_are_disabled : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Disable forwarded headers processing entirely
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithForwardedHeadersDisabled();
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_disabled()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenDisabled(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
new file mode 100644
index 0000000000..d22f107c0a
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_forwarded_headers_are_sent.cs
@@ -0,0 +1,44 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 1/2: Default Behavior with Headers from local-forward-headers-testing.md
+ /// When forwarded headers are sent and TrustAllProxies is true (default), headers should be applied.
+ ///
+ class When_forwarded_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Headers_should_be_applied_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWhenTrustAllProxies(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
new file mode 100644
index 0000000000..7b3ae50dcd
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_networks_are_configured.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 4: Known Networks (CIDR) from local-forward-headers-testing.md
+ /// When KnownNetworks are configured and the caller IP falls within, headers should be applied.
+ ///
+ class When_known_networks_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known networks to include localhost CIDR ranges (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownNetworks("127.0.0.0/8,::1/128");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_network()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known networks
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8").Or.Contain("::1/128"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
new file mode 100644
index 0000000000..c62c877c02
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_known_proxies_are_configured.cs
@@ -0,0 +1,63 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 3: Known Proxies Only from local-forward-headers-testing.md
+ /// When KnownProxies are configured and the caller IP matches, headers should be applied.
+ ///
+ class When_known_proxies_are_configured : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known proxies to include localhost addresses (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownProxies("127.0.0.1,::1");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_applied_when_caller_matches_known_proxy()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersAppliedWithKnownProxiesOrNetworks(
+ requestInfo,
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemoteIp: "203.0.113.50");
+
+ // Verify configuration shows known proxies
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("127.0.0.1").Or.Contain("::1"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
new file mode 100644
index 0000000000..f37cb198ac
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_only_proto_header_is_sent.cs
@@ -0,0 +1,38 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 11: Partial Headers (Proto Only) from local-forward-headers-testing.md
+ /// When only X-Forwarded-Proto is sent, only scheme should change.
+ ///
+ class When_only_proto_header_is_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Only_scheme_should_be_changed()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedProto: "https");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertPartialHeadersApplied(requestInfo, expectedScheme: "https");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
new file mode 100644
index 0000000000..8eb1a50db0
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs
@@ -0,0 +1,48 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 8: Proxy Chain (Multiple X-Forwarded-For Values) from local-forward-headers-testing.md
+ /// When TrustAllProxies is true and X-Forwarded-For contains multiple IPs (proxy chain),
+ /// the original client IP (first in the chain) should be returned.
+ ///
+ class When_proxy_chain_headers_are_sent : AcceptanceTest
+ {
+ [Test]
+ public async Task Original_client_ip_should_be_returned_when_trust_all_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Monitoring
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected: 203.0.113.50 (original client)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainProcessedWithTrustAllProxies(
+ requestInfo,
+ expectedOriginalClientIp: "203.0.113.50",
+ expectedScheme: "https",
+ expectedHost: "example.com");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
new file mode 100644
index 0000000000..6fae46dbec
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_proxy_chain_headers_are_sent_with_known_proxies.cs
@@ -0,0 +1,64 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 9: Proxy Chain with Known Proxies (ForwardLimit=1) from local-forward-headers-testing.md
+ /// When TrustAllProxies=false (known proxies configured), ForwardLimit=1, so only the last proxy IP is processed.
+ ///
+ class When_proxy_chain_headers_are_sent_with_known_proxies : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known proxies to include localhost (test server uses localhost)
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownProxies("127.0.0.1,::1");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Only_last_proxy_ip_should_be_processed_when_forward_limit_is_one()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate a proxy chain: client -> proxy1 -> proxy2 -> ServiceControl.Monitoring
+ // X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1
+ // Expected with ForwardLimit=1: 192.168.1.1 (last proxy in chain)
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertProxyChainWithForwardLimitOne(
+ requestInfo,
+ expectedLastProxyIp: "192.168.1.1",
+ expectedScheme: "https",
+ expectedHost: "example.com",
+ expectedRemainingForwardedFor: "203.0.113.50,10.0.0.1");
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
new file mode 100644
index 0000000000..0530d73861
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_request_has_no_forwarded_headers.cs
@@ -0,0 +1,40 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 0: Direct Access (No Proxy) from local-forward-headers-testing.md
+ /// When no forwarded headers are sent, the request values should remain unchanged.
+ ///
+ class When_request_has_no_forwarded_headers : AcceptanceTest
+ {
+ [Test]
+ public async Task Request_values_should_remain_unchanged()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ var result = await this.TryGet("/debug/request-info");
+ if (result.HasResult)
+ {
+ requestInfo = result.Item;
+ return true;
+ }
+ return false;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertDirectAccessWithNoForwardedHeaders(requestInfo);
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
new file mode 100644
index 0000000000..7edf72a491
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_network_sends_headers.cs
@@ -0,0 +1,68 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 6: Unknown Network Rejected from local-forward-headers-testing.md
+ /// When KnownNetworks are configured but the caller IP does NOT fall within, headers should be ignored.
+ ///
+ class When_unknown_network_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure known networks that do NOT include localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownNetworks("10.0.0.0/8,192.168.0.0/16");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_networks()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known networks)
+ // The known networks are 10.0.0.0/8 and 192.168.0.0/16, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the networks (203.0.113.1 is NOT in these networks)
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(requestInfo.Configuration.KnownNetworks, Does.Contain("192.168.0.0/16"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
new file mode 100644
index 0000000000..d5fae3c7f0
--- /dev/null
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/ForwardedHeaders/When_unknown_proxy_sends_headers.cs
@@ -0,0 +1,67 @@
+namespace ServiceControl.Monitoring.AcceptanceTests.Security.ForwardedHeaders
+{
+ using System.Threading.Tasks;
+ using AcceptanceTesting;
+ using AcceptanceTesting.ForwardedHeaders;
+ using NServiceBus.AcceptanceTesting;
+ using NUnit.Framework;
+
+ ///
+ /// Tests Scenario 5: Unknown Proxy Rejected from local-forward-headers-testing.md
+ /// When KnownProxies are configured but the caller IP does NOT match, headers should be ignored.
+ ///
+ class When_unknown_proxy_sends_headers : AcceptanceTest
+ {
+ ForwardedHeadersTestConfiguration configuration;
+
+ [SetUp]
+ public void ConfigureForwardedHeaders()
+ {
+ // Configure a known proxy that does NOT match localhost (test server uses localhost)
+ // This should cause headers to be ignored
+ configuration = new ForwardedHeadersTestConfiguration(ServiceControlInstanceType.Monitoring)
+ .WithKnownProxies("192.168.1.100");
+ }
+
+ [TearDown]
+ public void CleanupForwardedHeaders()
+ {
+ configuration?.Dispose();
+ }
+
+ [Test]
+ public async Task Headers_should_be_ignored_when_caller_not_in_known_proxies()
+ {
+ RequestInfoResponse requestInfo = null;
+
+ await Define()
+ .Done(async ctx =>
+ {
+ // Simulate request from IP 203.0.113.1 (TEST-NET-3, not in known proxies)
+ // The known proxy is 192.168.1.100, so this IP should be rejected
+ requestInfo = await ForwardedHeadersAssertions.GetRequestInfo(
+ HttpClient,
+ SerializerOptions,
+ xForwardedFor: "203.0.113.50",
+ xForwardedProto: "https",
+ xForwardedHost: "example.com",
+ testRemoteIp: "203.0.113.1");
+ return requestInfo != null;
+ })
+ .Run();
+
+ ForwardedHeadersAssertions.AssertHeadersIgnoredWhenProxyNotTrusted(
+ requestInfo,
+ sentXForwardedFor: "203.0.113.50",
+ sentXForwardedProto: "https",
+ sentXForwardedHost: "example.com");
+
+ // Verify configuration shows the trusted proxy (203.0.113.1 is NOT this proxy)
+ Assert.That(requestInfo.Configuration.KnownProxies, Does.Contain("192.168.1.100"));
+ }
+
+ class Context : ScenarioContext
+ {
+ }
+ }
+}
diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
index 00c545bd97..ac73dae8af 100644
--- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
+++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs
@@ -2,6 +2,7 @@ namespace ServiceControl.Monitoring.AcceptanceTests.TestSupport
{
using System;
using System.IO;
+ using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
@@ -113,7 +114,24 @@ async Task InitializeServiceControl(ScenarioContext context)
hostBuilder.AddServiceControlMonitoringTesting(settings);
host = hostBuilder.Build();
- host.UseServiceControlMonitoring();
+
+ // Test middleware: Set RemoteIpAddress from X-Test-Remote-IP header
+ // This must run BEFORE UseServiceControlMonitoring (which adds ForwardedHeaders middleware)
+ // so that the ForwardedHeaders middleware can properly check KnownProxies/KnownNetworks
+ host.Use(async (context, next) =>
+ {
+ if (context.Request.Headers.TryGetValue("X-Test-Remote-IP", out var testIpHeader))
+ {
+ var testIpValue = testIpHeader.ToString();
+ if (IPAddress.TryParse(testIpValue, out var testIp))
+ {
+ context.Connection.RemoteIpAddress = testIp;
+ }
+ }
+ await next();
+ });
+
+ host.UseServiceControlMonitoring(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.CorsSettings);
await host.StartAsync();
HttpClient = host.Services.GetRequiredKeyedService(settings.InstanceName).CreateClient();
diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt
index 070234384e..0a464e0f3b 100644
--- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt
+++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt
@@ -3,6 +3,39 @@
"LogLevel": "Information",
"LogPath": "C:\\Logs"
},
+ "OpenIdConnectSettings": {
+ "enabled": false,
+ "authority": null,
+ "audience": null,
+ "validateIssuer": false,
+ "validateAudience": false,
+ "validateLifetime": false,
+ "validateIssuerSigningKey": false,
+ "requireHttpsMetadata": false,
+ "servicePulseAuthority": null,
+ "servicePulseClientId": null,
+ "servicePulseApiScopes": null
+ },
+ "ForwardedHeadersSettings": {
+ "Enabled": true,
+ "TrustAllProxies": true,
+ "KnownProxiesRaw": [],
+ "KnownNetworks": []
+ },
+ "HttpsSettings": {
+ "Enabled": false,
+ "CertificatePath": null,
+ "CertificatePassword": null,
+ "RedirectHttpToHttps": false,
+ "HttpsPort": null,
+ "EnableHsts": false,
+ "HstsMaxAgeSeconds": 31536000,
+ "HstsIncludeSubDomains": false
+ },
+ "CorsSettings": {
+ "AllowAnyOrigin": true,
+ "AllowedOrigins": []
+ },
"InstanceName": "Particular.Monitoring",
"TransportType": "NServiceBus.ServiceControlLearningTransport, ServiceControl.Transports.LearningTransport",
"ConnectionString": null,
@@ -13,5 +46,6 @@
"RootUrl": "http://localhost:9999/",
"MaximumConcurrencyLevel": null,
"ServiceControlThroughputDataQueue": "ServiceControl.ThroughputData",
+ "ValidateConfiguration": true,
"ShutdownTimeout": "00:00:05"
}
\ No newline at end of file
diff --git a/src/ServiceControl.Monitoring/App.config b/src/ServiceControl.Monitoring/App.config
index 0a2fa4d478..4d3d8c6ed7 100644
--- a/src/ServiceControl.Monitoring/App.config
+++ b/src/ServiceControl.Monitoring/App.config
@@ -22,6 +22,46 @@ These settings are only here so that we can debug ServiceControl while developin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs
index 340c12fc08..ca648ac222 100644
--- a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs
+++ b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs
@@ -5,6 +5,8 @@ namespace ServiceControl.Monitoring
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Builder;
using NServiceBus;
+ using ServiceControl.Hosting.Auth;
+ using ServiceControl.Hosting.Https;
class RunCommand : AbstractCommand
{
@@ -13,11 +15,15 @@ public override async Task Execute(HostArguments args, Settings settings)
var endpointConfiguration = new EndpointConfiguration(settings.InstanceName);
var hostBuilder = WebApplication.CreateBuilder();
+ hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
+ hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlMonitoring((_, __) => Task.CompletedTask, settings, endpointConfiguration);
hostBuilder.AddServiceControlMonitoringApi();
var app = hostBuilder.Build();
- app.UseServiceControlMonitoring();
+ app.UseServiceControlMonitoring(settings.ForwardedHeadersSettings, settings.HttpsSettings, settings.CorsSettings);
+ app.UseServiceControlAuthentication(authenticationEnabled: settings.OpenIdConnectSettings.Enabled);
+
await app.RunAsync(settings.RootUrl);
}
}
diff --git a/src/ServiceControl.Monitoring/Http/RootController.cs b/src/ServiceControl.Monitoring/Http/RootController.cs
index c22f7f7ff1..fe15583ab0 100644
--- a/src/ServiceControl.Monitoring/Http/RootController.cs
+++ b/src/ServiceControl.Monitoring/Http/RootController.cs
@@ -1,5 +1,6 @@
namespace ServiceControl.Monitoring.Http
{
+ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
@@ -8,6 +9,7 @@ public class RootController : ControllerBase
{
[Route("")]
[HttpGet]
+ [AllowAnonymous]
public ActionResult Get()
{
var model = new MonitoringInstanceModel
diff --git a/src/ServiceControl.Monitoring/Properties/launchSettings.json b/src/ServiceControl.Monitoring/Properties/launchSettings.json
index 5365c2028e..beac1927c4 100644
--- a/src/ServiceControl.Monitoring/Properties/launchSettings.json
+++ b/src/ServiceControl.Monitoring/Properties/launchSettings.json
@@ -3,6 +3,7 @@
"ServiceControl.Monitoring": {
"commandName": "Project",
"launchBrowser": false,
+ "applicationUrl": "http://0.0.0.0:33633",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/src/ServiceControl.Monitoring/Settings.cs b/src/ServiceControl.Monitoring/Settings.cs
index 3412307042..e58b6364d6 100644
--- a/src/ServiceControl.Monitoring/Settings.cs
+++ b/src/ServiceControl.Monitoring/Settings.cs
@@ -16,6 +16,11 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n
{
LoggingSettings = loggingSettings ?? new(SettingsRootNamespace);
+ OpenIdConnectSettings = new OpenIdConnectSettings(SettingsRootNamespace, ValidateConfiguration, requireServicePulseSettings: false);
+ ForwardedHeadersSettings = new ForwardedHeadersSettings(SettingsRootNamespace);
+ HttpsSettings = new HttpsSettings(SettingsRootNamespace);
+ CorsSettings = new CorsSettings(SettingsRootNamespace);
+
// Overwrite the instance name if it is specified in ENVVAR, reg, or config file
InstanceName = SettingsReader.Read(SettingsRootNamespace, "InstanceName", InstanceName);
@@ -49,6 +54,14 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n
public LoggingSettings LoggingSettings { get; }
+ public OpenIdConnectSettings OpenIdConnectSettings { get; }
+
+ public ForwardedHeadersSettings ForwardedHeadersSettings { get; }
+
+ public HttpsSettings HttpsSettings { get; }
+
+ public CorsSettings CorsSettings { get; }
+
public string InstanceName { get; init; } = DEFAULT_INSTANCE_NAME;
public string TransportType { get; set; }
@@ -63,12 +76,14 @@ public Settings(LoggingSettings loggingSettings = null, string transportType = n
public TimeSpan EndpointUptimeGracePeriod { get; set; }
- public string RootUrl => $"http://{HttpHostName}:{HttpPort}/";
+ public string RootUrl => $"{(HttpsSettings.Enabled ? "https" : "http")}://{HttpHostName}:{HttpPort}/";
public int? MaximumConcurrencyLevel { get; set; }
public string ServiceControlThroughputDataQueue { get; set; }
+ public bool ValidateConfiguration => SettingsReader.Read(SettingsRootNamespace, "ValidateConfig", true);
+
// The default value is set to the maximum allowed time by the most
// restrictive hosting platform, which is Linux containers. Linux
// containers allow for a maximum of 10 seconds. We set it to 5 to
diff --git a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs
index db4e2d3a3e..efceb80c46 100644
--- a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs
+++ b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs
@@ -1,21 +1,33 @@
namespace ServiceControl.Monitoring.Infrastructure;
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.HttpOverrides;
+using ServiceControl.Hosting.ForwardedHeaders;
+using ServiceControl.Hosting.Https;
+using ServiceControl.Infrastructure;
public static class WebApplicationExtensions
{
- public static void UseServiceControlMonitoring(this WebApplication appBuilder)
+ public static void UseServiceControlMonitoring(this WebApplication appBuilder, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings, CorsSettings corsSettings)
{
- appBuilder.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All });
+ appBuilder.UseServiceControlForwardedHeaders(forwardedHeadersSettings);
+ appBuilder.UseServiceControlHttps(httpsSettings);
appBuilder.UseHttpLogging();
appBuilder.UseCors(policyBuilder =>
{
- policyBuilder.AllowAnyOrigin();
+ if (corsSettings.AllowAnyOrigin)
+ {
+ policyBuilder.AllowAnyOrigin();
+ }
+ else if (corsSettings.AllowedOrigins.Count > 0)
+ {
+ policyBuilder.WithOrigins([.. corsSettings.AllowedOrigins]);
+ policyBuilder.AllowCredentials();
+ }
+
policyBuilder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version"]);
- policyBuilder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept"]);
+ policyBuilder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]);
policyBuilder.WithMethods(["POST", "GET", "PUT", "DELETE", "OPTIONS", "PATCH"]);
});
diff --git a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs
index 4940807646..549a37d946 100644
--- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs
+++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs
@@ -177,8 +177,8 @@ public async Task StoreFailedErrorImport(FailedErrorImport failure)
await session.SaveChangesAsync();
}
+ // the edit failed message manager manages the lifetime of the session
public async Task CreateEditFailedMessageManager() =>
- // the edit failed message manager manages the lifetime of the session
new EditFailedMessageManager(await sessionProvider.OpenSession(), expirationManager);
public async Task> GetFailureGroupView(string groupId, string status, string modified)
@@ -323,8 +323,8 @@ async Task ErrorByDocumentId(string documentId)
return message;
}
+ // the notifications manager manages the lifetime of the session
public async Task CreateNotificationsManager() =>
- // the notifications manager manages the lifetime of the session
new NotificationsManager(await sessionProvider.OpenSession());
public async Task ErrorLastBy(string failedMessageId)
diff --git a/src/ServiceControl.Persistence/RetryHistory.cs b/src/ServiceControl.Persistence/RetryHistory.cs
index 995c9875fb..f1472f94b1 100644
--- a/src/ServiceControl.Persistence/RetryHistory.cs
+++ b/src/ServiceControl.Persistence/RetryHistory.cs
@@ -45,8 +45,8 @@ public void AddToUnacknowledged(UnacknowledgedRetryOperation unacknowledgedRetry
{
UnacknowledgedOperations.Add(unacknowledgedRetryOperation);
+ // All other retry types already have an explicit way to dismiss them on the UI
UnacknowledgedOperations = UnacknowledgedOperations
- // All other retry types already have an explicit way to dismiss them on the UI
.Where(operation => operation.RetryType is not RetryType.MultipleMessages and not RetryType.SingleMessage)
.ToList();
}
diff --git a/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs b/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs
index 87b3efd1bb..bfa5470c32 100644
--- a/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs
+++ b/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs
@@ -57,9 +57,9 @@ public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, Jso
}
// Ut8Parser.TryParse also handles some short format "g" cases which has a minimum of 1 chars independent of the format identifier
- if ((!Utf8Parser.TryParse(source, out TimeSpan parsedTimeSpan, out int bytesConsumed, 'c') || source.Length != bytesConsumed) &&
- // Otherwise we fall back to read with the short format "g" directly since that is what the SagaAudit plugin used to stay backward compatible
- (!Utf8Parser.TryParse(source, out parsedTimeSpan, out bytesConsumed, 'g') || source.Length != bytesConsumed))
+ // Otherwise we fall back to read with the short format "g" directly since that is what the SagaAudit plugin used to stay backward compatible
+ if ((!Utf8Parser.TryParse(source, out TimeSpan parsedTimeSpan, out int bytesConsumed, 'c') || source.Length != bytesConsumed)
+ && (!Utf8Parser.TryParse(source, out parsedTimeSpan, out bytesConsumed, 'g') || source.Length != bytesConsumed))
{
ThrowFormatException();
}
diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt
index c33a62237d..9b088fad11 100644
--- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt
+++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt
@@ -1,6 +1,7 @@
GET / => ServiceControl.Infrastructure.WebApi.RootController:Urls()
GET /archive/groups/id/{groupId:required:minlength(1)} => ServiceControl.MessageFailures.Api.ArchiveMessagesController:GetGroup(String groupId, String status, String modified)
GET /configuration => ServiceControl.Infrastructure.WebApi.RootController:Config()
+GET /configuration => ServiceControl.Authentication.AuthenticationController:Configuration()
GET /configuration/remotes => ServiceControl.Infrastructure.WebApi.RootController:RemoteConfig(CancellationToken cancellationToken)
GET /connection => ServiceControl.Connection.ConnectionController:GetConnectionDetails()
GET /conversations/{conversationId:required:minlength(1)} => ServiceControl.CompositeViews.Messages.GetMessagesByConversationController:Messages(PagingInfo pagingInfo, SortInfo sortInfo, Boolean includeSystemMessages, String conversationId)
diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
index 246f3e5678..c08e5690dd 100644
--- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
+++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt
@@ -3,6 +3,39 @@
"LogLevel": "Information",
"LogPath": "C:\\Logs"
},
+ "OpenIdConnectSettings": {
+ "enabled": false,
+ "authority": null,
+ "audience": null,
+ "validateIssuer": false,
+ "validateAudience": false,
+ "validateLifetime": false,
+ "validateIssuerSigningKey": false,
+ "requireHttpsMetadata": false,
+ "servicePulseAuthority": null,
+ "servicePulseClientId": null,
+ "servicePulseApiScopes": null
+ },
+ "ForwardedHeadersSettings": {
+ "Enabled": true,
+ "TrustAllProxies": true,
+ "KnownProxiesRaw": [],
+ "KnownNetworks": []
+ },
+ "HttpsSettings": {
+ "Enabled": false,
+ "CertificatePath": null,
+ "CertificatePassword": null,
+ "RedirectHttpToHttps": false,
+ "HttpsPort": null,
+ "EnableHsts": false,
+ "HstsMaxAgeSeconds": 31536000,
+ "HstsIncludeSubDomains": false
+ },
+ "CorsSettings": {
+ "AllowAnyOrigin": true,
+ "AllowedOrigins": []
+ },
"NotificationsFilter": null,
"AllowMessageEditing": false,
"MessageFilter": null,
diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs
new file mode 100644
index 0000000000..debc45200e
--- /dev/null
+++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/ForwardedHeadersSettingsTests.cs
@@ -0,0 +1,163 @@
+namespace ServiceControl.UnitTests.Infrastructure.Settings;
+
+using System;
+using System.Linq;
+using NUnit.Framework;
+using ServiceControl.Configuration;
+using ServiceControl.Infrastructure;
+
+///
+/// Tests for which is shared infrastructure code
+/// used by all three instance types (ServiceControl, ServiceControl.Audit, ServiceControl.Monitoring).
+/// Each instance passes a different which only affects
+/// the environment variable prefix (e.g., SERVICECONTROL_, SERVICECONTROL_AUDIT_, MONITORING_).
+/// The parsing logic is identical, so testing with one namespace is sufficient.
+///
+[TestFixture]
+public class ForwardedHeadersSettingsTests
+{
+ static readonly SettingsRootNamespace TestNamespace = new("ServiceControl");
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Clean up environment variables after each test
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_ENABLED", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_TRUSTALLPROXIES", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", null);
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", null);
+ }
+
+ [Test]
+ public void Should_parse_known_proxies_from_comma_separated_list()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,10.0.0.5,192.168.1.1");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(3));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("127.0.0.1"));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("10.0.0.5"));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("192.168.1.1"));
+ }
+
+ [Test]
+ public void Should_parse_known_proxies_to_ip_addresses()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,10.0.0.5");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+ var ipAddresses = settings.KnownProxies.ToList();
+
+ Assert.That(ipAddresses, Has.Count.EqualTo(2));
+ Assert.That(ipAddresses[0].ToString(), Is.EqualTo("127.0.0.1"));
+ Assert.That(ipAddresses[1].ToString(), Is.EqualTo("10.0.0.5"));
+ }
+
+ [Test]
+ public void Should_ignore_invalid_ip_addresses()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1,not-an-ip,10.0.0.5");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("127.0.0.1"));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("10.0.0.5"));
+ Assert.That(settings.KnownProxiesRaw, Does.Not.Contain("not-an-ip"));
+ }
+
+ [Test]
+ public void Should_parse_known_networks_from_comma_separated_cidr()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownNetworks, Has.Count.EqualTo(3));
+ Assert.That(settings.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(settings.KnownNetworks, Does.Contain("172.16.0.0/12"));
+ Assert.That(settings.KnownNetworks, Does.Contain("192.168.0.0/16"));
+ }
+
+ [Test]
+ public void Should_ignore_invalid_network_cidr()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", "10.0.0.0/8,invalid-network,172.16.0.0/12");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownNetworks, Has.Count.EqualTo(2));
+ Assert.That(settings.KnownNetworks, Does.Contain("10.0.0.0/8"));
+ Assert.That(settings.KnownNetworks, Does.Contain("172.16.0.0/12"));
+ Assert.That(settings.KnownNetworks, Does.Not.Contain("invalid-network"));
+ }
+
+ [Test]
+ public void Should_disable_trust_all_proxies_when_known_proxies_configured()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.TrustAllProxies, Is.False);
+ }
+
+ [Test]
+ public void Should_disable_trust_all_proxies_when_known_networks_configured()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNNETWORKS", "10.0.0.0/8");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.TrustAllProxies, Is.False);
+ }
+
+ [Test]
+ public void Should_default_to_enabled()
+ {
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.Enabled, Is.True);
+ }
+
+ [Test]
+ public void Should_default_to_trust_all_proxies()
+ {
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.TrustAllProxies, Is.True);
+ }
+
+ [Test]
+ public void Should_respect_explicit_disabled_setting()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_ENABLED", "false");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.Enabled, Is.False);
+ }
+
+ [Test]
+ public void Should_handle_semicolon_separator_in_proxies()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", "127.0.0.1;10.0.0.5");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2));
+ }
+
+ [Test]
+ public void Should_trim_whitespace_from_proxy_entries()
+ {
+ Environment.SetEnvironmentVariable("SERVICECONTROL_FORWARDEDHEADERS_KNOWNPROXIES", " 127.0.0.1 , 10.0.0.5 ");
+
+ var settings = new ForwardedHeadersSettings(TestNamespace);
+
+ Assert.That(settings.KnownProxiesRaw, Has.Count.EqualTo(2));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("127.0.0.1"));
+ Assert.That(settings.KnownProxiesRaw, Does.Contain("10.0.0.5"));
+ }
+}
diff --git a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs
index 22eee8c632..4c327da5c8 100644
--- a/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs
+++ b/src/ServiceControl.UnitTests/ScatterGather/MessageView_ScatterGatherTest.cs
@@ -6,6 +6,7 @@
using System.Net.Http;
using System.Threading.Tasks;
using CompositeViews.Messages;
+ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
@@ -17,7 +18,7 @@ abstract class MessageView_ScatterGatherTest
[SetUp]
public void SetUp()
{
- var api = new TestApi(null, null, null, NullLogger.Instance);
+ var api = new TestApi(null, null, null, null, NullLogger.Instance);
Results = api.AggregateResults(new ScatterGatherApiMessageViewContext(new PagingInfo(), new SortInfo()), GetData());
}
@@ -68,8 +69,8 @@ protected IEnumerable RemoteData()
class TestApi : ScatterGatherApiMessageView
+
+
+
+
diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs
index dfa7511613..685bc7dc16 100644
--- a/src/ServiceControl/WebApplicationExtensions.cs
+++ b/src/ServiceControl/WebApplicationExtensions.cs
@@ -3,13 +3,16 @@ namespace ServiceControl;
using Infrastructure.SignalR;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.HttpOverrides;
+using ServiceControl.Hosting.ForwardedHeaders;
+using ServiceControl.Hosting.Https;
+using ServiceControl.Infrastructure;
public static class WebApplicationExtensions
{
- public static void UseServiceControl(this WebApplication app)
+ public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings)
{
- app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All });
+ app.UseServiceControlForwardedHeaders(forwardedHeadersSettings);
+ app.UseServiceControlHttps(httpsSettings);
app.UseResponseCompression();
app.UseMiddleware();
app.UseHttpLogging();