diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02e899fe0a..cd5a119de3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v5.1.0 with: - dotnet-version: 7.0.x + dotnet-version: 8.0.x - name: Set up Node.js uses: actions/setup-node@v6.1.0 with: diff --git a/.gitignore b/.gitignore index 5bf25ccac6..3a703b0f97 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,10 @@ src/scaffolding.config # Visual Studio Code .vscode + +# AI +.claude +CLAUDE.md + +# local testing +.local \ No newline at end of file diff --git a/docs/authentication-testing.md b/docs/authentication-testing.md new file mode 100644 index 0000000000..b02c42bbf2 --- /dev/null +++ b/docs/authentication-testing.md @@ -0,0 +1,986 @@ +# Local Testing Authentication + +This guide provides scenario-based tests for ServicePulse's OIDC authentication. Use this to verify authentication behavior during local development. + +For additional details on authentication in ServicePulse, see the [ServicePulse Security](https://docs.particular.net/servicepulse/security/configuration/authentication) documentation. + +## Prerequisites + +- ServicePulse built locally (see [main README for instructions](../README.md#setting-up-the-project-for-development)) +- ServiceControl instance running (provides authentication configuration) - See the [hosting guide in ServiceControl docs](https://docs.particular.net/servicecontrol/security/hosting-guide) for more info. +- (Optional) An OIDC identity provider for testing authenticated scenarios + +### Building the Frontend + +```cmd +cd src\Frontend +npm install +npm run build +``` + +## Host Options + +ServicePulse has two hosting options for local testing: + +### .NET 8 Host + +Uses ASP.NET Core with built-in YARP reverse proxy. Configuration via environment variables. + +```cmd +cd src\ServicePulse +dotnet run +``` + +Default URL: `http://localhost:5291` + +### .NET Framework Host + +Uses OWIN self-hosting. Configuration via command-line arguments. + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url="http://localhost:8081" +``` + +Default URL: `http://localhost:8081` + +> [!NOTE] +> The .NET Framework host does not have a built-in reverse proxy. The frontend connects directly to ServiceControl, which requires CORS configuration in ServiceControl. +> For HTTPS with the .NET Framework host, you must bind the SSL certificate at the OS level using `netsh`. See [HTTPS Testing](https-testing.md) for details. + +## Test Scenarios + +### Scenario 1: Authentication Disabled (Default) + +Verify that ServicePulse works without authentication when ServiceControl has auth disabled. + +#### Option A: Using Mocks (No ServiceControl Required) + +Use the mock scenario for frontend development without running ServiceControl. + +**Start with mocks:** + +```cmd +cd src\Frontend +set VITE_MOCK_SCENARIO=auth-disabled +npm run dev:mocks +``` + +**Test in browser:** + +1. Open `http://localhost:5173` +2. ServicePulse should load directly without any login prompt +3. The user profile menu should NOT appear in the header +4. Console should show: `Loading mock scenario: auth-disabled` + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication +``` + +#### Option B: Using Real ServiceControl + +**Prerequisites:** + +- ServiceControl running with authentication disabled (default) + +**Start ServicePulse (.NET 8 host):** + +```cmd +cd src\ServicePulse +dotnet run +``` + +**Test in browser:** + +1. Open `http://localhost:5291` +2. ServicePulse should load directly without any login prompt +3. The user profile menu should NOT appear in the header + +**Verify with curl:** + +```cmd +curl http://localhost:5291/js/app.constants.js +``` + +**Expected:** The constants file loads successfully. Access to ServicePulse does not require authentication. + +#### Option C: Using .NET Framework Host + +**Prerequisites:** + +- ServiceControl running with authentication disabled (default) +- ServicePulse.Host built + +**Start ServicePulse (.NET Framework host):** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url="http://localhost:8081" +``` + +**Test in browser:** + +1. Open `http://localhost:8081` +2. ServicePulse should load directly without any login prompt +3. The user profile menu should NOT appear in the header + +**Verify with curl:** + +```cmd +curl http://localhost:8081/js/app.constants.js +``` + +**Expected:** The constants file loads successfully. Access to ServicePulse does not require authentication. + +### Scenario 2: Verify Authentication Configuration Endpoint + +Test that ServiceControl returns the correct authentication configuration for ServicePulse. + +#### Option A: Using Mocks (No ServiceControl Required) + +Use the mock scenario to verify the auth configuration endpoint returns the expected response shape. + +> [!WARNING] +> The app will attempt to redirect to the identity provider, which will fail without a real IdP - this is expected behavior. + +**Start with mocks:** + +```cmd +cd src\Frontend +set VITE_MOCK_SCENARIO=auth-enabled +npm run dev:mocks +``` + +**Test in browser:** + +1. Open `http://localhost:5173` +2. Open Developer Tools > Network tab +3. Look for request to `/api/authentication/configuration` +4. Verify response matches the expected mock values below + +**Expected mock response:** + +```json +{ + "enabled": true, + "client_id": "servicepulse-test", + "authority": "https://login.microsoftonline.com/test-tenant-id/v2.0", + "api_scopes": "[\"api://servicecontrol/access_as_user\"]", + "audience": "api://servicecontrol" +} +``` + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication/auth-enabled +``` + +#### Option B: Using Real ServiceControl (.NET 8 Host) + +**Configure and start ServiceControl:** + +See ServiceControl docs for correct setup + +Set ServiceControl and ServicePulse with HTTPS. + +```cmd +set SERVICEPULSE_HTTPS_ENABLED=true +set SERVICEPULSE_HTTPS_CERTIFICATEPATH=C:\Users\warwi\source\repos\Particular\ServicePulse\.local\certs\localhost.pfx +set SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD=changeit +set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICEPULSE_HTTPS_PORT= +set SERVICEPULSE_HTTPS_ENABLEHSTS= + +cd src\ServicePulse +dotnet run +``` + +**Test with curl:** + +```cmd +curl --ssl-no-revoke https://localhost:33333/api/authentication/configuration | json +``` + +**Expected output:** + +```json +{ + "enabled": true, + "client_id": "{servicepulse-client-id}", + "authority": "https://login.microsoftonline.com/{tenant-id}/v2.0", + "audience": "api://servicecontrol", + "api_scopes": "[\"api://servicecontrol/access_as_user\"]" +} +``` + +The configuration endpoint is accessible without authentication and returns all fields ServicePulse needs to initiate the OIDC flow. + +#### Option C: Using Real ServiceControl (.NET Framework Host) + +**Prerequisites:** + +- ServiceControl running with authentication enabled +- SSL certificate bound at OS level via `netsh` (see [HTTPS Testing](https-testing.md)) +- ServicePulse.Host built (run `build.ps1` from repo root) + +See [HTTPS Testing](https-testing.md) for detailed certificate setup instructions. + +**Start ServicePulse (.NET Framework host with HTTPS):** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url="https://localhost:9090" --httpsenabled=true +``` + +Ensure you update the ServiceControl and Monitoring URLs to HTTLS + +**Test with curl:** + +```cmd +curl --ssl-no-revoke https://localhost:33333/api/authentication/configuration | json +``` + +**Expected output:** + +```json +{ + "enabled": true, + "client_id": "{servicepulse-client-id}", + "authority": "https://login.microsoftonline.com/{tenant-id}/v2.0", + "audience": "api://servicecontrol", + "api_scopes": "[\"api://servicecontrol/access_as_user\"]" +} +``` + +> [!NOTE] +> The .NET Framework host does not have a reverse proxy. Authentication configuration is fetched directly from ServiceControl, so ensure ServiceControl has CORS configured to allow requests from the ServicePulse origin. + +### Scenario 3: Authentication Enabled (Browser Flow) + +Verify the OIDC login flow when authentication is enabled. + +#### Using Mocks (No ServiceControl Required) + +Use the mock scenario to simulate an authenticated user state. This bypasses the actual OIDC redirect flow and injects a mock token directly. + +**Start with mocks:** + +```cmd +cd src\Frontend +set VITE_MOCK_SCENARIO=auth-authenticated +npm run dev:mocks +``` + +**Test in browser:** + +1. Open `http://localhost:5173` +2. Dashboard should load directly (no login redirect) +3. User profile menu should appear in the header +4. Check Developer Tools > Application > Session Storage for `auth_token` + +**Expected behavior:** + +- Console shows: `Existing user session found { name: 'Test User', email: 'test.user@example.com' }` +- Console shows: `User authenticated successfully` +- Dashboard loads without redirect + +**Check Session Storage (Developer Tools > Application > Session Storage):** + +Key: `oidc.user:https://login.microsoftonline.com/test-tenant-id/v2.0:servicepulse-test` + +```json +{ + "access_token": "mock-access-token-for-testing", + "token_type": "Bearer", + "profile": { + "name": "Test User", + "email": "test.user@example.com", + "sub": "user-123" + } +} +``` + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication/auth-authenticated +``` + +#### Using Real ServiceControl and Identity Provider (.NET 8 Host) + +**Prerequisites:** + +- ServiceControl running with authentication enabled (see Scenario 2) +- OIDC identity provider configured (Microsoft Entra ID, Okta, Auth0, etc.) + +**Start ServicePulse:** + +```cmd +cd src\ServicePulse +dotnet run +``` + +**Test in browser:** + +1. Open `https://localhost:5291` +2. ServicePulse should show a loading screen while fetching auth config +3. Browser should redirect to the identity provider login page +4. Enter valid credentials (if not already authenticated in current session) +5. After successful login, browser redirects back to ServicePulse +6. ServicePulse dashboard should load +7. User profile menu should appear in the header showing user name and email + +#### Using Real ServiceControl and Identity Provider (.NET Framework Host) + +**Prerequisites:** + +- ServiceControl running with authentication enabled (see Scenario 2) +- OIDC identity provider configured (Microsoft Entra ID, Okta, Auth0, etc.) +- SSL certificate bound at OS level via `netsh` +- ServicePulse.Host built (run `build.ps1` from repo root) +- Redirect URI registered in IdP: `https://localhost:8443/` + +**Start ServicePulse (.NET Framework host):** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url="https://localhost:8443" --httpsenabled=true +``` + +**Test in browser:** + +1. Open `https://localhost:8443` +2. ServicePulse should show a loading screen while fetching auth config +3. Browser should redirect to the identity provider login page +4. Enter valid credentials (if not already authenticated in current session) +5. After successful login, browser redirects back to ServicePulse +6. ServicePulse dashboard should load +7. User profile menu should appear in the header showing user name and email + +> [!IMPORTANT] +> Ensure the redirect URI `https://localhost:8443/` is registered in your identity provider's application configuration. The .NET Framework host uses a different port than the .NET 8 host. + +### Scenario 4: Token Included in API Requests + +Verify that authenticated requests include the Bearer token. + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication/auth-token-in-requests +``` + +**Manual test in browser:** + +Prerequisites: Completed Scenario 3 (logged in successfully) + +1. Open browser Developer Tools (F12) +2. Go to the Network tab +3. Navigate to different pages in ServicePulse (Dashboard, Failed Messages, etc.) +4. Click on API requests to ServiceControl (e.g., `/api/endpoints`) +5. Check the Request Headers + +**Expected:** Each API request includes: + +```text +Authorization: Bearer eyJhbGciOiJSUzI1NiIs... +``` + +**Test with curl (using a token):** + +If you have obtained a token (e.g., from browser Developer Tools > Application > Session Storage > `oidc.user:*`), you can test API requests directly: + +```cmd +rem Set your token (copy from browser session storage) +set TOKEN=eyJhbGciOiJSUzI1NiIs... + +rem Test against ServiceControl directly +curl -v -H "Authorization: Bearer %TOKEN%" http://localhost:33333/api/endpoints + +rem Test through ServicePulse reverse proxy +curl -v -H "Authorization: Bearer %TOKEN%" http://localhost:5291/api/endpoints +``` + +**Expected:** Both requests return `200 OK` with endpoint data (JSON array). + +### Scenario 5: Unauthenticated API Access Blocked + +Verify that API requests without a token are rejected when auth is enabled. + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication/auth-unauthenticated +``` + +**Manual test with curl (no token):** + +Prerequisites: ServiceControl running with authentication enabled + +```cmd +curl -v http://localhost:33333/api/endpoints +``` + +**Expected output:** + +```text +HTTP/1.1 401 Unauthorized +``` + +The request is rejected because no Bearer token was provided. + +### Scenario 6: Session Persistence + +Verify that the session persists within a browser tab. + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication/auth-session-persistence +``` + +**Manual test in browser:** + +Prerequisites: Completed Scenario 3 (logged in successfully) + +1. After logging in, note that ServicePulse is working +2. Refresh the page (F5) +3. ServicePulse should reload without requiring login again +4. Navigate between different pages + +**Expected:** The session persists. User remains authenticated without re-login. + +### Scenario 7: Session Isolation Between Tabs + +Verify that sessions are isolated between browser tabs. + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication/auth-tab-isolation +``` + +The automated test verifies that the auth token is stored in `sessionStorage` (tab-specific) and NOT in `localStorage` (shared across tabs). + +**Manual test in browser:** + +Prerequisites: Completed Scenario 3 (logged in successfully in one tab) + +1. Open a new browser tab +2. Navigate to `http://localhost:5291` +3. The new tab should redirect to the identity provider for login + +**Expected:** Each tab requires its own login because tokens are stored in `sessionStorage` (tab-specific). + +> [!NOTE] +> If your identity provider maintains a session (SSO), the login may complete automatically without prompting for credentials. + +### Scenario 8: Logout Flow + +Verify that logout clears the session and redirects to the logged-out page. + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication/auth-logout +``` + +**Manual test in browser:** + +Prerequisites: Completed Scenario 3 (logged in successfully) + +1. Click on the user profile menu in the header +2. Click "Log out" +3. Browser should redirect to the identity provider's logout page +4. After IdP logout, browser redirects to `http://localhost:5291/#/logged-out` +5. The logged-out page displays: + - "You have been signed out" message + - "Sign in again" button +6. Click "Sign in again" to initiate a new login + +**Expected:** The session is cleared. User sees the logged-out confirmation page and can sign in again. + +**Test with curl (verify logged-out page is accessible without auth):** + +```cmd +curl http://localhost:5291/#/logged-out +``` + +**Expected:** The logged-out page should be accessible without authentication (it's an anonymous route). + +### Scenario 9: Silent Token Renewal + +Verify that tokens are renewed automatically before expiration. + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication/auth-silent-renewal +``` + +The automated test verifies that the UserManager is initialized with silent renewal support (mocked `signinSilent` method). + +**Manual test in browser:** + +Prerequisites: + +- Completed Scenario 3 (logged in successfully) +- Identity provider configured with short-lived access tokens (for testing) + +1. Open browser Developer Tools (F12) +2. Go to the Network tab +3. Filter by "silent-renew" +4. Leave ServicePulse open and wait for the token to approach expiration +5. Watch for a request to `silent-renew.html` + +**Expected:** Before the token expires, a silent renewal request is made via an iframe. The token is refreshed without user interaction. + +> [!NOTE] +> Silent renewal timing depends on your identity provider's token lifetime configuration. + +### Scenario 10: Silent Renewal Failure + +Verify behavior when silent renewal fails (e.g., IdP session expired). + +**Run automated tests:** + +```cmd +cd src\Frontend +npx vitest run ./test/specs/authentication/auth-renewal-failure +``` + +The automated test uses a mock where `signinSilent` fails, verifying the app handles renewal failures gracefully. + +**Manual test in browser:** + +Prerequisites: + +- Completed Scenario 3 (logged in successfully) +- Identity provider session expired or revoked + +1. Log in to ServicePulse +2. In a separate tab, log out from your identity provider (or wait for IdP session to expire) +3. Return to ServicePulse and wait for token renewal to be attempted +4. Or refresh the page + +**Expected:** ServicePulse detects the authentication failure and redirects to the identity provider login page. + +### Scenario 11: Invalid Redirect URI + +Verify error handling when redirect URI is misconfigured. + +**Prerequisites:** + +- ServiceControl with authentication enabled +- Redirect URI in identity provider does NOT match ServicePulse URL + +**Test in browser:** + +1. Open `http://localhost:5291` +2. Browser redirects to identity provider +3. After login, identity provider rejects the redirect + +**Expected:** Identity provider shows an error about invalid redirect URI. This indicates misconfiguration in the IdP application registration. + +**Automated test:** + +```bash +cd src/Frontend +npx vitest run test/specs/authentication/auth-invalid-redirect.spec.ts +``` + +> [!NOTE] +> The automated test verifies the app's behavior when OAuth callbacks fail. The actual "invalid redirect URI" error is displayed by the identity provider itself, not the application. + +### Scenario 12: YARP Reverse Proxy Token Forwarding + +Verify that the internal YARP reverse proxy forwards Bearer tokens to ServiceControl. + +**Prerequisites:** + +- ServiceControl running with authentication enabled (see Scenario 2) +- ServicePulse running with reverse proxy enabled (default) +- Completed Scenario 3 (logged in successfully) + +**How YARP works:** + +When the reverse proxy is enabled (default), ServicePulse serves the frontend at `https://localhost:5291` and proxies API requests: + +- `/api/*` → forwarded to ServiceControl (e.g., `http://localhost:33333/`) +- `/monitoring-api/*` → forwarded to Monitoring instance + +YARP automatically forwards the `Authorization` header to downstream services. + +**Test in browser:** + +1. Open browser Developer Tools (F12) > Network tab +2. Navigate to the Dashboard or any page that loads data +3. Find a request to `/api/endpoints` (or similar) +4. Verify the request URL is `/api/endpoints` (relative, through YARP) +5. Check the Request Headers include `Authorization: Bearer ...` +6. Verify the response is `200 OK` with data (not `401 Unauthorized`) + +**Expected:** Requests through the YARP proxy include the Bearer token and ServiceControl accepts them. + +**Verify reverse proxy is enabled:** + +```cmd +curl https://localhost:5291/js/app.constants.js +``` + +When reverse proxy is enabled, `service_control_url` should be `"/api/"` (relative path). + +**Automated test:** + +```bash +cd src/Frontend +npx vitest run test/specs/authentication/auth-yarp-proxy.spec.ts +``` + +### Scenario 13: Direct ServiceControl Access (Reverse Proxy Disabled) + +Verify authentication works when the reverse proxy is disabled and the frontend connects directly to ServiceControl. + +**Prerequisites:** + +- ServiceControl running with authentication enabled +- OIDC identity provider configured + +**Start ServicePulse with reverse proxy disabled:** + +```cmd +set ENABLE_REVERSE_PROXY=false +cd src\ServicePulse +dotnet run +``` + +**Verify configuration:** + +```cmd +curl https://localhost:5291/js/app.constants.js +``` + +When reverse proxy is disabled, `service_control_url` should be the full ServiceControl URL (e.g., `"http://localhost:33333/api/"`). + +**Test in browser:** + +1. Open `https://localhost:5291` +2. Complete the login flow +3. Open Developer Tools > Network tab +4. Navigate to pages that load data +5. Verify requests go directly to ServiceControl URL (not `/api/`) +6. Verify requests include `Authorization: Bearer ...` header +7. Verify responses are successful + +**Expected:** Direct requests to ServiceControl include the Bearer token and are accepted. + +**Automated test:** + +```bash +cd src/Frontend +npx vitest run test/specs/authentication/auth-direct-access.spec.ts +``` + +### Scenario 14: Forwarded Headers with Authentication + +Verify that forwarded headers work correctly with authentication when behind a reverse proxy (e.g., NGINX). + +**Prerequisites:** + +- ServiceControl running with authentication enabled +- External reverse proxy (NGINX) configured (see [Reverse Proxy Testing](nginx-testing.md)) +- ServicePulse configured to trust forwarded headers + +#### Using .NET 8 Host + +**Configure ServicePulse:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true +cd src\ServicePulse +dotnet run +``` + +**Test in browser:** + +1. Access ServicePulse through the external reverse proxy (e.g., `https://servicepulse.localhost/`) +2. Complete the login flow +3. Verify the OAuth redirect URI uses the correct external URL +4. Verify ServicePulse loads successfully after login + +**Expected:** The forwarded headers (`X-Forwarded-Proto`, `X-Forwarded-Host`) are processed correctly, and OAuth redirects use the external URL. + +#### Using .NET Framework Host + +**Configure ServicePulse:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url="http://localhost:8081" --forwardedheadersenabled=true --forwardedheaderstrustallproxies=true +``` + +Or to restrict to specific proxy IPs: + +```cmd +ServicePulse.Host.exe --url="http://localhost:8081" --forwardedheadersenabled=true --forwardedheaderstrustallproxies=false --forwardedheadersknownproxies="127.0.0.1,::1" +``` + +**Test in browser:** + +1. Access ServicePulse through the external reverse proxy (e.g., `https://servicepulse.localhost/`) +2. Complete the login flow +3. Verify the OAuth redirect URI uses the correct external URL +4. Verify ServicePulse loads successfully after login + +**Expected:** The forwarded headers (`X-Forwarded-Proto`, `X-Forwarded-Host`) are processed correctly, and OAuth redirects use the external URL. + +> [!NOTE] +> The .NET Framework host has forwarded headers enabled by default with `TrustAllProxies=true`. For production, consider specifying known proxies explicitly. + +### Scenario 15: Auth Configuration Endpoint Unavailable + +Verify graceful handling when ServiceControl is unavailable during authentication configuration fetch. + +**Prerequisites:** + +- ServicePulse running +- ServiceControl NOT running + +**Test in browser:** + +1. Stop ServiceControl +2. Open `https://localhost:5291` +3. Observe the loading behavior + +**Expected:** ServicePulse should display an error message indicating it cannot connect to ServiceControl, rather than crashing or showing a blank screen. + +**Automated test:** + +```bash +cd src/Frontend +npx vitest run test/specs/authentication/auth-config-unavailable.spec.ts +``` + +> [!NOTE] +> The automated tests verify the app handles auth config endpoint errors gracefully by falling back to "auth disabled" mode, allowing users to still access the dashboard. + +### Scenario 16: OAuth Callback Error Handling + +Verify that OAuth errors returned in the callback are handled gracefully. + +**Prerequisites:** + +- ServiceControl running with authentication enabled +- OIDC identity provider configured + +**Test by simulating an error:** + +1. Manually navigate to a URL with an error parameter: + + ```text + https://localhost:5291/?error=access_denied&error_description=User%20cancelled%20the%20login + ``` + +2. Observe the behavior + +**Expected:** ServicePulse should display the error message to the user and allow them to retry authentication. + +**Automated test:** + +```bash +cd src/Frontend +npx vitest run test/specs/authentication/auth-callback-error.spec.ts +``` + +### Scenario 17: Monitoring Instance Authentication + +Verify that authentication tokens are forwarded to the Monitoring instance as well. + +**Prerequisites:** + +- ServiceControl running with authentication enabled +- ServiceControl Monitoring instance running +- Completed Scenario 3 (logged in successfully) + +**Test in browser:** + +1. Navigate to the Monitoring tab in ServicePulse +2. Open Developer Tools > Network tab +3. Find requests to `/monitoring-api/*` +4. Verify requests include `Authorization: Bearer ...` header +5. Verify Monitoring data loads successfully + +**Expected:** Monitoring API requests through YARP include the Bearer token and return data successfully. + +## Development Mode with Mocks + +For frontend development without a real identity provider, you can use MSW (Mock Service Worker) to mock the authentication endpoint. + +**Start with mocks:** + +```cmd +cd src\Frontend +npm run dev:mocks +``` + +**Mock authentication disabled:** + +The default mock configuration returns authentication as disabled. To test authenticated scenarios, you'll need to add mock handlers for the auth configuration endpoint. + +## Automated Testing + +Automated tests for authentication use **Vitest + MSW** (Mock Service Worker) to test auth flows without requiring a real identity provider. + +**Approach:** + +- Mock the `/api/authentication/configuration` endpoint to return auth-enabled config +- Mock the `oidc-client-ts` UserManager to simulate authenticated users +- Test UI behavior (profile menu appears, logout works, etc.) +- Test that API requests include the Bearer token + +**Run automated auth tests:** + +```cmd +cd src\Frontend +npm run test:component +``` + +See `src/Frontend/test/preconditions/` for auth-related test setup factories. + +## Troubleshooting + +### "Authentication required" but no redirect + +**Possible causes:** + +1. ServiceControl auth configuration missing `authority` or `client_id` +2. OIDC library failed to initialize + +**Solution:** Check browser console for JavaScript errors. Verify ServiceControl auth configuration is complete. + +### Redirect loop between ServicePulse and IdP + +**Possible causes:** + +1. Redirect URI mismatch (trailing slash difference) +2. Token validation failing + +**Solution:** Ensure redirect URI in IdP exactly matches `http://localhost:5291/` (with or without trailing slash consistently). + +### "CORS error" in browser console + +**Possible causes:** + +1. Identity provider doesn't allow requests from `http://localhost:5291` +2. ServiceControl doesn't have CORS configured for the ServicePulse origin + +**Solution:** Configure CORS in your identity provider to allow the ServicePulse origin. + +### Token not refreshing + +**Possible causes:** + +1. `offline_access` scope not granted +2. Third-party cookies blocked by browser +3. `silent-renew.html` not accessible + +**Solution:** + +1. Ensure `offline_access` is in the requested scopes +2. Check browser settings for third-party cookie restrictions +3. Verify `http://localhost:5291/silent-renew.html` returns successfully + +### API requests still failing after login + +**Possible causes:** + +1. Token audience doesn't match ServiceControl's expected audience +2. Token scopes don't include required API scopes +3. Token signature validation failing + +**Solution:** Verify identity provider configuration matches ServiceControl's expected values for audience and scopes. + +### 401 errors through YARP reverse proxy + +**Possible causes:** + +1. ServiceControl not configured to accept the token +2. Token audience mismatch between ServicePulse and ServiceControl configuration +3. CORS issues when ServiceControl and ServicePulse are on different origins + +**Solution:** + +1. Verify ServiceControl is configured with matching authentication settings +2. Check that the `audience` in ServiceControl matches the token's audience claim +3. When using reverse proxy (default), requests go to the same origin so CORS should not be an issue + +### Forwarded headers not working + +**Possible causes:** + +1. Forwarded headers middleware not enabled +2. Request not coming from a trusted proxy +3. Headers being stripped by intermediate proxy + +**Solution:** + +1. Ensure `SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true` +2. Configure trusted proxies: `SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES` or `SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true` +3. Check that your reverse proxy is sending `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-For` headers + +### OAuth redirect uses wrong URL behind proxy + +**Possible causes:** + +1. Forwarded headers not being processed +2. Identity provider redirect URI doesn't match the external URL + +**Solution:** + +1. Enable and configure forwarded headers (see above) +2. Update the redirect URI in your identity provider to match the external URL (e.g., `https://servicepulse.example.com/`) + +## Browser Developer Tools Tips + +### Viewing Token Contents + +1. Open Developer Tools > Application tab +2. Expand Session Storage +3. Find the `oidc.user:` key +4. The stored object contains the access token and user info + +### Decoding JWT Tokens + +Copy the access token and paste it into [jwt.io](https://jwt.io) to view: + +- Header (algorithm, key ID) +- Payload (claims, scopes, expiration) +- Signature + +### Monitoring Auth Requests + +1. Open Developer Tools > Network tab +2. Filter by domain of your identity provider +3. Watch for: + - `/authorize` - Initial login redirect + - `/token` - Token exchange + - `/userinfo` - User profile fetch (if enabled) + +## See Also + +- [Authentication](https://docs.particular.net/servicepulse/security/configuration/authentication) - Authentication configuration reference +- [HTTPS Configuration](https://docs.particular.net/servicepulse/security/configuration/tls#configuration) - Secure token transmission with HTTPS +- [Reverse Proxy Testing](nginx-testing.md) - Testing with NGINX reverse proxy diff --git a/docs/forwarded-headers-testing.md b/docs/forwarded-headers-testing.md new file mode 100644 index 0000000000..b593c046b3 --- /dev/null +++ b/docs/forwarded-headers-testing.md @@ -0,0 +1,849 @@ +# Local Testing Forward Headers (Without NGINX) + +This guide explains how to test forward headers configuration for ServicePulse without using NGINX or Docker. This approach uses curl to manually send `X-Forwarded-*` headers directly to the application. + +## Prerequisites + +- ServicePulse built locally (see [main README for instructions](../README.md#setting-up-the-project-for-development)) +- 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` + +### Building ServicePulse.Host (.NET Framework) + +If testing the .NET Framework version, build ServicePulse.Host first: + +```cmd +REM Build the frontend +cd src\Frontend +npm install +npm run build + +REM Copy the frontend build to the host +cd ..\.. +xcopy /E /I /Y src\Frontend\dist src\ServicePulse.Host\app +cd src\ServicePulse.Host +dotnet build +``` + +## Application Reference + +| Application | Project Directory | Default Port | Configuration | +|------------------------------------|-------------------------|--------------|---------------------------------------------------| +| ServicePulse (.NET 8) | `src\ServicePulse` | 5291 | Environment variables with `SERVICEPULSE_` prefix | +| ServicePulse.Host (.NET Framework) | `src\ServicePulse.Host` | 8081 | Command-line arguments with `--` prefix | + +### Configuration Settings + +See [Forward Header Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers) + +## 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` + +## Test Scenarios + +Each scenario shows configuration for both platforms: + +- **ServicePulse (.NET 8)**: Uses environment variables, run from `src\ServicePulse` +- **ServicePulse.Host (.NET Framework)**: Uses command-line arguments, run from `src\ServicePulse.Host\bin\Debug\net48` + +> [!IMPORTANT] +> All commands assume you start in the repository root folder. Each scenario includes `cd` commands to navigate to the correct directory. +> For .NET 8, set environment variables in the same terminal where you run `dotnet run`. Environment variables are scoped to the terminal session. + +--- + +### Default Configuration (No Settings) + +These scenarios use default configuration with no forwarded headers settings specified. + +**Start the application:** + +**.NET 8:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED= +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS= + +cd src\ServicePulse +dotnet run +``` + +**.NET Framework:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 +``` + +#### Scenario 1: Direct Access (No Headers) + +Test a direct request without any forwarded headers, simulating access without a reverse proxy. + +**Test with curl (no forwarded headers):** + +```cmd +curl http://localhost:5291/debug/request-info | json +curl http://localhost:8081/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:5291", // or localhost:8081 for .NET Framework + "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 2: Default Behavior (With Headers) + +Test the default behavior when no forwarded headers configuration is set, but headers are sent. + +**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:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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`. + +--- + +### Trust All Proxies (Explicit) + +These scenarios explicitly enable trust for all proxies. This is the same as default behavior but with explicit configuration. + +**Start the application:** + +**.NET 8:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS= + +cd src\ServicePulse +dotnet run +``` + +**.NET Framework:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersenabled=true --forwardedheaderstrustallproxies=true +``` + +#### Scenario 3: Basic Headers Processing + +**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:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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 4: Proxy Chain (Multiple X-Forwarded-For Values) + +Test how ServicePulse handles multiple proxies in the chain. + +**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:5291/debug/request-info | json +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:8081/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 5: Multiple Proto and Host Values + +Test how ServicePulse handles multiple values in `X-Forwarded-Proto` and `X-Forwarded-Host` headers, simulating requests that passed through multiple proxies with different configurations. + +**Test with curl (simulating multiple proto/host values):** + +```cmd +curl -H "X-Forwarded-Proto: https, http" -H "X-Forwarded-Host: example.com, proxy.internal, lb.internal" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https, http" -H "X-Forwarded-Host: example.com, proxy.internal, lb.internal" -H "X-Forwarded-For: 203.0.113.50, 10.0.0.1, 192.168.1.1" http://localhost:8081/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": [] + } +} +``` + +When multiple values are present, the middleware uses the leftmost (first) value from each header, which represents the original client's values. The `scheme` is `https` (first value from `X-Forwarded-Proto`), `host` is `example.com` (first value from `X-Forwarded-Host`), and `remoteIpAddress` is `203.0.113.50` (first value from `X-Forwarded-For`). + +#### Scenario 6: 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. + +**Test with curl (only X-Forwarded-Proto):** + +```cmd +curl -H "X-Forwarded-Proto: https" http://localhost:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" http://localhost:8081/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "localhost:5291", + "remoteIpAddress": "::1" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": true, + "knownProxies": [], + "knownNetworks": [] + } +} +``` + +Only the `scheme` changed to `https`. The `host` remains `localhost:5291` and `remoteIpAddress` remains `::1` because those headers weren't sent. Each header is processed independently. + +--- + +### Known Proxies Only + +These scenarios configure specific IP addresses as trusted proxies. + +> [!NOTE] +> Setting known proxies automatically disables trust all proxies. Both IPv4 (`127.0.0.1`) and IPv6 (`::1`) loopback addresses are included since curl may use either. + +#### Scenario 7: Trusted Proxy (Localhost) + +Only accept forwarded headers from specific IP addresses. + +**Start the application:** + +**.NET 8:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1,::1 +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS= + +cd src\ServicePulse +dotnet run +``` + +**.NET Framework:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownproxies=127.0.0.1,::1 +``` + +**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:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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 8: Proxy Chain with Known Proxies (ForwardLimit = 1) + +Test how ServicePulse 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. + +**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:5291/debug/request-info | json +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:8081/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 Scenarios 4-5 where `TrustAllProxies = true` returns the original client IP. + +#### Scenario 9: Unknown Proxy Rejected + +Configure a known proxy that doesn't match the request source to verify headers are ignored. + +**Start the application:** + +**.NET 8:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100 +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS= + +cd src\ServicePulse +dotnet run +``` + +**.NET Framework:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownproxies=192.168.1.100 +``` + +**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:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:5291", + "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 10: 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. + +**Start the application:** + +**.NET 8:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=127.0.0.1 +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS= + +cd src\ServicePulse +dotnet run +``` + +**.NET Framework:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownproxies=127.0.0.1 +``` + +> [!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:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/debug/request-info | json +``` + +**Expected output (if curl uses IPv6):** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:5291", + "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`. + +> [!NOTE] +> If your output shows headers were applied, curl is using IPv4. The behavior depends on your system's DNS resolution for `localhost`. + +--- + +### Known Networks (CIDR) + +These scenarios configure trusted network ranges using CIDR notation. + +> [!NOTE] +> Both IPv4 (`127.0.0.0/8`) and IPv6 (`::1/128`) loopback networks are included since curl may use either. + +#### Scenario 11: Trusted Network (Localhost) + +Trust all proxies within a network range. + +**Start the application:** + +**.NET 8:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128 + +cd src\ServicePulse +dotnet run +``` + +**.NET Framework:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownnetworks=127.0.0.0/8,::1/128 +``` + +**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:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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 12: Unknown Network Rejected + +Configure a known network that doesn't match the request source to verify headers are ignored. + +**Start the application:** + +**.NET 8:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=10.0.0.0/8,192.168.0.0/16 + +cd src\ServicePulse +dotnet run +``` + +**.NET Framework:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownnetworks=10.0.0.0/8,192.168.0.0/16 +``` + +**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:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:5291", + "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. + +--- + +### Combined Known Proxies and Networks + +This scenario uses both `KnownProxies` and `KnownNetworks` together. + +#### Scenario 13: Combined Configuration + +**Start the application:** + +**.NET 8:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES=192.168.1.100 +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS=127.0.0.0/8,::1/128 + +cd src\ServicePulse +dotnet run +``` + +**.NET Framework:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersknownproxies=192.168.1.100 --forwardedheadersknownnetworks=127.0.0.0/8,::1/128 +``` + +**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:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/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. + +--- + +### Forwarded Headers Disabled + +This scenario completely disables forwarded headers processing. + +#### Scenario 14: Disabled + +**Start the application:** + +**.NET 8:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=false +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS= + +cd src\ServicePulse +dotnet run +``` + +**.NET Framework:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheadersenabled=false +``` + +**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:5291/debug/request-info | json +curl -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: example.com" -H "X-Forwarded-For: 203.0.113.50" http://localhost:8081/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "http", + "host": "localhost:5291", + "remoteIpAddress": "::1" + }, + "rawHeaders": { + "xForwardedFor": "203.0.113.50", + "xForwardedProto": "https", + "xForwardedHost": "example.com" + }, + "configuration": { + "enabled": false, + "trustAllProxies": true, + "knownProxies": [], + "knownNetworks": [] + } +} +``` + +Headers are ignored because forwarded headers processing is disabled entirely. Notice `enabled` is `false` in the configuration. The `trustAllProxies` value defaults to `true` but is irrelevant when forwarded headers are disabled. + +## Cleanup (.NET 8 only) + +After testing with .NET 8, clear the environment variables: + +**Command Prompt (cmd):** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED= +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES= +set SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS= +``` + +> [!NOTE] +> .NET Framework uses command-line arguments, so no cleanup is needed - just stop the application. + +## See Also + +- [Forwarded Headers Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers#configuration) - Configuration reference for forwarded headers +- [NGINX Testing](nginx-testing.md) - Testing with a real reverse proxy (NGINX) +- [Hosting Options](hosting-options.md) - General hosting configuration guide diff --git a/docs/hosting-options.md b/docs/hosting-options.md new file mode 100644 index 0000000000..8e901bdc18 --- /dev/null +++ b/docs/hosting-options.md @@ -0,0 +1,83 @@ +# ServicePulse Hosting Options + +There are several ways to host ServicePulse: + +## 1. Docker Container (Modern - Recommended) + +Uses the **ServicePulse** project (.NET 8.0 + ASP.NET Core) + +```bash +docker run -p 9090:9090 \ + -e SERVICECONTROL_URL="http://servicecontrol:33333/api/" \ + -e MONITORING_URL="http://servicecontrol-monitoring:33633/" \ + particular/servicepulse:latest +``` + +**Features:** + +- Built-in YARP reverse proxy (proxies `/api/` and `/monitoring-api/` to backends) +- Cross-platform (linux/amd64, linux/arm64) +- Environment variable configuration +- Can disable reverse proxy with `ENABLE_REVERSE_PROXY=false` + +## 2. ServicePulse.Host - Windows Service (Legacy) + +Uses **ServicePulse.Host** project (.NET Framework 4.8 + OWIN) + +```cmd +REM Run interactively +ServicePulse.Host.exe --url="http://localhost:9090" + +REM Install as Windows Service +ServicePulse.Host.exe --install --url="http://localhost:9090" --serviceControlUrl="http://localhost:33333/api" --serviceControlMonitoringUrl="http://localhost:33633" + +REM Uninstall +ServicePulse.Host.exe --uninstall +``` + +**Features:** + +- Self-hosted HTTP server via OWIN +- Runs as Windows Service or console app +- Requires URL ACL reservation (`netsh http add urlacl`) +- No reverse proxy - frontend makes direct CORS calls to ServiceControl + +## 3. IIS Hosting (Extract Mode) + +Extract static files for hosting in IIS or any web server: + +```cmd +ServicePulse.Host.exe --extract --serviceControlUrl="http://localhost:33333/api" --serviceControlMonitoringUrl="http://localhost:33633" --outpath="C:\inetpub\wwwroot\servicepulse" +``` + +This creates a standalone SPA with URLs baked into `app.constants.js`. Requires ServiceControl to have CORS enabled. + +## 4. Windows Installer + +The `Setup` project creates an MSI/EXE installer that: + +- Installs ServicePulse.Host as a Windows Service +- Configures URL ACL automatically +- Default port: 9090 + +## 5. ASP.NET Core in IIS + +The modern **ServicePulse** project can be hosted in IIS via ASP.NET Core Module, providing reverse proxy capabilities. + +## 6. Particular.PlatformSample.ServicePulse + +Not a hosting option - this is a NuGet package that bundles ServicePulse for embedding in the Particular Platform Sample. + +## Key Differences + +| Option | Technology | Reverse Proxy | Platform | +|---------------------|-----------------|---------------|----------------| +| Docker/ASP.NET Core | .NET 8.0 | Yes (YARP) | Cross-platform | +| ServicePulse.Host | .NET 4.8 + OWIN | No | Windows only | +| IIS Extract | Static files | No | Any web server | + +## See Also + +- [HTTPS Configuration](https://docs.particular.net/servicepulse/security/configuration/tls#configuration) - Configure direct HTTPS for either platform +- [Forwarded Headers Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers#configuration) - Configure forwarded headers when behind a reverse proxy +- [HTTPS Testing](https-testing.md) - Test HTTPS configuration locally diff --git a/docs/https-testing.md b/docs/https-testing.md new file mode 100644 index 0000000000..28a1d60388 --- /dev/null +++ b/docs/https-testing.md @@ -0,0 +1,364 @@ +# Local Testing with Direct HTTPS + +This guide provides scenario-based tests for ServicePulse's direct HTTPS features. Use this to verify 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 ServicePulse. When running with direct HTTPS, ServicePulse only binds to a single port (HTTPS). To test HTTP to HTTPS redirection, see [Reverse Proxy Testing](nginx-testing.md). HSTS should not be tested on localhost because browsers cache the HSTS policy, which could break other local development. To test HSTS, use the [NGINX reverse proxy setup](nginx-testing.md) with a custom hostname (`servicepulse.localhost`). + +## Application Reference + +| Application | Project Directory | Default Port | Configuration | +|------------------------------------|-------------------------|--------------|---------------------------------------------------| +| ServicePulse (.NET 8) | `src\ServicePulse` | 5291 | Environment variables with `SERVICEPULSE_` prefix | +| ServicePulse.Host (.NET Framework) | `src\ServicePulse.Host` | 9090 | Command-line arguments with `--` prefix | + +## HTTPS Configuration Reference + +See [ServicePulse TLS](https://docs.particular.net/servicepulse/security/configuration/tls). + +## .NET 8 Prerequisites + +- ServicePulse built locally (see [main README for instructions](../README.md#setting-up-the-project-for-development)) +- ServicePulse built locally (see main README for build instructions) +- curl (included with Windows 10/11) + +### 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 PFX Certificates + +Kestrel used in the .NET 8 host requires certificates in PFX format. Use mkcert to generate them: + +```cmd +mkcert -install +cd .local\certs +mkcert -p12-file localhost.pfx -pkcs12 localhost 127.0.0.1 ::1 servicepulse +``` + +When prompted for a password, you can use an empty password by pressing Enter, or set a password and note it for the configuration step (default is `changeit`). + +## .NET Framework Prerequisites + +ServicePulse.Host (.NET Framework) uses Windows HttpListener which requires additional setup. + +### Build the Frontend and ServicePulse.Host + +ServicePulse.Host embeds the frontend files into the assembly at build time. The easiest way to build everything is to run the full build script from the repository root: + +```powershell +PowerShell -File .\build.ps1 +``` + +Alternatively, build manually: + +```cmd +cd src\Frontend +npm install +npm run build + +cd ..\.. +xcopy /E /I /Y src\Frontend\dist src\ServicePulse.Host\app +cd src\ServicePulse.Host +dotnet build +``` + +> [!NOTE] +> The frontend files must be copied to `src/ServicePulse.Host/app/` *before* building because they are embedded into the assembly at compile time. + +### Import Certificate to Windows Store (Administrator) + +The certificate must be imported into the Windows certificate store: + +```powershell +$password = ConvertTo-SecureString -String "" -Force -AsPlainText +Import-PfxCertificate -FilePath ".local\certs\localhost.pfx" -CertStoreLocation Cert:\LocalMachine\My -Password $password +``` + +### Get Certificate Thumbprint (Administrator) + +```powershell +(Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*localhost*" }).Thumbprint +``` + +Copy this thumbprint for the next step. + +### Bind Certificate to Port (Administrator) + +Run these commands in an **elevated (Administrator) command prompt**: + +```cmd +netsh http add sslcert ipport=0.0.0.0:9090 certhash=YOUR_THUMBPRINT appid={00000000-0000-0000-0000-000000000000} +netsh http add urlacl url=https://+:9090/ user=Everyone +``` + +Replace `YOUR_THUMBPRINT` with the thumbprint from the previous step. + +## Test Scenarios + +--- + +### HTTPS Enabled (.NET 8) + +> [!NOTE] +> Complete the [.NET 8 Prerequisites](#net-8-prerequisites) section first. + +**Start the application:** + +```cmd +set SERVICEPULSE_HTTPS_ENABLED=true +set SERVICEPULSE_HTTPS_CERTIFICATEPATH=C:\Users\warwi\source\repos\Particular\ServicePulse\.local\certs\localhost.pfx +set SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD=changeit +set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICEPULSE_HTTPS_PORT= +set SERVICEPULSE_HTTPS_ENABLEHSTS= + +cd src\ServicePulse +dotnet run +``` + +#### Scenario 1: Basic HTTPS Connectivity + +Verify that HTTPS is working with a valid certificate. + +**Test with curl:** + +```cmd +curl --ssl-no-revoke -v https://localhost:5291 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: renegotiating SSL/TLS connection +* schannel: SSL/TLS connection renegotiated +< HTTP/1.1 200 OK +``` + +The request succeeds over HTTPS. The exact SSL output varies by curl version, but you should see `HTTP/1.1 200 OK` confirming success. + +#### Scenario 2: HTTP Disabled + +Verify that HTTP requests fail when only HTTPS is enabled. + +**Test with curl (HTTP):** + +```cmd +curl http://localhost:5291 +``` + +**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 Enabled (.NET Framework) + +> [!NOTE] +> Complete the [.NET Framework Prerequisites](#net-framework-prerequisites) section first. + +**Start the application:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=https://localhost:9090 --httpsenabled=true +``` + +#### Scenario 3: Basic HTTPS Connectivity + +Verify that HTTPS is working with ServicePulse.Host. + +**Test with curl:** + +```cmd +curl --ssl-no-revoke -v https://localhost:9090 2>&1 | findstr /C:"HTTP/" /C:"SSL" +``` + +**Expected output:** + +```text +* schannel: renegotiating SSL/TLS connection +* schannel: SSL/TLS connection renegotiated +< HTTP/1.1 200 OK +``` + +The request succeeds over HTTPS. You should see `HTTP/1.1 200 OK` confirming success. + +#### Scenario 4: HTTP Disabled + +Verify that HTTP requests fail when HTTPS is configured. + +**Test with curl (HTTP):** + +```cmd +curl http://localhost:9090 +``` + +**Expected output (one of):** + +```text +curl: (52) Empty reply from server +curl: (56) Recv failure: Connection was reset +``` + +HTTP requests fail because HttpListener is configured for HTTPS only. The exact error varies depending on timing. + +## Cleanup + +### Clear Environment Variables (.NET 8) + +After testing, clear the environment variables: + +**Command Prompt (cmd):** + +```cmd +set SERVICEPULSE_HTTPS_ENABLED= +set SERVICEPULSE_HTTPS_CERTIFICATEPATH= +set SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD= +set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICEPULSE_HTTPS_PORT= +set SERVICEPULSE_HTTPS_ENABLEHSTS= +``` + +### Cleanup (.NET Framework) + +To remove the SSL binding and URL ACL: + +```cmd +netsh http delete sslcert ipport=0.0.0.0:9090 +netsh http delete urlacl url=https://+:9090/ +``` + +To remove the certificate from the store: + +```powershell +Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*localhost*" } | Remove-Item +``` + +## Troubleshooting + +### Certificate not found + +Ensure the `SERVICEPULSE_HTTPS_CERTIFICATEPATH` is an absolute path and the file exists. + +### Certificate password incorrect + +If you set a password when generating the PFX, ensure it matches the configured password. + +### Certificate errors in browser + +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:5291 +``` + +### Port already in use + +Ensure no other process is using the ServicePulse port (default 5291 for .NET 8, 9090 for .NET Framework). + +### HttpListener Access Denied (.NET Framework) + +Ensure you've created the URL ACL reservation: + +```cmd +netsh http add urlacl url=https://+:9090/ user=Everyone +``` + +### SSL Binding Failed (.NET Framework) + +Ensure: + +1. The certificate is imported to `Cert:\LocalMachine\My` +2. The thumbprint is correct (no spaces) +3. You're running as Administrator + +### No response from ServicePulse.Host HTTPS (.NET Framework) + +If `curl --ssl-no-revoke https://localhost:9090` hangs with no response: + +1. **Verify the application started successfully:** + - Check the console output for any errors when starting ServicePulse.Host.exe + - The application should display a message indicating it's listening + +2. **Verify the SSL certificate binding:** + + ```cmd + netsh http show sslcert ipport=0.0.0.0:9090 + ``` + + The output should show the certificate hash and application ID. If not found, re-add the binding. + +3. **Verify the URL ACL:** + + ```cmd + netsh http show urlacl url=https://+:9090/ + ``` + + The output should show the reserved URL. If not found, re-add the URL ACL. + +4. **Check for thumbprint issues:** + - Thumbprints must have no spaces + - The PowerShell command in the prerequisites outputs the thumbprint without spaces + - Example: `A1B2C3D4E5F6...` (not `A1 B2 C3 D4 E5 F6...`) + +5. **Verify the certificate is valid:** + + ```powershell + Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*localhost*" } | Select-Object Subject, Thumbprint, NotBefore, NotAfter + ``` + +6. **Try removing and re-adding the SSL binding:** + + ```cmd + netsh http delete sslcert ipport=0.0.0.0:9090 + netsh http add sslcert ipport=0.0.0.0:9090 certhash=YOUR_THUMBPRINT appid={00000000-0000-0000-0000-000000000000} + ``` + +7. **Check Windows Event Viewer:** + - Open Event Viewer (`eventvwr.msc`) + - Navigate to Windows Logs > Application + - Look for errors related to HttpListener or SSL + +## See Also + +- [HTTPS Configuration](https://docs.particular.net/servicepulse/security/configuration/tls#configuration) - Configuration reference for all HTTPS settings +- [Forwarded Headers Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers#configuration) - Configure forwarded headers when behind a reverse proxy +- [Forwarded Headers Testing](forwarded-headers-testing.md) - Testing forwarded headers without a reverse proxy +- [Reverse Proxy Testing](nginx-testing.md) - Testing with NGINX reverse proxy diff --git a/docs/nginx-testing.md b/docs/nginx-testing.md new file mode 100644 index 0000000000..9d34419f85 --- /dev/null +++ b/docs/nginx-testing.md @@ -0,0 +1,608 @@ +# Local Testing with NGINX Reverse Proxy + +This guide provides scenario-based tests for ServicePulse 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) + +## Application Reference + +| Application | Project Directory | Default Port | Hostname | Configuration | +|------------------------------------|-------------------------|--------------|-------------------------------|---------------------------------------------------| +| ServicePulse (.NET 8) | `src\ServicePulse` | 5291 | `servicepulse.localhost` | Environment variables with `SERVICEPULSE_` prefix | +| ServicePulse.Host (.NET Framework) | `src\ServicePulse.Host` | 8081 | `servicepulse-host.localhost` | Command-line arguments with `--` prefix | + +## Configuration Reference + +See [ServicePulse Security](https://docs.particular.net/servicepulse/security). + +## Prerequisites + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running +- [mkcert](https://github.com/FiloSottile/mkcert) for generating local development certificates +- ServicePulse built locally (see [main README for instructions](../README.md#setting-up-the-project-for-development)) +- curl (included with Windows 10/11) + +### 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 servicepulse.pem -key-file servicepulse-key.pem servicepulse.localhost servicepulse-host.localhost localhost +``` + +### Step 3: Create Docker Compose Configuration + +Create `.local/compose.yml`: + +```yaml +services: + reverse-proxy-servicepulse: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./certs/servicepulse.pem:/etc/nginx/certs/servicepulse.pem:ro + - ./certs/servicepulse-key.pem:/etc/nginx/certs/servicepulse-key.pem:ro +``` + +### Step 4: Create NGINX Configuration + +Create `.local/nginx.conf`: + +```nginx +events { worker_connections 1024; } + +http { + # Shared SSL Settings + ssl_certificate /etc/nginx/certs/servicepulse.pem; + ssl_certificate_key /etc/nginx/certs/servicepulse-key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # ServicePulse (.NET 8) - HTTPS + server { + listen 443 ssl; + server_name servicepulse.localhost; + + location / { + proxy_pass http://host.docker.internal:5291; + + # 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; + } + } + + # ServicePulse (.NET 8) - HTTP (for testing HTTP-to-HTTPS redirect) + server { + listen 80; + server_name servicepulse.localhost; + + location / { + proxy_pass http://host.docker.internal:5291; + + # 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; + } + } + + # ServicePulse.Host (.NET Framework) - HTTPS + server { + listen 443 ssl; + server_name servicepulse-host.localhost; + + location / { + proxy_pass http://host.docker.internal:8081; + + # 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; + } + } + + # ServicePulse.Host (.NET Framework) - HTTP (for testing HTTP-to-HTTPS redirect) + server { + listen 80; + server_name servicepulse-host.localhost; + + location / { + proxy_pass http://host.docker.internal:8081; + + # 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 servicepulse.localhost +127.0.0.1 servicepulse-host.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/ + ├── servicepulse.pem + └── servicepulse-key.pem +``` + +## .NET Framework Prerequisites + +ServicePulse.Host (.NET Framework) requires a URL ACL reservation. + +### Build the Frontend and ServicePulse.Host + +ServicePulse.Host embeds the frontend files into the assembly at build time. The easiest way to build everything is to run the full build script from the repository root: + +```powershell +PowerShell -File .\build.ps1 +``` + +Alternatively, build manually: + +```cmd +cd src\Frontend +npm install +npm run build + +cd ..\.. +xcopy /E /I /Y src\Frontend\dist src\ServicePulse.Host\app +cd src\ServicePulse.Host +dotnet build +``` + +### Create URL ACL Reservation (Administrator) + +Run in an **elevated (Administrator) command prompt**: + +```cmd +netsh http add urlacl url=http://+:8081/ user=Everyone +``` + +## Test Scenarios + +> [!IMPORTANT] +> ServicePulse must be running before testing. A 502 Bad Gateway error means NGINX cannot reach ServicePulse. 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 (.NET 8) + +Verify that HTTPS is working through the reverse proxy. + +**Clear environment variables and start ServicePulse:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED= +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICEPULSE_HTTPS_PORT= +set SERVICEPULSE_HTTPS_ENABLEHSTS= + +cd src\ServicePulse +dotnet run +``` + +**Test with curl:** + +```cmd +curl -k -v https://servicepulse.localhost 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: HTTPS Access (.NET Framework) + +Verify that HTTPS is working through the reverse proxy with ServicePulse.Host. + +> [!IMPORTANT] +> Complete the [.NET Framework Prerequisites](#net-framework-prerequisites) section first. + +**Start ServicePulse.Host:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 +``` + +**Test with curl:** + +```cmd +curl -k -v https://servicepulse-host.localhost 2>&1 | findstr /C:"HTTP/" +``` + +**Expected output:** + +```text +< HTTP/1.1 200 OK +``` + +The request succeeds over HTTPS through the NGINX reverse proxy. + +### Scenario 3: Forwarded Headers Processing (.NET 8) + +Verify that forwarded headers are being processed correctly. + +**Set environment variables and start ServicePulse:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICEPULSE_HTTPS_PORT= +set SERVICEPULSE_HTTPS_ENABLEHSTS= + +cd src\ServicePulse +dotnet run +``` + +**Test with curl:** + +```cmd +curl -k https://servicepulse.localhost/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "servicepulse.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 `servicepulse.localhost` (from `X-Forwarded-Host`) +- `rawHeaders` are empty because the middleware consumed them (trusted proxy) + +### Scenario 4: Forwarded Headers Processing (.NET Framework) + +Verify that forwarded headers are being processed correctly with ServicePulse.Host. + +**Start ServicePulse.Host with forwarded headers enabled:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheaderstrustallproxies=true +``` + +**Test with curl:** + +```cmd +curl -k https://servicepulse-host.localhost/debug/request-info | json +``` + +**Expected output:** + +```json +{ + "processed": { + "scheme": "https", + "host": "servicepulse-host.localhost", + "remoteIpAddress": "172.x.x.x" + }, + "rawHeaders": { + "xForwardedFor": "", + "xForwardedProto": "", + "xForwardedHost": "" + }, + "configuration": { + "enabled": true, + "trustAllProxies": true, + "knownProxies": [], + "knownNetworks": [] + } +} +``` + +### Scenario 5: HTTP to HTTPS Redirect (.NET 8) + +Verify that HTTP requests are redirected to HTTPS. + +**Set environment variables and start ServicePulse:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS=true +set SERVICEPULSE_HTTPS_PORT=443 +set SERVICEPULSE_HTTPS_ENABLEHSTS= + +cd src\ServicePulse +dotnet run +``` + +**Test with curl:** + +```cmd +curl -v http://servicepulse.localhost 2>&1 | findstr /i location +``` + +**Expected output:** + +```text +< Location: https://servicepulse.localhost/ +``` + +HTTP requests are redirected to HTTPS with a 307 (Temporary Redirect) status. + +### Scenario 6: HTTP to HTTPS Redirect (.NET Framework) + +Verify that HTTP requests are redirected to HTTPS with ServicePulse.Host. + +**Start ServicePulse.Host with redirect enabled:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheaderstrustallproxies=true --httpsredirecthttptohttps=true --httpsport=443 +``` + +**Test with curl:** + +```cmd +curl -v http://servicepulse-host.localhost 2>&1 | findstr /i location +``` + +**Expected output:** + +```text +< Location: https://servicepulse-host.localhost/ +``` + +HTTP requests are redirected to HTTPS with a 307 (Temporary Redirect) status. + +### Scenario 7: HSTS (.NET 8) + +Verify that the HSTS header is included in HTTPS responses. + +> [!NOTE] +> You must use `--environment Production` because HSTS is disabled in Development. + +**Set environment variables and start ServicePulse:** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED=true +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES=true +set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICEPULSE_HTTPS_PORT= +set SERVICEPULSE_HTTPS_ENABLEHSTS=true + +cd src\ServicePulse +dotnet run --environment Production +``` + +**Test with curl:** + +```cmd +curl -k -v https://servicepulse.localhost 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. + +### Scenario 8: HSTS (.NET Framework) + +Verify that the HSTS header is included in HTTPS responses with ServicePulse.Host. + +**Start ServicePulse.Host with HSTS enabled:** + +```cmd +cd src\ServicePulse.Host\bin\Debug\net48 +ServicePulse.Host.exe --url=http://localhost:8081 --forwardedheaderstrustallproxies=true --httpsenablehsts=true +``` + +**Test with curl:** + +```cmd +curl -k -v https://servicepulse-host.localhost 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. + +## Cleanup + +### Stop NGINX + +```cmd +docker compose -f .local/compose.yml down +``` + +### Clear Environment Variables (.NET 8) + +After testing, clear the environment variables: + +**Command Prompt (cmd):** + +```cmd +set SERVICEPULSE_FORWARDEDHEADERS_ENABLED= +set SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES= +set SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS= +set SERVICEPULSE_HTTPS_PORT= +set SERVICEPULSE_HTTPS_ENABLEHSTS= +``` + +**PowerShell:** + +```powershell +$env:SERVICEPULSE_FORWARDEDHEADERS_ENABLED = $null +$env:SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES = $null +$env:SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS = $null +$env:SERVICEPULSE_HTTPS_PORT = $null +$env:SERVICEPULSE_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 servicepulse.localhost +127.0.0.1 servicepulse-host.localhost +``` + +## Troubleshooting + +### 502 Bad Gateway + +This error means NGINX cannot reach ServicePulse. Check: + +1. ServicePulse is running (`dotnet run` in `src/ServicePulse`) +2. ServicePulse is accessible directly: `curl http://localhost:5291` +3. Docker Desktop is running and `host.docker.internal` resolves correctly + +### "Connection refused" errors + +Ensure ServicePulse is running and listening on the expected port (5291 for .NET 8, 8081 for .NET Framework). + +### Conflicting environment variables from direct HTTPS testing + +If you previously tested [direct HTTPS](https-testing.md), you may have environment variables set that conflict with reverse proxy testing. Clear them before running: + +**Command Prompt (cmd):** + +```cmd +set SERVICEPULSE_HTTPS_ENABLED= +set SERVICEPULSE_HTTPS_CERTIFICATEPATH= +set SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD= +``` + +**PowerShell:** + +```powershell +$env:SERVICEPULSE_HTTPS_ENABLED = $null +$env:SERVICEPULSE_HTTPS_CERTIFICATEPATH = $null +$env:SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD = $null +``` + +For reverse proxy testing, ServicePulse should run on HTTP (not HTTPS) since NGINX handles SSL termination. + +### Headers not being applied + +1. Verify `SERVICEPULSE_FORWARDEDHEADERS_ENABLED` is `true` +2. Verify `SERVICEPULSE_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 ServicePulse port is not blocked by Windows Firewall + +### Debug endpoint not available + +The `/debug/request-info` endpoint is only available: + +- ServicePulse (.NET 8): When running in Development environment +- ServicePulse.Host (.NET Framework): In DEBUG builds when running interactively + +## See Also + +- [HTTPS Configuration](https://docs.particular.net/servicepulse/security/configuration/tls#configuration) - Configuration reference for all HTTPS settings +- [Forwarded Headers Configuration](https://docs.particular.net/servicepulse/security/configuration/forward-headers#configuration) - Configuration reference for all forwarded headers settings +- [HTTPS Testing](https-testing.md) - Testing direct HTTPS without a reverse proxy +- [Forwarded Headers Testing](forwarded-headers-testing.md) - Testing forwarded headers without a reverse proxy +- [Authentication Testing](authentication-testing.md) - Testing OIDC authentication diff --git a/src/Frontend/eslint-rules/no-raw-fetch.ts b/src/Frontend/eslint-rules/no-raw-fetch.ts new file mode 100644 index 0000000000..42d16024fa --- /dev/null +++ b/src/Frontend/eslint-rules/no-raw-fetch.ts @@ -0,0 +1,48 @@ +import type { Rule } from "eslint"; + +/** + * ESLint rule to disallow raw fetch() calls. + * Use authFetch from useAuthenticatedFetch.ts instead. + */ +const rule: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: "Disallow raw fetch() calls. Use authFetch from useAuthenticatedFetch.ts instead.", + }, + messages: { + noRawFetch: "Do not use raw fetch(). Use authFetch from '@/composables/useAuthenticatedFetch' instead.", + }, + schema: [], + }, + create(context) { + const sourceCode = context.sourceCode; + + return { + CallExpression(node) { + // Check if it's a call to `fetch` + if (node.callee.type === "Identifier" && node.callee.name === "fetch") { + // Check if this file is the useAuthenticatedFetch composable itself + const filename = context.filename; + if (filename.includes("useAuthenticatedFetch")) { + return; // Allow fetch in the wrapper file itself + } + + // Check if `fetch` is a local variable/parameter (not the global) + const scope = sourceCode.getScope(node); + const variable = scope.references.find((ref) => ref.identifier === node.callee); + if (variable && variable.resolved && variable.resolved.defs.length > 0) { + return; // It's a local variable, not the global fetch + } + + context.report({ + node, + messageId: "noRawFetch", + }); + } + }, + }; + }, +}; + +export default rule; diff --git a/src/Frontend/eslint.config.mjs b/src/Frontend/eslint.config.mjs index e5da58a06e..272d66996a 100644 --- a/src/Frontend/eslint.config.mjs +++ b/src/Frontend/eslint.config.mjs @@ -4,6 +4,13 @@ import tseslint from "typescript-eslint"; import pluginVue from "eslint-plugin-vue"; import pluginPromise from "eslint-plugin-promise"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import noRawFetch from "./eslint-rules/no-raw-fetch.ts"; + +const localPlugin = { + rules: { + "no-raw-fetch": noRawFetch, + }, +}; export default tseslint.config( { @@ -12,6 +19,9 @@ export default tseslint.config( { files: ["**/*.{js,mjs,ts,vue}"], languageOptions: { globals: globals.browser, ecmaVersion: "latest", parserOptions: { parser: tseslint.parser } }, + plugins: { + local: localPlugin, + }, extends: [pluginJs.configs.recommended, ...tseslint.configs.recommended, ...pluginVue.configs["flat/essential"], pluginPromise.configs["flat/recommended"], eslintPluginPrettierRecommended], rules: { "no-duplicate-imports": "error", @@ -24,6 +34,7 @@ export default tseslint.config( "prefer-const": "error", eqeqeq: ["error", "smart"], "no-throw-literal": "warn", + "local/no-raw-fetch": "error", }, } ); diff --git a/src/Frontend/package-lock.json b/src/Frontend/package-lock.json index beaeab5f9d..37e69bddbe 100644 --- a/src/Frontend/package-lock.json +++ b/src/Frontend/package-lock.json @@ -28,6 +28,7 @@ "diff": "8.0.3", "hex-to-css-filter": "6.0.0", "lossless-json": "4.3.0", + "oidc-client-ts": "3.4.1", "pinia": "3.0.4", "vue": "3.5.26", "vue-codemirror6": "1.4.1", @@ -3492,9 +3493,9 @@ } }, "node_modules/alien-signals": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.0.tgz", - "integrity": "sha512-yufC6VpSy8tK3I0lO67pjumo5JvDQVQyr38+3OHqe6CHl1t2VZekKZ7EKKZSqk0cRmE7U7tfZbpXiKNzuc+ckg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", "dev": true, "license": "MIT" }, @@ -4942,24 +4943,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6068,6 +6051,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6509,6 +6501,18 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -7503,6 +7507,24 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -8045,6 +8067,24 @@ "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.12.tgz", diff --git a/src/Frontend/package.json b/src/Frontend/package.json index 4cadeb7fef..58e2735ffb 100644 --- a/src/Frontend/package.json +++ b/src/Frontend/package.json @@ -40,6 +40,7 @@ "diff": "8.0.3", "hex-to-css-filter": "6.0.0", "lossless-json": "4.3.0", + "oidc-client-ts": "3.4.1", "pinia": "3.0.4", "vue": "3.5.26", "vue-codemirror6": "1.4.1", diff --git a/src/Frontend/public/silent-renew.html b/src/Frontend/public/silent-renew.html new file mode 100644 index 0000000000..5cc4e681a0 --- /dev/null +++ b/src/Frontend/public/silent-renew.html @@ -0,0 +1,20 @@ + + + + + Silent Renew + + + + + diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue index 1a69d6bc41..de55293cb5 100644 --- a/src/Frontend/src/App.vue +++ b/src/Frontend/src/App.vue @@ -1,18 +1,34 @@ diff --git a/src/Frontend/src/AuthApp.vue b/src/Frontend/src/AuthApp.vue new file mode 100644 index 0000000000..e28c8544fd --- /dev/null +++ b/src/Frontend/src/AuthApp.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/Frontend/src/assets/navbar.css b/src/Frontend/src/assets/navbar.css index 4f44cf7d0b..ee78aab009 100644 --- a/src/Frontend/src/assets/navbar.css +++ b/src/Frontend/src/assets/navbar.css @@ -6,6 +6,8 @@ .navbar { height: 60px; flex-wrap: nowrap; + position: relative; + z-index: 1000; } .navbar-inverse { diff --git a/src/Frontend/src/components/PageHeader.vue b/src/Frontend/src/components/PageHeader.vue index e059894b22..093cff518f 100644 --- a/src/Frontend/src/components/PageHeader.vue +++ b/src/Frontend/src/components/PageHeader.vue @@ -13,8 +13,15 @@ import FeedbackButton from "@/components/FeedbackButton.vue"; import ThroughputMenuItem from "@/views/throughputreport/ThroughputMenuItem.vue"; import AuditMenuItem from "./audit/AuditMenuItem.vue"; import monitoringClient from "@/components/monitoring/monitoringClient"; +import UserProfileMenuItem from "@/components/UserProfileMenuItem.vue"; +import { useAuthStore } from "@/stores/AuthStore"; +import { storeToRefs } from "pinia"; const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; + +const authStore = useAuthStore(); +const { authEnabled, isAuthenticated } = storeToRefs(authStore); + // prettier-ignore const menuItems = computed( () => [ @@ -45,6 +52,9 @@ const menuItems = computed(
  • +
  • + +
  • diff --git a/src/Frontend/src/components/UserProfileMenuItem.vue b/src/Frontend/src/components/UserProfileMenuItem.vue new file mode 100644 index 0000000000..c513fb9680 --- /dev/null +++ b/src/Frontend/src/components/UserProfileMenuItem.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/Frontend/src/components/configuration/PlatformConnections.vue b/src/Frontend/src/components/configuration/PlatformConnections.vue index 6f1ad46fe5..2f97ee54ea 100644 --- a/src/Frontend/src/components/configuration/PlatformConnections.vue +++ b/src/Frontend/src/components/configuration/PlatformConnections.vue @@ -5,6 +5,7 @@ import FAIcon from "@/components/FAIcon.vue"; import useConnectionsAndStatsAutoRefresh from "@/composables/useConnectionsAndStatsAutoRefresh"; import serviceControlClient from "@/components/serviceControlClient"; import monitoringClient from "../monitoring/monitoringClient"; +import { authFetch } from "@/composables/useAuthenticatedFetch"; const { store: connectionStore } = useConnectionsAndStatsAutoRefresh(); const connectionState = connectionStore.connectionState; @@ -24,7 +25,7 @@ async function testServiceControlUrl() { if (localServiceControlUrl.value) { testingServiceControl.value = true; try { - const response = await fetch(localServiceControlUrl.value); + const response = await authFetch(localServiceControlUrl.value); serviceControlValid.value = response.ok && response.headers.has("X-Particular-Version"); } catch { serviceControlValid.value = false; @@ -43,7 +44,7 @@ async function testMonitoringUrl() { } try { - const response = await fetch(localMonitoringUrl.value + "monitored-endpoints"); + const response = await authFetch(localMonitoringUrl.value + "monitored-endpoints"); monitoringValid.value = response.ok && response.headers.has("X-Particular-Version"); } catch { monitoringValid.value = false; diff --git a/src/Frontend/src/components/monitoring/monitoringClient.ts b/src/Frontend/src/components/monitoring/monitoringClient.ts index 7b94d105d1..45574ff28c 100644 --- a/src/Frontend/src/components/monitoring/monitoringClient.ts +++ b/src/Frontend/src/components/monitoring/monitoringClient.ts @@ -1,3 +1,4 @@ +import { authFetch } from "@/composables/useAuthenticatedFetch"; import { Endpoint, EndpointDetails } from "@/resources/MonitoringEndpoint"; export interface MetricsConnectionDetails { @@ -56,7 +57,7 @@ class MonitoringClient { if (this.isMonitoringDisabled) { return; } - await fetch(`${this.url}monitored-instance/${endpointName}/${instanceId}`, { + await authFetch(`${this.url}monitored-instance/${endpointName}/${instanceId}`, { method: "DELETE", }); } @@ -66,7 +67,7 @@ class MonitoringClient { return false; } - const response = await fetch(`${this.url}`, { + const response = await authFetch(`${this.url}`, { method: "OPTIONS", }); @@ -94,7 +95,7 @@ class MonitoringClient { return []; } - const response = await fetch(`${this.url}${suffix}`); + const response = await authFetch(`${this.url}${suffix}`); const data = await response.json(); return [response, data]; diff --git a/src/Frontend/src/components/serviceControlClient.ts b/src/Frontend/src/components/serviceControlClient.ts index 3e0ec2c183..a5d025b9dd 100644 --- a/src/Frontend/src/components/serviceControlClient.ts +++ b/src/Frontend/src/components/serviceControlClient.ts @@ -1,3 +1,5 @@ +import { authFetch } from "@/composables/useAuthenticatedFetch"; + export interface ServiceControlInstanceConnection { settings: { [key: string]: object }; errors: string[]; @@ -27,7 +29,7 @@ class ServiceControlClient { } public async fetchTypedFromServiceControl(suffix: string, signal?: AbortSignal): Promise<[Response, T]> { - const response = await fetch(`${this.url}${suffix}`, { signal }); + const response = await authFetch(`${this.url}${suffix}`, { signal }); if (!response.ok) throw new Error(response.statusText ?? "No response"); const data = await response.json(); @@ -42,7 +44,7 @@ class ServiceControlClient { requestOptions.headers = { "Content-Type": "application/json" }; requestOptions.body = JSON.stringify(payload); } - return await fetch(`${this.url}${suffix}`, requestOptions); + return await authFetch(`${this.url}${suffix}`, requestOptions); } public async putToServiceControl(suffix: string, payload: object | null) { @@ -53,14 +55,14 @@ class ServiceControlClient { requestOptions.headers = { "Content-Type": "application/json" }; requestOptions.body = JSON.stringify(payload); } - return await fetch(`${this.url}${suffix}`, requestOptions); + return await authFetch(`${this.url}${suffix}`, requestOptions); } public async deleteFromServiceControl(suffix: string) { const requestOptions: RequestInit = { method: "DELETE", }; - return await fetch(`${this.url}${suffix}`, requestOptions); + return await authFetch(`${this.url}${suffix}`, requestOptions); } public async patchToServiceControl(suffix: string, payload: object | null) { @@ -71,7 +73,7 @@ class ServiceControlClient { requestOptions.headers = { "Content-Type": "application/json" }; requestOptions.body = JSON.stringify(payload); } - return await fetch(`${this.url}${suffix}`, requestOptions); + return await authFetch(`${this.url}${suffix}`, requestOptions); } public async fetchFromServiceControl(suffix: string, options?: { cache?: RequestCache }) { @@ -82,7 +84,7 @@ class ServiceControlClient { Accept: "application/json", }, }; - return await fetch(`${this.url}${suffix}`, requestOptions); + return await authFetch(`${this.url}${suffix}`, requestOptions); } public async getErrorMessagesCount(status: string) { diff --git a/src/Frontend/src/composables/autoRefresh.ts b/src/Frontend/src/composables/autoRefresh.ts index 92b6c06f73..23150f3323 100644 --- a/src/Frontend/src/composables/autoRefresh.ts +++ b/src/Frontend/src/composables/autoRefresh.ts @@ -1,7 +1,7 @@ import { watch, ref, shallowReadonly, type WatchStopHandle } from "vue"; import { useCounter, useDocumentVisibility, useTimeoutPoll } from "@vueuse/core"; -export default function useFetchWithAutoRefresh(name: string, fetch: () => Promise, intervalMs: number) { +export default function useFetchWithAutoRefresh(name: string, fetchFn: () => Promise, intervalMs: number) { let watchStop: WatchStopHandle | null = null; const { count, inc, dec, reset } = useCounter(0); const interval = ref(intervalMs); @@ -11,7 +11,7 @@ export default function useFetchWithAutoRefresh(name: string, fetch: () => Promi return; } isRefreshing.value = true; - await fetch(); + await fetchFn(); isRefreshing.value = false; }; const { isActive, pause, resume } = useTimeoutPoll( diff --git a/src/Frontend/src/composables/useAuth.ts b/src/Frontend/src/composables/useAuth.ts new file mode 100644 index 0000000000..137d70d73b --- /dev/null +++ b/src/Frontend/src/composables/useAuth.ts @@ -0,0 +1,162 @@ +import { useAuthStore } from "@/stores/AuthStore"; +import type { AuthConfig } from "@/types/auth"; +import { UserManager, type User } from "oidc-client-ts"; + +let userManager: UserManager | null = null; + +/** + * Authentication composable using 'oidc-client-ts' package + * Supports any OIDC-compliant identity provider (Entra ID, Auth0, Okta, etc.) + */ +export function useAuth() { + const authStore = useAuthStore(); + + function initializeUserManager(config: AuthConfig): UserManager { + if (!userManager) { + userManager = new UserManager(config); + + // Set up event handlers + userManager.events.addUserLoaded((user: User) => { + console.debug("User loaded:", user.profile); + authStore.setToken(user.access_token); + }); + + userManager.events.addUserUnloaded(() => { + console.debug("User unloaded"); + authStore.clearToken(); + }); + + userManager.events.addAccessTokenExpiring(async () => { + console.debug("Access token expiring, attempting silent renewal..."); + try { + await userManager?.signinSilent(); + } catch (error) { + console.error("Silent token renewal failed:", error); + } + }); + + userManager.events.addAccessTokenExpired(() => { + console.debug("Access token expired"); + authStore.clearToken(); + }); + + userManager.events.addSilentRenewError((error) => { + console.error("Silent renew error:", error); + }); + } + + return userManager; + } + + /** + * Gets the current user from the UserManager + */ + async function getUser(): Promise { + if (!userManager) { + return null; + } + return await userManager.getUser(); + } + + /** + * Attempts to authenticate the user + * This checks for existing authentication or handles the callback from the identity provider + */ + async function authenticate(config: AuthConfig): Promise { + const manager = initializeUserManager(config); + + try { + // Check if we're returning from the identity provider (callback) + // Look for specific OAuth parameters in the URL + const params = new URLSearchParams(window.location.search); + const hasCode = params.has("code"); + const hasState = params.has("state"); + const hasError = params.has("error"); + + if (hasCode && hasState) { + // This is an OAuth callback with authorization code + console.debug("Processing OAuth callback..."); + authStore.setAuthenticating(true); + try { + const user = await manager.signinCallback(); + console.debug("Signin callback successful"); + if (user) { + authStore.setToken(user.access_token); + // Clean up URL by removing OAuth parameters + window.history.replaceState({}, document.title, window.location.pathname); + return true; + } + } catch (error) { + console.error("Signin callback error details:", { + error, + errorMessage: error instanceof Error ? error.message : "Unknown error", + errorStack: error instanceof Error ? error.stack : undefined, + }); + authStore.setAuthError(error instanceof Error ? error.message : "Callback failed"); + // Don't continue - callback failed, user needs to try again + return false; + } finally { + authStore.setAuthenticating(false); + } + } else if (hasError) { + // OAuth error in callback + const errorDescription = params.get("error_description") || params.get("error"); + console.error("OAuth error:", errorDescription); + authStore.setAuthError(errorDescription || "Authentication failed"); + return false; + } + + // Check for existing valid user session + const user = await manager.getUser(); + if (user && !user.expired) { + console.debug("Existing user session found", user.profile); + authStore.setToken(user.access_token); + return true; + } + + // No valid session, initiate login + authStore.setAuthenticating(true); + await manager.signinRedirect(); + return false; // Will redirect, so this won't actually return + } catch (error) { + authStore.setAuthenticating(false); + const errorMessage = error instanceof Error ? error.message : "Unknown authentication error"; + authStore.setAuthError(errorMessage); + console.error("Authentication error:", error); + throw error; + } + } + + /** + * Logs out the user and optionally redirects to the identity provider's logout endpoint + */ + async function logout(redirectToIdp: boolean = true): Promise { + if (!userManager) { + authStore.clearToken(); + return; + } + + try { + if (redirectToIdp) { + // Sign out and redirect to the identity provider + await userManager.signoutRedirect(); + } else { + // Remove local session only + await userManager.removeUser(); + authStore.clearToken(); + } + } catch (error) { + console.error("Logout error:", error); + authStore.clearToken(); + } + } + + return { + authenticate, + logout, + getUser, + isAuthenticated: authStore.isAuthenticated, + isAuthenticating: authStore.isAuthenticating, + authError: authStore.authError, + }; +} diff --git a/src/Frontend/src/composables/useAuthenticatedFetch.ts b/src/Frontend/src/composables/useAuthenticatedFetch.ts new file mode 100644 index 0000000000..2fa2d0cfa7 --- /dev/null +++ b/src/Frontend/src/composables/useAuthenticatedFetch.ts @@ -0,0 +1,38 @@ +import { useAuthStore } from "@/stores/AuthStore"; + +const UNAUTHENTICATED_ENDPOINTS = ["/api/authentication/configuration"]; + +function isUnauthenticatedEndpoint(url: string): boolean { + return UNAUTHENTICATED_ENDPOINTS.some((endpoint) => url.includes(endpoint)); +} + +/** + * Authenticated fetch wrapper that automatically includes JWT token + * in the Authorization header when authentication is enabled. + */ +export function authFetch(input: RequestInfo, init?: RequestInit): Promise { + const authStore = useAuthStore(); + const url = typeof input === "string" ? input : input.url; + + // Allow unauthenticated requests to specific endpoints + if (isUnauthenticatedEndpoint(url)) { + return fetch(input, init); + } + + // If authentication is disabled, make request without token + if (!authStore.authEnabled) { + return fetch(input, init); + } + + // If authentication is enabled, require a token + // potentially handle token refresh here if expired, however it shouldnt be required due to silent renew + const token = authStore.token; + if (!token) { + throw new Error("No authentication token available. Please authenticate first."); + } + + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${token}`); + + return fetch(input, { ...init, headers }); +} diff --git a/src/Frontend/src/main.ts b/src/Frontend/src/main.ts index cd549b1d06..bfab6b6f8c 100644 --- a/src/Frontend/src/main.ts +++ b/src/Frontend/src/main.ts @@ -10,7 +10,8 @@ async function conditionallyEnableMocking() { return; } - const { worker } = await import("@/../test/mocks/browser"); + const { loadScenario } = await import("@/../test/mocks/scenarios"); + const { worker } = await loadScenario(); // `worker.start()` returns a Promise that resolves // once the Service Worker is up and ready to intercept requests. diff --git a/src/Frontend/src/mount.ts b/src/Frontend/src/mount.ts index d8eab93497..d9f1223d58 100644 --- a/src/Frontend/src/mount.ts +++ b/src/Frontend/src/mount.ts @@ -1,6 +1,6 @@ import { createApp } from "vue"; import type { Router } from "vue-router"; -import App from "./App.vue"; +import AuthApp from "./AuthApp.vue"; import Toast, { type PluginOptions, POSITION } from "vue-toastification"; import VueTippy from "vue-tippy"; import { createPinia } from "pinia"; @@ -22,7 +22,7 @@ export function mount({ router }: { router: Router }) { next(); }); - const app = createApp(App); + const app = createApp(AuthApp); app.use(router).use(Toast, toastOptions).use(SimpleTypeahead).use(createPinia()).use(VueTippy); app.mount(`#app`); diff --git a/src/Frontend/src/router/config.ts b/src/Frontend/src/router/config.ts index 63da573eff..9e4f579db0 100644 --- a/src/Frontend/src/router/config.ts +++ b/src/Frontend/src/router/config.ts @@ -9,6 +9,7 @@ import CustomChecksView from "@/views/CustomChecksView.vue"; import HeartbeatsView from "@/views/HeartbeatsView.vue"; import ThroughputReportView from "@/views/ThroughputReportView.vue"; import AuditView from "@/views/AuditView.vue"; +import LoggedOutView from "@/views/LoggedOutView.vue"; export interface RouteItem { path: string; @@ -17,9 +18,16 @@ export interface RouteItem { title: string; component?: RouteComponent | (() => Promise); children?: RouteItem[]; + allowAnonymous?: boolean; } const config: RouteItem[] = [ + { + path: routeLinks.loggedOut, + component: LoggedOutView, + title: "Signed Out", + allowAnonymous: true, + }, { path: routeLinks.dashboard, component: DashboardView, diff --git a/src/Frontend/src/router/index.ts b/src/Frontend/src/router/index.ts index 9384b05cb4..1a96454c44 100644 --- a/src/Frontend/src/router/index.ts +++ b/src/Frontend/src/router/index.ts @@ -1,8 +1,11 @@ import { createRouter, createWebHashHistory, type RouteRecordRaw, RouteRecordSingleViewWithChildren } from "vue-router"; import config, { RouteItem } from "./config"; -function meta(item: { title: string }) { - return { title: `${item.title} • ServicePulse` }; +function meta(item: RouteItem) { + return { + title: `${item.title} • ServicePulse`, + allowAnonymous: item.allowAnonymous ?? false, + }; } function addChildren(parent: RouteRecordSingleViewWithChildren, item: RouteItem) { diff --git a/src/Frontend/src/router/routeLinks.ts b/src/Frontend/src/router/routeLinks.ts index 9cb63ed9a6..64ee77620c 100644 --- a/src/Frontend/src/router/routeLinks.ts +++ b/src/Frontend/src/router/routeLinks.ts @@ -107,6 +107,7 @@ const routeLinks = { messages: messagesLinks("/messages"), configuration: configurationLinks("/configuration"), throughput: throughputLinks("/usage"), + loggedOut: "/logged-out", }; export default routeLinks; diff --git a/src/Frontend/src/stores/AuthStore.ts b/src/Frontend/src/stores/AuthStore.ts new file mode 100644 index 0000000000..8ee3062751 --- /dev/null +++ b/src/Frontend/src/stores/AuthStore.ts @@ -0,0 +1,124 @@ +import { acceptHMRUpdate, defineStore } from "pinia"; +import { ref } from "vue"; +import type { AuthConfig } from "@/types/auth"; +import { WebStorageStateStore } from "oidc-client-ts"; +import routeLinks from "@/router/routeLinks"; +import serviceControlClient from "@/components/serviceControlClient"; + +interface AuthConfigResponse { + enabled: boolean; + client_id: string; + authority: string; + api_scopes: string; + audience: string; +} + +export const useAuthStore = defineStore("auth", () => { + const token = ref(null); + const isAuthenticated = ref(false); + const isAuthenticating = ref(false); + const authError = ref(null); + const authConfig = ref(null); + const authEnabled = ref(false); + const loading = ref(true); + + async function refresh() { + loading.value = true; + try { + const config = await getAuthConfig(); + if (config) { + authEnabled.value = config.enabled; + authConfig.value = config.enabled ? transformToAuthConfig(config) : null; + } + } finally { + loading.value = false; + } + } + + async function getAuthConfig() { + try { + const [, data] = await serviceControlClient.fetchTypedFromServiceControl("authentication/configuration"); + return data; + } catch (err) { + console.error("Error fetching auth configuration", err); + return null; + } + } + + function transformToAuthConfig(config: AuthConfigResponse): AuthConfig { + const apiScope = JSON.parse(config.api_scopes).join(" "); + // Use hash-based URL for post-logout redirect since the app uses hash routing + const postLogoutRedirectUri = `${window.location.origin}${window.location.pathname}#${routeLinks.loggedOut}`; + return { + authority: config.authority, + client_id: config.client_id, + redirect_uri: window.location.origin, + post_logout_redirect_uri: postLogoutRedirectUri, + response_type: "code", + scope: `${apiScope} openid profile email offline_access`, + automaticSilentRenew: true, + loadUserInfo: false, + includeIdTokenInSilentRenew: true, + silent_redirect_uri: window.location.origin + "/silent-renew.html", + filterProtocolClaims: true, + userStore: new WebStorageStateStore({ store: window.sessionStorage }), + extraQueryParams: { + audience: config.audience, + }, + }; + } + + function setToken(newToken: string | null) { + token.value = newToken; + isAuthenticated.value = !!newToken; + + if (newToken) { + sessionStorage.setItem("auth_token", newToken); + } else { + sessionStorage.removeItem("auth_token"); + } + } + + function clearToken() { + setToken(null); + authError.value = null; + } + + function loadTokenFromStorage() { + const storedToken = sessionStorage.getItem("auth_token"); + if (storedToken) { + token.value = storedToken; + isAuthenticated.value = true; + } + } + + function setAuthenticating(value: boolean) { + isAuthenticating.value = value; + } + + function setAuthError(error: string | null) { + authError.value = error; + } + + return { + token, + isAuthenticated, + isAuthenticating, + authError, + authConfig, + authEnabled, + loading, + refresh, + setToken, + clearToken, + loadTokenFromStorage, + setAuthenticating, + setAuthError, + }; +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot)); +} + +export type AuthStore = ReturnType; diff --git a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts index 56ab03151d..1372437f35 100644 --- a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts +++ b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts @@ -6,6 +6,7 @@ import { acceptHMRUpdate, defineStore } from "pinia"; import { computed, reactive } from "vue"; import serviceControlClient from "@/components/serviceControlClient"; import monitoringClient from "@/components/monitoring/monitoringClient"; +import { authFetch } from "@/composables/useAuthenticatedFetch"; export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersionsStore", () => { const environment = reactive({ @@ -104,9 +105,10 @@ export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersion }; }); -async function getData(url: string) { +async function getData(url: string, authenticated = false) { try { - const response = await fetch(url); + // eslint-disable-next-line local/no-raw-fetch + const response = await (authenticated ? authFetch(url) : fetch(url)); // this needs to be an unauthenticated call return (await response.json()) as unknown as Release[]; } catch (e) { console.log(e); @@ -124,8 +126,8 @@ async function useServiceProductUrls() { const spURL = "https://platformupdate.particular.net/servicepulse.txt"; const scURL = "https://platformupdate.particular.net/servicecontrol.txt"; - const servicePulse = getData(spURL); - const serviceControl = getData(scURL); + const servicePulse = getData(spURL, false); + const serviceControl = getData(scURL, false); const [sp, sc] = await Promise.all([servicePulse, serviceControl]); const latestSP = sp[0]; diff --git a/src/Frontend/src/types/auth.ts b/src/Frontend/src/types/auth.ts new file mode 100644 index 0000000000..cdcafba80f --- /dev/null +++ b/src/Frontend/src/types/auth.ts @@ -0,0 +1,7 @@ +import type { UserManagerSettings } from "oidc-client-ts"; + +/** + * Extended OIDC configuration using 'oidc-client-ts' package + * This provides type-safe configuration for any OIDC-compliant identity provider + */ +export type AuthConfig = UserManagerSettings; diff --git a/src/Frontend/src/views/LoggedOutView.vue b/src/Frontend/src/views/LoggedOutView.vue new file mode 100644 index 0000000000..7328039675 --- /dev/null +++ b/src/Frontend/src/views/LoggedOutView.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/Frontend/test/mocks/browser.ts b/src/Frontend/test/mocks/browser.ts index 5b192293d7..4b3d5a760e 100644 --- a/src/Frontend/test/mocks/browser.ts +++ b/src/Frontend/test/mocks/browser.ts @@ -22,7 +22,8 @@ const makeDriver = (): Driver => ({ const driver = makeDriver(); -(async () => { +// Export a promise that resolves when all mock handlers are registered +export const setupComplete = (async () => { await driver.setUp(precondition.serviceControlWithMonitoring); //override the default mocked endpoints with a custom list await driver.setUp(precondition.hasCustomChecks(3, 2)); diff --git a/src/Frontend/test/mocks/oidc-client-mock.ts b/src/Frontend/test/mocks/oidc-client-mock.ts new file mode 100644 index 0000000000..cdcb3ca875 --- /dev/null +++ b/src/Frontend/test/mocks/oidc-client-mock.ts @@ -0,0 +1,124 @@ +/** + * Mock for oidc-client-ts library + * + * This mock allows testing the full authentication flow without + * requiring a real identity provider or browser redirects. + * + * Usage in test file (must be at the top, before other imports): + * + * ```typescript + * import { vi } from "vitest"; + * import { createOidcMock } from "../../mocks/oidc-client-mock"; + * + * vi.mock("oidc-client-ts", () => createOidcMock()); + * + * // ... rest of imports and tests + * ``` + */ +import { vi } from "vitest"; + +export interface MockUser { + access_token: string; + expired: boolean; + profile: { + name: string; + email: string; + sub: string; + }; +} + +export const defaultMockUser: MockUser = { + access_token: "mock-access-token-for-testing", + expired: false, + profile: { + name: "Test User", + email: "test.user@example.com", + sub: "user-123", + }, +}; + +/** + * Creates the oidc-client-ts mock module with an authenticated user. + * Use with vi.mock("oidc-client-ts", () => createOidcMock()) + * + * @param user - The mock user to return from getUser(), or null for unauthenticated + */ +export function createOidcMock(user: MockUser | null = defaultMockUser) { + return { + UserManager: class MockUserManager { + getUser = vi.fn().mockResolvedValue(user); + signinRedirect = vi.fn().mockResolvedValue(undefined); + signinCallback = vi.fn().mockResolvedValue(user); + signinSilent = vi.fn().mockResolvedValue(user); + signoutRedirect = vi.fn().mockResolvedValue(undefined); + removeUser = vi.fn().mockResolvedValue(undefined); + events = { + addUserLoaded: vi.fn(), + addUserUnloaded: vi.fn(), + addAccessTokenExpiring: vi.fn(), + addAccessTokenExpired: vi.fn(), + addSilentRenewError: vi.fn(), + }; + }, + WebStorageStateStore: class MockWebStorageStateStore {}, + }; +} + +/** + * Creates a mock for unauthenticated state (will trigger login redirect) + */ +export function createOidcMockUnauthenticated() { + return createOidcMock(null); +} + +/** + * Creates a mock where signinSilent fails (simulates IdP session expired). + * Initial authentication works, but token renewal fails. + */ +export function createOidcMockWithFailingSilentRenew(user: MockUser = defaultMockUser) { + return { + UserManager: class MockUserManager { + getUser = vi.fn().mockResolvedValue(user); + signinRedirect = vi.fn().mockResolvedValue(undefined); + signinCallback = vi.fn().mockResolvedValue(user); + // signinSilent fails - simulating IdP session expired + signinSilent = vi.fn().mockRejectedValue(new Error("Silent renewal failed: IdP session expired")); + signoutRedirect = vi.fn().mockResolvedValue(undefined); + removeUser = vi.fn().mockResolvedValue(undefined); + events = { + addUserLoaded: vi.fn(), + addUserUnloaded: vi.fn(), + addAccessTokenExpiring: vi.fn(), + addAccessTokenExpired: vi.fn(), + addSilentRenewError: vi.fn(), + }; + }, + WebStorageStateStore: class MockWebStorageStateStore {}, + }; +} + +/** + * Creates a mock where signinCallback fails (simulates invalid redirect URI). + * This happens when the identity provider rejects the OAuth callback. + */ +export function createOidcMockWithInvalidRedirectUri() { + return { + UserManager: class MockUserManager { + getUser = vi.fn().mockResolvedValue(null); + signinRedirect = vi.fn().mockResolvedValue(undefined); + // signinCallback fails - simulating IdP rejecting the redirect URI + signinCallback = vi.fn().mockRejectedValue(new Error("Invalid redirect_uri: The redirect URI in the request does not match the configured redirect URIs")); + signinSilent = vi.fn().mockRejectedValue(new Error("No user session")); + signoutRedirect = vi.fn().mockResolvedValue(undefined); + removeUser = vi.fn().mockResolvedValue(undefined); + events = { + addUserLoaded: vi.fn(), + addUserUnloaded: vi.fn(), + addAccessTokenExpiring: vi.fn(), + addAccessTokenExpired: vi.fn(), + addSilentRenewError: vi.fn(), + }; + }, + WebStorageStateStore: class MockWebStorageStateStore {}, + }; +} diff --git a/src/Frontend/test/mocks/scenarios/authentication/auth-authenticated.ts b/src/Frontend/test/mocks/scenarios/authentication/auth-authenticated.ts new file mode 100644 index 0000000000..4688728485 --- /dev/null +++ b/src/Frontend/test/mocks/scenarios/authentication/auth-authenticated.ts @@ -0,0 +1,52 @@ +/** + * Scenario 3: Authenticated User State + * + * This scenario mocks a user who has completed the OIDC login flow. + * The app loads with an authenticated session already established. + * + * Note: This bypasses the actual OIDC redirect flow - it simulates + * the state after a successful login. + * + * Usage: + * set VITE_MOCK_SCENARIO=auth-authenticated + * npm run dev:mocks + * + * Test: + * 1. Open browser to the dev server URL + * 2. Dashboard should load directly (no login redirect) + * 3. User profile menu should appear in header + */ +import { setupWorker } from "msw/browser"; +import { Driver } from "../../../driver"; +import { makeMockEndpoint, makeMockEndpointDynamic } from "../../../mock-endpoint"; +import * as precondition from "../../../preconditions"; + +export const worker = setupWorker(); +const mockEndpoint = makeMockEndpoint({ mockServer: worker }); +const mockEndpointDynamic = makeMockEndpointDynamic({ mockServer: worker }); + +const makeDriver = (): Driver => ({ + goTo() { + throw new Error("Not implemented"); + }, + mockEndpoint, + mockEndpointDynamic, + setUp(factory) { + return factory({ driver: this }); + }, + disposeApp() { + throw new Error("Not implemented"); + }, +}); + +const driver = makeDriver(); + +// Export a promise that resolves when all mock handlers are registered +export const setupComplete = (async () => { + // Scenario 3: Authenticated user state (shared precondition) + await driver.setUp(precondition.scenarioAuthenticatedUser); + + // Sample data for testing + await driver.setUp(precondition.hasCustomChecks(3, 2)); + await driver.setUp(precondition.monitoredEndpointsNamed(["Sales.OrderProcessor", "Sales.PaymentHandler", "Shipping.DeliveryService"])); +})(); diff --git a/src/Frontend/test/mocks/scenarios/authentication/auth-disabled.ts b/src/Frontend/test/mocks/scenarios/authentication/auth-disabled.ts new file mode 100644 index 0000000000..59f1d87541 --- /dev/null +++ b/src/Frontend/test/mocks/scenarios/authentication/auth-disabled.ts @@ -0,0 +1,49 @@ +/** + * Scenario 1: Authentication Disabled (Default) + * + * This scenario mocks ServiceControl with authentication disabled. + * ServicePulse loads directly without any login prompt. + * + * Usage: + * set VITE_MOCK_SCENARIO=auth-disabled + * npm run dev:mocks + * + * Test: + * 1. Open browser to the dev server URL + * 2. ServicePulse should load directly without login + * 3. User profile menu should NOT appear in header + */ +import { setupWorker } from "msw/browser"; +import { Driver } from "../../../driver"; +import { makeMockEndpoint, makeMockEndpointDynamic } from "../../../mock-endpoint"; +import * as precondition from "../../../preconditions"; + +export const worker = setupWorker(); +const mockEndpoint = makeMockEndpoint({ mockServer: worker }); +const mockEndpointDynamic = makeMockEndpointDynamic({ mockServer: worker }); + +const makeDriver = (): Driver => ({ + goTo() { + throw new Error("Not implemented"); + }, + mockEndpoint, + mockEndpointDynamic, + setUp(factory) { + return factory({ driver: this }); + }, + disposeApp() { + throw new Error("Not implemented"); + }, +}); + +const driver = makeDriver(); + +// Export a promise that resolves when all mock handlers are registered +export const setupComplete = (async () => { + // Scenario 1: Authentication disabled (shared precondition) + await driver.setUp(precondition.scenarioAuthDisabled); + + // Sample data for testing + await driver.setUp(precondition.hasCustomChecks(3, 2)); + await driver.setUp(precondition.monitoredEndpointsNamed(["Sales.OrderProcessor", "Sales.PaymentHandler", "Shipping.DeliveryService"])); +})(); diff --git a/src/Frontend/test/mocks/scenarios/authentication/auth-enabled.ts b/src/Frontend/test/mocks/scenarios/authentication/auth-enabled.ts new file mode 100644 index 0000000000..a85988f439 --- /dev/null +++ b/src/Frontend/test/mocks/scenarios/authentication/auth-enabled.ts @@ -0,0 +1,52 @@ +/** + * Scenario 2: Authentication Enabled + * + * This scenario mocks ServiceControl with authentication enabled. + * The auth configuration endpoint returns valid OIDC settings. + * + * Note: This scenario only mocks the configuration endpoint. + * The actual OIDC flow requires a real identity provider. + * + * Usage: + * set VITE_MOCK_SCENARIO=auth-enabled + * npm run dev:mocks + * + * Test: + * 1. Open browser to the dev server URL + * 2. ServicePulse should redirect to identity provider (will fail without real IdP) + * 3. Check Network tab for /api/authentication/configuration response + */ +import { setupWorker } from "msw/browser"; +import { Driver } from "../../../driver"; +import { makeMockEndpoint, makeMockEndpointDynamic } from "../../../mock-endpoint"; +import * as precondition from "../../../preconditions"; + +export const worker = setupWorker(); +const mockEndpoint = makeMockEndpoint({ mockServer: worker }); +const mockEndpointDynamic = makeMockEndpointDynamic({ mockServer: worker }); + +const makeDriver = (): Driver => ({ + goTo() { + throw new Error("Not implemented"); + }, + mockEndpoint, + mockEndpointDynamic, + setUp(factory) { + return factory({ driver: this }); + }, + disposeApp() { + throw new Error("Not implemented"); + }, +}); + +const driver = makeDriver(); + +// Export a promise that resolves when all mock handlers are registered +export const setupComplete = (async () => { + // Scenario 2: Authentication enabled (shared precondition) + await driver.setUp(precondition.scenarioAuthEnabled); + + // Sample data for testing + await driver.setUp(precondition.hasCustomChecks(3, 2)); + await driver.setUp(precondition.monitoredEndpointsNamed(["Sales.OrderProcessor", "Sales.PaymentHandler", "Shipping.DeliveryService"])); +})(); diff --git a/src/Frontend/test/mocks/scenarios/index.ts b/src/Frontend/test/mocks/scenarios/index.ts new file mode 100644 index 0000000000..38d529e8dc --- /dev/null +++ b/src/Frontend/test/mocks/scenarios/index.ts @@ -0,0 +1,47 @@ +/** + * Mock Scenarios Index + * + * This file dynamically loads the appropriate mock scenario based on + * the VITE_MOCK_SCENARIO environment variable. + * + * Usage: + * VITE_MOCK_SCENARIO=auth-disabled npm run dev:mocks + * + * Or add to package.json scripts: + * "dev:mocks:auth-disabled": "cross-env NODE_ENV=dev-mocks VITE_MOCK_SCENARIO=auth-disabled vite" + */ + +type ScenarioModule = { + worker: import("msw/browser").SetupWorker; + setupComplete?: Promise; +}; + +const scenarios: Record Promise> = { + default: () => import("../browser"), + "auth-disabled": () => import("./authentication/auth-disabled"), + "auth-enabled": () => import("./authentication/auth-enabled"), + "auth-authenticated": () => import("./authentication/auth-authenticated"), +}; + +export async function loadScenario(): Promise { + // Trim to handle Windows CMD whitespace issues (e.g., "set VAR=value && cmd" includes trailing space) + const scenarioName = import.meta.env.VITE_MOCK_SCENARIO?.trim() || "default"; + const loader = scenarios[scenarioName]; + + if (!loader) { + console.warn(`Unknown mock scenario: "${scenarioName}", falling back to default. Available: ${Object.keys(scenarios).join(", ")}`); + const module = await scenarios.default(); + if (module.setupComplete) await module.setupComplete; + return module; + } + + console.log(`Loading mock scenario: ${scenarioName}`); + const module = await loader(); + + // Wait for setup to complete before returning + if (module.setupComplete) { + await module.setupComplete; + } + + return module; +} diff --git a/src/Frontend/test/preconditions/authentication.ts b/src/Frontend/test/preconditions/authentication.ts new file mode 100644 index 0000000000..78afe42e3e --- /dev/null +++ b/src/Frontend/test/preconditions/authentication.ts @@ -0,0 +1,192 @@ +import { SetupFactoryOptions } from "../driver"; +import * as precondition from "."; + +/** + * Authentication configuration response from ServiceControl + * Endpoint: GET /api/authentication/configuration + */ +export interface AuthConfigResponse { + enabled: boolean; + client_id: string; + authority: string; + api_scopes: string; + audience: string; +} + +/** + * Default disabled auth configuration + * Used when authentication is not configured in ServiceControl + */ +export const authDisabledConfig: AuthConfigResponse = { + enabled: false, + client_id: "", + authority: "", + api_scopes: "[]", + audience: "", +}; + +/** + * Example enabled auth configuration for testing + * Uses placeholder values - replace with real IdP config for integration tests + */ +export const authEnabledConfig: AuthConfigResponse = { + enabled: true, + client_id: "servicepulse-test", + authority: "https://login.microsoftonline.com/test-tenant-id/v2.0", + api_scopes: '["api://servicecontrol/access_as_user"]', + audience: "api://servicecontrol", +}; + +/** + * Scenario 1: Authentication Disabled (Default) + * ServiceControl returns auth as disabled, no login required + */ +export const hasAuthenticationDisabled = + () => + ({ driver }: SetupFactoryOptions) => { + const serviceControlInstanceUrl = window.defaultConfig.service_control_url; + driver.mockEndpoint(`${serviceControlInstanceUrl}authentication/configuration`, { + body: authDisabledConfig, + }); + return authDisabledConfig; + }; + +/** + * Authentication enabled with custom configuration + * @param config - Custom auth configuration to return + */ +export const hasAuthenticationEnabled = + (config: Partial = {}) => + ({ driver }: SetupFactoryOptions) => { + const fullConfig: AuthConfigResponse = { + ...authEnabledConfig, + ...config, + }; + const serviceControlInstanceUrl = window.defaultConfig.service_control_url; + driver.mockEndpoint(`${serviceControlInstanceUrl}authentication/configuration`, { + body: fullConfig, + }); + return fullConfig; + }; + +/** + * Authentication endpoint returns an error (ServiceControl unavailable) + */ +export const hasAuthenticationError = + (status = 500) => + ({ driver }: SetupFactoryOptions) => { + const serviceControlInstanceUrl = window.defaultConfig.service_control_url; + driver.mockEndpoint(`${serviceControlInstanceUrl}authentication/configuration`, { + status, + body: { error: "ServiceControl unavailable" }, + }); + }; + +/** + * Scenario 1: Complete setup for authentication disabled + * Includes ServiceControl with monitoring and auth disabled config + * Use this for both manual mock scenarios and automated tests + */ +export const scenarioAuthDisabled = async ({ driver }: SetupFactoryOptions) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(hasAuthenticationDisabled()); + return authDisabledConfig; +}; + +/** + * Scenario 2: Complete setup for authentication enabled + * Includes ServiceControl with monitoring and auth enabled config + * Use this for both manual mock scenarios and automated tests + */ +export const scenarioAuthEnabled = async ({ driver }: SetupFactoryOptions) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(hasAuthenticationEnabled()); + return authEnabledConfig; +}; + +/** + * Mock user profile for authenticated state testing + */ +export const mockAuthenticatedUser = { + name: "Test User", + email: "test.user@example.com", + sub: "user-123", +}; + +/** + * Mock OIDC discovery document for browser-based testing. + * This allows oidc-client-ts to initialize without a real IdP. + */ +export const hasOidcDiscoveryMock = + () => + ({ driver }: SetupFactoryOptions) => { + const authority = authEnabledConfig.authority; + + // Mock the OIDC discovery endpoint + driver.mockEndpoint(`${authority}/.well-known/openid-configuration`, { + body: { + issuer: authority, + authorization_endpoint: `${authority}/authorize`, + token_endpoint: `${authority}/token`, + userinfo_endpoint: `${authority}/userinfo`, + end_session_endpoint: `${authority}/logout`, + jwks_uri: `${authority}/keys`, + response_types_supported: ["code", "id_token", "token"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + scopes_supported: ["openid", "profile", "email", "offline_access"], + }, + }); + }; + +/** + * Creates a mock OIDC user object in the format oidc-client-ts expects. + * This is stored in sessionStorage and retrieved by UserManager.getUser() + */ +function createOidcUserObject(token: string, profile: typeof mockAuthenticatedUser) { + const now = Math.floor(Date.now() / 1000); + const expiresIn = 3600; // 1 hour + + return { + access_token: token, + token_type: "Bearer", + expires_at: now + expiresIn, + expired: false, + scope: "openid profile email offline_access", + profile: { + sub: profile.sub, + name: profile.name, + email: profile.email, + iss: authEnabledConfig.authority, + aud: authEnabledConfig.client_id, + exp: now + expiresIn, + iat: now, + }, + }; +} + +/** + * Scenario 3: Authenticated user state + * Simulates a user who has completed the OIDC login flow. + * Pre-populates oidc-client-ts storage with a mock authenticated user. + * + * Works for both browser-based manual testing and automated tests. + */ +export const scenarioAuthenticatedUser = async ({ driver }: SetupFactoryOptions) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(hasAuthenticationEnabled()); + await driver.setUp(hasOidcDiscoveryMock()); + + // Create mock token and user + const mockToken = "mock-access-token-for-testing"; + const oidcUser = createOidcUserObject(mockToken, mockAuthenticatedUser); + + // Store in the format oidc-client-ts expects: oidc.user:{authority}:{client_id} + const storageKey = `oidc.user:${authEnabledConfig.authority}:${authEnabledConfig.client_id}`; + sessionStorage.setItem(storageKey, JSON.stringify(oidcUser)); + + // Also set the auth_token for the AuthStore + sessionStorage.setItem("auth_token", mockToken); + + return { authConfig: authEnabledConfig, user: mockAuthenticatedUser, token: mockToken }; +}; diff --git a/src/Frontend/test/preconditions/index.ts b/src/Frontend/test/preconditions/index.ts index b2a0bcc10c..ca2536b1dd 100644 --- a/src/Frontend/test/preconditions/index.ts +++ b/src/Frontend/test/preconditions/index.ts @@ -21,3 +21,4 @@ export { hasLicensingSettingTest } from "../preconditions/hasLicensingSettingTes export { hasLicensingEndpoints } from "../preconditions/hasLicensingEndpoints"; export { hasEndpointSettings } from "./hasEndpointSettings"; export * from "./configuration"; +export * from "./authentication"; diff --git a/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts b/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts index dfae768ace..7a1c46b349 100644 --- a/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts +++ b/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts @@ -4,6 +4,9 @@ import { SetupFactoryOptions } from "../driver"; export const serviceControlWithMonitoring = async ({ driver }: SetupFactoryOptions) => { //Service control requests minimum setup. Todo: encapsulate for reuse. + //http://localhost:33333/api/authentication/configuration - auth disabled by default + await driver.setUp(precondition.hasAuthenticationDisabled()); + //http://localhost:33333/api/license await driver.setUp(precondition.hasActiveLicense); diff --git a/src/Frontend/test/specs/authentication/auth-authenticated.spec.ts b/src/Frontend/test/specs/authentication/auth-authenticated.spec.ts new file mode 100644 index 0000000000..a85274b163 --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-authenticated.spec.ts @@ -0,0 +1,59 @@ +import { vi, expect } from "vitest"; +import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock"; + +// Mock oidc-client-ts BEFORE any imports that use it +vi.mock("oidc-client-ts", () => createOidcMock()); + +import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { waitFor, screen } from "@testing-library/vue"; + +describe("FEATURE: Authenticated User Access", () => { + describe("RULE: Authenticated users should see the dashboard", () => { + test("EXAMPLE: Dashboard loads with authenticated user", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify auth token was set by the mocked UserManager + const authToken = sessionStorage.getItem("auth_token"); + expect(authToken).toBe(defaultMockUser.access_token); + }); + }); + + describe("RULE: Authenticated requests should include Bearer token", () => { + test("EXAMPLE: API requests include Authorization header", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + // Track Authorization headers from API requests + const capturedHeaders: string[] = []; + const serviceControlUrl = window.defaultConfig.service_control_url; + + driver.mockEndpointDynamic(`${serviceControlUrl}endpoints`, "get", (_url, _params, request) => { + const authHeader = request.headers.get("Authorization"); + if (authHeader) { + capturedHeaders.push(authHeader); + } + return Promise.resolve({ body: [] }); + }); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify at least one request included the Bearer token + await waitFor(() => { + expect(capturedHeaders.length).toBeGreaterThan(0); + expect(capturedHeaders[0]).toBe(`Bearer ${defaultMockUser.access_token}`); + }); + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-callback-error.spec.ts b/src/Frontend/test/specs/authentication/auth-callback-error.spec.ts new file mode 100644 index 0000000000..218a4e1d46 --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-callback-error.spec.ts @@ -0,0 +1,152 @@ +import { vi, expect, beforeEach } from "vitest"; +import { createOidcMockUnauthenticated } from "../../mocks/oidc-client-mock"; + +// Mock oidc-client-ts with unauthenticated state +vi.mock("oidc-client-ts", () => createOidcMockUnauthenticated()); + +import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { waitFor } from "@testing-library/vue"; +import { useAuthStore } from "@/stores/AuthStore"; + +describe("FEATURE: OAuth Callback Error Handling (Scenario 16)", () => { + describe("RULE: OAuth errors should be captured and displayed to the user", () => { + // Store the original location + let originalLocation: Location; + + beforeEach(() => { + // Save original location + originalLocation = window.location; + }); + + test("EXAMPLE: access_denied error sets auth error state", async ({ driver }) => { + // Mock window.location.search to include OAuth error parameters + // This simulates the IdP redirecting back with an error + const mockSearch = "?error=access_denied&error_description=User%20cancelled%20the%20login"; + + // Create a mock location object + const mockLocation = { + ...originalLocation, + search: mockSearch, + hash: "#/dashboard", + href: `http://localhost:5173${mockSearch}#/dashboard`, + }; + + // Replace window.location + Object.defineProperty(window, "location", { + value: mockLocation, + writable: true, + configurable: true, + }); + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + await driver.goTo("/dashboard"); + + const authStore = useAuthStore(); + + await waitFor(() => { + // Auth error should be set from the URL parameters + expect(authStore.authError).toBeTruthy(); + }); + + // Verify the error message contains the description + expect(authStore.authError).toContain("cancelled"); + + // User should not be authenticated + expect(authStore.isAuthenticated).toBe(false); + + // Restore original location + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + configurable: true, + }); + }); + + test("EXAMPLE: invalid_request error sets auth error state", async ({ driver }) => { + // Simulate invalid_request error (e.g., missing required parameter) + const mockSearch = "?error=invalid_request&error_description=Missing%20required%20parameter"; + + const mockLocation = { + ...originalLocation, + search: mockSearch, + hash: "#/dashboard", + href: `http://localhost:5173${mockSearch}#/dashboard`, + }; + + Object.defineProperty(window, "location", { + value: mockLocation, + writable: true, + configurable: true, + }); + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + await driver.goTo("/dashboard"); + + const authStore = useAuthStore(); + + await waitFor(() => { + expect(authStore.authError).toBeTruthy(); + }); + + // Verify the error captures the description + expect(authStore.authError).toContain("Missing"); + + // User should not be authenticated + expect(authStore.isAuthenticated).toBe(false); + + // Restore original location + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + configurable: true, + }); + }); + + test("EXAMPLE: error without description uses error code", async ({ driver }) => { + // Simulate error without description + const mockSearch = "?error=server_error"; + + const mockLocation = { + ...originalLocation, + search: mockSearch, + hash: "#/dashboard", + href: `http://localhost:5173${mockSearch}#/dashboard`, + }; + + Object.defineProperty(window, "location", { + value: mockLocation, + writable: true, + configurable: true, + }); + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + await driver.goTo("/dashboard"); + + const authStore = useAuthStore(); + + await waitFor(() => { + expect(authStore.authError).toBeTruthy(); + }); + + // When no description, the error code should be used + expect(authStore.authError).toBe("server_error"); + + // User should not be authenticated + expect(authStore.isAuthenticated).toBe(false); + + // Restore original location + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + configurable: true, + }); + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-config-unavailable.spec.ts b/src/Frontend/test/specs/authentication/auth-config-unavailable.spec.ts new file mode 100644 index 0000000000..e4f82113c7 --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-config-unavailable.spec.ts @@ -0,0 +1,88 @@ +import { vi, expect } from "vitest"; +import { createOidcMock } from "../../mocks/oidc-client-mock"; + +// Mock oidc-client-ts +vi.mock("oidc-client-ts", () => createOidcMock()); + +import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { waitFor, screen } from "@testing-library/vue"; +import { useAuthStore } from "@/stores/AuthStore"; + +describe("FEATURE: Auth Configuration Endpoint Unavailable (Scenario 15)", () => { + describe("RULE: App should handle ServiceControl unavailability gracefully", () => { + test("EXAMPLE: App continues to load when auth config endpoint returns error", async ({ driver }) => { + // Set up ServiceControl endpoints but auth config returns 500 error + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationError(500)); + + await driver.goTo("/dashboard"); + + const authStore = useAuthStore(); + + // App should not crash - should continue loading + await waitFor(() => { + // Loading should complete (not stuck) + expect(authStore.loading).toBe(false); + }); + + // Since auth config failed, auth should be treated as disabled + expect(authStore.authEnabled).toBe(false); + + // Dashboard should still be accessible (graceful degradation) + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + }); + + test("EXAMPLE: App continues to load when auth config endpoint returns 503", async ({ driver }) => { + // Simulate ServiceControl being temporarily unavailable (503) + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationError(503)); + + await driver.goTo("/dashboard"); + + const authStore = useAuthStore(); + + await waitFor(() => { + // Loading should complete + expect(authStore.loading).toBe(false); + // Auth should be treated as disabled when config unavailable + expect(authStore.authEnabled).toBe(false); + }); + + // App should not crash or show blank screen + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + }); + + test("EXAMPLE: App continues to load when auth config endpoint times out (network error)", async ({ driver }) => { + // Set up basic ServiceControl endpoints + await driver.setUp(precondition.serviceControlWithMonitoring); + + // Mock auth endpoint to throw network error + const serviceControlUrl = window.defaultConfig.service_control_url; + driver.mockEndpointDynamic(`${serviceControlUrl}authentication/configuration`, "get", () => { + return Promise.reject(new Error("Network error: Connection refused")); + }); + + await driver.goTo("/dashboard"); + + const authStore = useAuthStore(); + + await waitFor(() => { + // Loading should complete despite network error + expect(authStore.loading).toBe(false); + }); + + // Auth should be disabled (graceful fallback) + expect(authStore.authEnabled).toBe(false); + + // Dashboard should still render + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-direct-access.spec.ts b/src/Frontend/test/specs/authentication/auth-direct-access.spec.ts new file mode 100644 index 0000000000..538cce4e93 --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-direct-access.spec.ts @@ -0,0 +1,80 @@ +import { vi, expect } from "vitest"; +import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock"; + +// Mock oidc-client-ts BEFORE any imports that use it +vi.mock("oidc-client-ts", () => createOidcMock()); + +import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { waitFor, screen } from "@testing-library/vue"; + +describe("FEATURE: Direct ServiceControl Access (Scenario 13)", () => { + describe("RULE: Bearer tokens should be included when accessing ServiceControl directly", () => { + test("EXAMPLE: API requests to absolute URLs include Authorization header", async ({ driver }) => { + // Default configuration uses absolute URLs (reverse proxy disabled mode) + // service_control_url is "http://localhost:33333/api/" by default + const serviceControlUrl = window.defaultConfig.service_control_url; + + // Verify we're using absolute URL (not relative YARP path) + expect(serviceControlUrl).toMatch(/^https?:\/\//); + expect(serviceControlUrl).not.toBe("/api/"); + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + // Track Authorization headers from API requests + const capturedHeaders: string[] = []; + const capturedUrls: string[] = []; + + // Mock endpoint at absolute URL (direct ServiceControl access) + driver.mockEndpointDynamic(`${serviceControlUrl}endpoints`, "get", (url, _params, request) => { + capturedUrls.push(url.toString()); + const authHeader = request.headers.get("Authorization"); + if (authHeader) { + capturedHeaders.push(authHeader); + } + return Promise.resolve({ body: [] }); + }); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify requests went to absolute URL (direct access, not through YARP) + await waitFor(() => { + expect(capturedUrls.length).toBeGreaterThan(0); + // URL should be absolute (direct to ServiceControl) + expect(capturedUrls[0]).toMatch(/^https?:\/\/localhost:\d+/); + }); + + // Verify Authorization header was included + await waitFor(() => { + expect(capturedHeaders.length).toBeGreaterThan(0); + expect(capturedHeaders[0]).toBe(`Bearer ${defaultMockUser.access_token}`); + }); + }); + + test("EXAMPLE: Service control URL is configured as absolute path in direct mode", async ({ driver }) => { + // Default configuration uses absolute URLs + const serviceControlUrl = window.defaultConfig.service_control_url; + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify the service control URL is configured as absolute (direct mode) + expect(serviceControlUrl).toMatch(/^https?:\/\//); + expect(serviceControlUrl).toContain("localhost"); + + // Verify auth token is set + expect(sessionStorage.getItem("auth_token")).toBe(defaultMockUser.access_token); + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-disabled.spec.ts b/src/Frontend/test/specs/authentication/auth-disabled.spec.ts new file mode 100644 index 0000000000..111d963ed1 --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-disabled.spec.ts @@ -0,0 +1,33 @@ +import { test, describe } from "../../drivers/vitest/driver"; +import { expect } from "vitest"; +import * as precondition from "../../preconditions"; +import { waitFor, screen } from "@testing-library/vue"; + +describe("FEATURE: Authentication Disabled (Scenario 1)", () => { + describe("RULE: ServicePulse should load without login when auth is disabled", () => { + test("EXAMPLE: Dashboard loads directly without authentication prompt", async ({ driver }) => { + // Uses shared precondition for consistency with manual mock scenario + await driver.setUp(precondition.scenarioAuthDisabled); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + // Dashboard should load without redirect to login + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + }); + + test("EXAMPLE: User profile menu should not appear when auth is disabled", async ({ driver }) => { + await driver.setUp(precondition.scenarioAuthDisabled); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // User profile menu should not be present + expect(screen.queryByTestId("user-profile-menu")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-enabled.spec.ts b/src/Frontend/test/specs/authentication/auth-enabled.spec.ts new file mode 100644 index 0000000000..505d9e70e8 --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-enabled.spec.ts @@ -0,0 +1,40 @@ +import { test, describe } from "../../drivers/vitest/driver"; +import { expect } from "vitest"; +import * as precondition from "../../preconditions"; + +describe("FEATURE: Authentication Enabled (Scenario 2)", () => { + describe("RULE: Authentication configuration endpoint should return valid OIDC config", () => { + test("EXAMPLE: Auth config endpoint returns enabled with all required fields", async ({ driver }) => { + const authConfig = await driver.setUp(precondition.scenarioAuthEnabled); + + // Verify all required fields are present + expect(authConfig.enabled).toBe(true); + expect(authConfig.client_id).toBeDefined(); + expect(authConfig.client_id).not.toBe(""); + expect(authConfig.authority).toBeDefined(); + expect(authConfig.authority).toContain("https://"); + expect(authConfig.api_scopes).toBeDefined(); + expect(authConfig.audience).toBeDefined(); + + // Navigate to trigger app mount (required for cleanup) + await driver.goTo("/dashboard"); + }); + + test("EXAMPLE: Auth config endpoint is accessible without authentication", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + // Navigate to trigger app mount + await driver.goTo("/dashboard"); + + // The endpoint should be mocked and accessible + const serviceControlUrl = window.defaultConfig.service_control_url; + // eslint-disable-next-line local/no-raw-fetch + const response = await fetch(`${serviceControlUrl}authentication/configuration`); + + expect(response.ok).toBe(true); + const data = await response.json(); + expect(data.enabled).toBe(true); + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-logout.spec.ts b/src/Frontend/test/specs/authentication/auth-logout.spec.ts new file mode 100644 index 0000000000..c8d8cb4431 --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-logout.spec.ts @@ -0,0 +1,54 @@ +import { vi, expect } from "vitest"; +import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock"; + +// Mock oidc-client-ts BEFORE any imports that use it +vi.mock("oidc-client-ts", () => createOidcMock()); + +import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { waitFor, screen } from "@testing-library/vue"; + +describe("FEATURE: Logout Flow (Scenario 8)", () => { + describe("RULE: Logged-out page should be accessible without authentication", () => { + test("EXAMPLE: Logged-out page displays sign-out confirmation", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + // Navigate directly to logged-out page (anonymous route) + await driver.goTo("/logged-out"); + + await waitFor(() => { + // Verify the logged-out page content + expect(screen.getByText(/You have been signed out/i)).toBeInTheDocument(); + expect(screen.getByText(/You have successfully signed out of ServicePulse/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Sign in again/i })).toBeInTheDocument(); + }); + }); + + test("EXAMPLE: Logout clears auth token from session storage", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + // First authenticate + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify token exists + expect(sessionStorage.getItem("auth_token")).toBe(defaultMockUser.access_token); + + // Simulate logout by clearing token and navigating to logged-out page + sessionStorage.removeItem("auth_token"); + await driver.goTo("/logged-out"); + + await waitFor(() => { + expect(screen.getByText(/You have been signed out/i)).toBeInTheDocument(); + }); + + // Verify token is cleared + expect(sessionStorage.getItem("auth_token")).toBeNull(); + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-renewal-failure.spec.ts b/src/Frontend/test/specs/authentication/auth-renewal-failure.spec.ts new file mode 100644 index 0000000000..0d586877f8 --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-renewal-failure.spec.ts @@ -0,0 +1,28 @@ +import { vi, expect } from "vitest"; +import { createOidcMockWithFailingSilentRenew, defaultMockUser } from "../../mocks/oidc-client-mock"; + +// Mock oidc-client-ts with signinSilent that fails +vi.mock("oidc-client-ts", () => createOidcMockWithFailingSilentRenew()); + +import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { waitFor, screen } from "@testing-library/vue"; + +describe("FEATURE: Silent Renewal Failure", () => { + describe("RULE: App should handle silent renewal failures gracefully", () => { + test("EXAMPLE: User can still authenticate initially even with failing silent renewal", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Initial authentication works (getUser returns a valid user) + // Even though signinSilent would fail, the initial auth succeeds + expect(sessionStorage.getItem("auth_token")).toBe(defaultMockUser.access_token); + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-session-storage.spec.ts b/src/Frontend/test/specs/authentication/auth-session-storage.spec.ts new file mode 100644 index 0000000000..a064a8b0bf --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-session-storage.spec.ts @@ -0,0 +1,71 @@ +import { vi, expect } from "vitest"; +import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock"; + +// Mock oidc-client-ts BEFORE any imports that use it +vi.mock("oidc-client-ts", () => createOidcMock()); + +import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { waitFor, screen } from "@testing-library/vue"; + +describe("FEATURE: Session Storage Behavior", () => { + describe("RULE: Sessions should be isolated per browser tab", () => { + test("EXAMPLE: Auth token is stored in sessionStorage (tab-specific, not shared)", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify token is in sessionStorage (tab-specific) + const sessionToken = sessionStorage.getItem("auth_token"); + expect(sessionToken).toBe(defaultMockUser.access_token); + + // Verify token is NOT in localStorage (would be shared across tabs) + const localToken = localStorage.getItem("auth_token"); + expect(localToken).toBeNull(); + }); + }); + + describe("RULE: Session should persist across navigation within the same tab", () => { + test("EXAMPLE: Auth token persists when navigating between pages", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + // Initial navigation - triggers auth + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify initial auth token is set + const initialToken = sessionStorage.getItem("auth_token"); + expect(initialToken).toBe(defaultMockUser.access_token); + + // Navigate to a different route + await driver.goTo("/failed-messages/all"); + + // Wait for navigation to complete + await waitFor(() => { + // Verify token persists after navigation + const tokenAfterNav = sessionStorage.getItem("auth_token"); + expect(tokenAfterNav).toBe(defaultMockUser.access_token); + }); + + // Navigate back to dashboard + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify token still persists + const tokenAfterReturn = sessionStorage.getItem("auth_token"); + expect(tokenAfterReturn).toBe(defaultMockUser.access_token); + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-unauthenticated.spec.ts b/src/Frontend/test/specs/authentication/auth-unauthenticated.spec.ts new file mode 100644 index 0000000000..5a1cd57150 --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-unauthenticated.spec.ts @@ -0,0 +1,46 @@ +import { vi, expect } from "vitest"; +import { createOidcMockUnauthenticated } from "../../mocks/oidc-client-mock"; + +// Mock oidc-client-ts with unauthenticated state (no user) +vi.mock("oidc-client-ts", () => createOidcMockUnauthenticated()); + +import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { waitFor } from "@testing-library/vue"; + +describe("FEATURE: Unauthenticated User Handling", () => { + describe("RULE: Unauthenticated users should not have access tokens", () => { + test("EXAMPLE: No auth token when user is not authenticated", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + // Navigate to trigger app mount and auth flow + // The mocked UserManager returns null, so no token will be set + await driver.goTo("/dashboard"); + + // Since user is not authenticated, signinRedirect should be called + // and the app should not proceed to make authenticated API calls + await waitFor(() => { + const authToken = sessionStorage.getItem("auth_token"); + expect(authToken).toBeNull(); + }); + }); + + test("EXAMPLE: signinRedirect is triggered when user has no session", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + // Navigate to protected route without authentication + await driver.goTo("/dashboard"); + + // Since getUser returns null, the app should redirect to IdP + // Auth token should not be set + await waitFor(() => { + expect(sessionStorage.getItem("auth_token")).toBeNull(); + }); + + // Note: In a real scenario, the IdP would handle the login. + // This test verifies the app correctly initiates the redirect flow. + }); + }); +}); diff --git a/src/Frontend/test/specs/authentication/auth-yarp-proxy.spec.ts b/src/Frontend/test/specs/authentication/auth-yarp-proxy.spec.ts new file mode 100644 index 0000000000..d3be11bfad --- /dev/null +++ b/src/Frontend/test/specs/authentication/auth-yarp-proxy.spec.ts @@ -0,0 +1,84 @@ +import { vi, expect } from "vitest"; +import { createOidcMock, defaultMockUser } from "../../mocks/oidc-client-mock"; + +// Mock oidc-client-ts BEFORE any imports that use it +vi.mock("oidc-client-ts", () => createOidcMock()); + +import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { waitFor, screen } from "@testing-library/vue"; +import monitoringClient from "@/components/monitoring/monitoringClient"; +import serviceControlClient from "@/components/serviceControlClient"; + +describe("FEATURE: YARP Reverse Proxy Token Forwarding (Scenario 12)", () => { + describe("RULE: Bearer tokens should be forwarded through YARP proxy", () => { + test("EXAMPLE: API requests through relative URLs include Authorization header", async ({ driver }) => { + // Configure YARP mode with relative URLs + // When reverse proxy is enabled, service_control_url is "/api/" (relative path) + window.defaultConfig.service_control_url = "/api/"; + window.defaultConfig.monitoring_urls = ["/monitoring-api/"]; + serviceControlClient.resetUrl(); + monitoringClient.resetUrl(); + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + // Track Authorization headers from API requests through YARP + const capturedHeaders: string[] = []; + const capturedUrls: string[] = []; + + // Mock endpoint at relative URL (YARP proxy path) + driver.mockEndpointDynamic("/api/endpoints", "get", (url, _params, request) => { + capturedUrls.push(url.toString()); + const authHeader = request.headers.get("Authorization"); + if (authHeader) { + capturedHeaders.push(authHeader); + } + return Promise.resolve({ body: [] }); + }); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify requests went through the relative URL (YARP proxy) + await waitFor(() => { + expect(capturedUrls.length).toBeGreaterThan(0); + // URL should be relative (through YARP), not absolute + expect(capturedUrls[0]).toContain("/api/endpoints"); + }); + + // Verify Authorization header was included (forwarded by YARP) + await waitFor(() => { + expect(capturedHeaders.length).toBeGreaterThan(0); + expect(capturedHeaders[0]).toBe(`Bearer ${defaultMockUser.access_token}`); + }); + }); + + test("EXAMPLE: Service control URL is configured as relative path in YARP mode", async ({ driver }) => { + // Configure YARP mode with relative URLs + window.defaultConfig.service_control_url = "/api/"; + window.defaultConfig.monitoring_urls = ["/monitoring-api/"]; + serviceControlClient.resetUrl(); + monitoringClient.resetUrl(); + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAuthenticationEnabled()); + + await driver.goTo("/dashboard"); + + await waitFor(() => { + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + }); + + // Verify the service control URL is configured as relative (YARP mode) + expect(window.defaultConfig.service_control_url).toBe("/api/"); + expect(window.defaultConfig.monitoring_urls[0]).toBe("/monitoring-api/"); + + // Verify auth token is set (YARP would forward this to backend) + expect(sessionStorage.getItem("auth_token")).toBe(defaultMockUser.access_token); + }); + }); +}); diff --git a/src/Frontend/vite.config.ts b/src/Frontend/vite.config.ts index f8b73aa881..37f5ca9ac2 100644 --- a/src/Frontend/vite.config.ts +++ b/src/Frontend/vite.config.ts @@ -25,6 +25,7 @@ const port = 5173; const defaultUrls = [ "http://10.211.55.3:*", // The default Parallels url to access Windows VM "http://localhost:*", + "https://*", ]; // https://vitejs.dev/config/ diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/DebugRequestInfoResponse.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/DebugRequestInfoResponse.cs new file mode 100644 index 0000000000..6b20807ff1 --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/DebugRequestInfoResponse.cs @@ -0,0 +1,58 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Text.Json.Serialization; + + /// + /// Represents the JSON response from the /debug/request-info endpoint. + /// + public class DebugRequestInfoResponse + { + [JsonPropertyName("processed")] + public ProcessedValues Processed { get; set; } + + [JsonPropertyName("rawHeaders")] + public RawHeaders RawHeaders { get; set; } + + [JsonPropertyName("configuration")] + public ConfigurationValues Configuration { get; set; } + } + + public class ProcessedValues + { + [JsonPropertyName("scheme")] + public string Scheme { get; set; } + + [JsonPropertyName("host")] + public string Host { get; set; } + + [JsonPropertyName("remoteIpAddress")] + public string RemoteIpAddress { get; set; } + } + + public class RawHeaders + { + [JsonPropertyName("xForwardedFor")] + public string XForwardedFor { get; set; } + + [JsonPropertyName("xForwardedProto")] + public string XForwardedProto { get; set; } + + [JsonPropertyName("xForwardedHost")] + public string XForwardedHost { get; set; } + } + + public class ConfigurationValues + { + [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/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestBase.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestBase.cs new file mode 100644 index 0000000000..22675aa11f --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestBase.cs @@ -0,0 +1,57 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Net.Http; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Owin.Testing; + using NUnit.Framework; + + /// + /// Base class for forwarded headers acceptance tests providing common setup, teardown, and helper methods. + /// + public abstract class ForwardedHeadersTestBase + { + protected TestServer Server; + + [TearDown] + public void TearDown() + { + Server?.Dispose(); + ForwardedHeadersTestStartup.Reset(); + } + + protected void CreateServer() + { + Server = TestServer.Create(); + } + + protected async Task SendRequestWithHeaders( + string forwardedFor = null, + string forwardedProto = null, + string forwardedHost = null, + CancellationToken cancellationToken = default) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/debug/request-info"); + + if (forwardedFor != null) + { + request.Headers.Add("X-Forwarded-For", forwardedFor); + } + if (forwardedProto != null) + { + request.Headers.Add("X-Forwarded-Proto", forwardedProto); + } + if (forwardedHost != null) + { + request.Headers.Add("X-Forwarded-Host", forwardedHost); + } + + var response = await Server.HttpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestStartup.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestStartup.cs new file mode 100644 index 0000000000..38082b3c8f --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/ForwardedHeadersTestStartup.cs @@ -0,0 +1,182 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Collections.Generic; + using System.Net; + using System.Threading.Tasks; + using Microsoft.Owin; + using global::Owin; + using ServicePulse.Host.Owin; + + /// + /// Test OWIN startup class that configures the forwarded headers middleware pipeline + /// with the debug endpoint enabled for acceptance testing. + /// + public class ForwardedHeadersTestStartup + { + public static ForwardedHeadersOptions ForwardedHeadersOptions { get; set; } = new ForwardedHeadersOptions(); + + /// + /// The remote IP address to simulate for incoming requests. + /// Microsoft.Owin.Testing doesn't set RemoteIpAddress, so we inject it. + /// + public static string SimulatedRemoteIpAddress { get; set; } = "127.0.0.1"; + + public void Configuration(IAppBuilder app) + { + // Inject simulated remote IP for testing (TestServer doesn't set this) + app.Use(SimulatedRemoteIpAddress); + + // Forwarded headers must be first in the pipeline + app.UseForwardedHeaders(ForwardedHeadersOptions); + + // Debug endpoint for testing - always enabled in tests + app.UseDebugRequestInfo(ForwardedHeadersOptions); + } + + public static void Reset() + { + ForwardedHeadersOptions = new ForwardedHeadersOptions(); + SimulatedRemoteIpAddress = "127.0.0.1"; + } + + public static void ConfigureDefaults() + { + ForwardedHeadersOptions = new ForwardedHeadersOptions + { + Enabled = true, + TrustAllProxies = true + }; + SimulatedRemoteIpAddress = "127.0.0.1"; + } + + public static void ConfigureDisabled() + { + ForwardedHeadersOptions = new ForwardedHeadersOptions + { + Enabled = false + }; + SimulatedRemoteIpAddress = "127.0.0.1"; + } + + public static void ConfigureWithKnownProxies(params string[] proxies) + { + var knownProxies = new List(); + foreach (var proxy in proxies) + { + if (IPAddress.TryParse(proxy, out var ip)) + { + knownProxies.Add(ip); + } + } + + ForwardedHeadersOptions = new ForwardedHeadersOptions + { + Enabled = true, + TrustAllProxies = false, + KnownProxies = knownProxies + }; + SimulatedRemoteIpAddress = "127.0.0.1"; + } + + public static void ConfigureWithKnownNetworks(params string[] networks) + { + var knownNetworks = new List(); + foreach (var network in networks) + { + if (CidrNetwork.TryParse(network, out var cidr)) + { + knownNetworks.Add(cidr); + } + } + + ForwardedHeadersOptions = new ForwardedHeadersOptions + { + Enabled = true, + TrustAllProxies = false, + KnownNetworks = knownNetworks + }; + SimulatedRemoteIpAddress = "127.0.0.1"; + } + + public static void ConfigureWithKnownProxiesAndNetworks(string[] proxies, string[] networks) + { + var knownProxies = new List(); + foreach (var proxy in proxies) + { + if (IPAddress.TryParse(proxy, out var ip)) + { + knownProxies.Add(ip); + } + } + + var knownNetworks = new List(); + foreach (var network in networks) + { + if (CidrNetwork.TryParse(network, out var cidr)) + { + knownNetworks.Add(cidr); + } + } + + ForwardedHeadersOptions = new ForwardedHeadersOptions + { + Enabled = true, + TrustAllProxies = false, + KnownProxies = knownProxies, + KnownNetworks = knownNetworks + }; + SimulatedRemoteIpAddress = "127.0.0.1"; + } + + /// + /// Configure with unknown proxy - simulates a request from an untrusted IP + /// + public static void ConfigureWithUnknownProxy() + { + ForwardedHeadersOptions = new ForwardedHeadersOptions + { + Enabled = true, + TrustAllProxies = false, + KnownProxies = new List { IPAddress.Parse("10.0.0.1") } + }; + // Simulate request from a different IP than the known proxy + SimulatedRemoteIpAddress = "192.168.1.100"; + } + + /// + /// Configure with unknown network - simulates a request from outside trusted networks + /// + public static void ConfigureWithUnknownNetwork() + { + ForwardedHeadersOptions = new ForwardedHeadersOptions + { + Enabled = true, + TrustAllProxies = false, + KnownNetworks = new List { CidrNetwork.TryParse("10.0.0.0/8", out var n) ? n : null } + }; + // Simulate request from an IP outside the known network + SimulatedRemoteIpAddress = "192.168.1.100"; + } + } + + /// + /// Middleware that injects a simulated remote IP address for testing. + /// Microsoft.Owin.Testing's TestServer doesn't populate the RemoteIpAddress. + /// + public class RemoteIpSimulatorMiddleware : OwinMiddleware + { + readonly string remoteIpAddress; + + public RemoteIpSimulatorMiddleware(OwinMiddleware next, string remoteIpAddress) : base(next) + { + this.remoteIpAddress = remoteIpAddress; + } + + public override Task Invoke(IOwinContext context) + { + // Set the server.RemoteIpAddress environment variable + context.Environment["server.RemoteIpAddress"] = remoteIpAddress; + return Next.Invoke(context); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs new file mode 100644 index 0000000000..21c572d297 --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs @@ -0,0 +1,47 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests behavior when forwarded headers processing is disabled. + /// + [TestFixture] + public class When_forwarded_headers_are_disabled : ForwardedHeadersTestBase + { + [SetUp] + public void SetUp() + { + ForwardedHeadersTestStartup.ConfigureDisabled(); + CreateServer(); + } + + [Test] + public async Task Headers_should_not_be_processed() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // When disabled, forwarded headers should not affect processed values + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.Not.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Headers_should_remain_unmodified() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should remain intact when processing is disabled + Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50")); + Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https")); + Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com")); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_sent.cs new file mode 100644 index 0000000000..3d0010c5b0 --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_forwarded_headers_are_sent.cs @@ -0,0 +1,46 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests default behavior when forwarded headers are sent and all proxies are trusted (default configuration). + /// + [TestFixture] + public class When_forwarded_headers_are_sent : ForwardedHeadersTestBase + { + [SetUp] + public void SetUp() + { + ForwardedHeadersTestStartup.ConfigureDefaults(); + CreateServer(); + } + + [Test] + public async Task Headers_should_be_applied_when_trust_all_proxies() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + Assert.That(result.Processed.Host, Is.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Headers_should_be_consumed_after_processing() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should be consumed (removed) after processing + Assert.That(result.RawHeaders.XForwardedFor, Is.Empty); + Assert.That(result.RawHeaders.XForwardedProto, Is.Empty); + Assert.That(result.RawHeaders.XForwardedHost, Is.Empty); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_only_proto_header_is_sent.cs new file mode 100644 index 0000000000..3dea0a1aac --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_only_proto_header_is_sent.cs @@ -0,0 +1,38 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests behavior when only the X-Forwarded-Proto header is sent. + /// Common scenario for reverse proxies that only indicate the protocol. + /// + [TestFixture] + public class When_only_proto_header_is_sent : ForwardedHeadersTestBase + { + [SetUp] + public void SetUp() + { + ForwardedHeadersTestStartup.ConfigureDefaults(); + CreateServer(); + } + + [Test] + public async Task Scheme_should_be_updated() + { + var result = await SendRequestWithHeaders(forwardedProto: "https"); + + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + } + + [Test] + public async Task Host_and_ip_should_use_original_values() + { + var result = await SendRequestWithHeaders(forwardedProto: "https"); + + // Host and IP should use original values since only proto was forwarded + Assert.That(result.Processed.Host, Is.Not.Empty); + // RemoteIpAddress should be the original (not from X-Forwarded-For) + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs new file mode 100644 index 0000000000..7a62e26beb --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs @@ -0,0 +1,68 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests behavior when multiple values are in the X-Forwarded-* headers (proxy chain). + /// The leftmost (first) entry represents the original client's value. + /// + [TestFixture] + public class When_proxy_chain_headers_are_sent : ForwardedHeadersTestBase + { + [SetUp] + public void SetUp() + { + ForwardedHeadersTestStartup.ConfigureDefaults(); + CreateServer(); + } + + [Test] + public async Task Should_use_leftmost_ip_when_trust_all_proxies() + { + // Proxy chain: client -> proxy1 -> proxy2 -> server + // X-Forwarded-For format: client, proxy1, proxy2 + // TrustAllProxies=true processes all entries, ending with leftmost (original client) + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1"); + + // TrustAllProxies=true processes entire chain, returns leftmost (original client) + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Should_use_leftmost_proto_from_chain() + { + var result = await SendRequestWithHeaders( + forwardedProto: "https, http"); + + // Should use leftmost protocol (original client's scheme) + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + } + + [Test] + public async Task Should_use_leftmost_host_from_chain() + { + var result = await SendRequestWithHeaders( + forwardedHost: "first.example.com, second.example.com"); + + // Should use leftmost host (original client's host) + Assert.That(result.Processed.Host, Is.EqualTo("first.example.com")); + } + + [Test] + public async Task Should_consume_all_entries_when_trust_all_proxies() + { + // TrustAllProxies=true consumes all entries from all headers (consistent with ASP.NET Core) + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + forwardedProto: "https, http", + forwardedHost: "first.example.com, second.example.com"); + + // All headers fully consumed when TrustAllProxies=true + Assert.That(result.RawHeaders.XForwardedFor, Is.Empty); + Assert.That(result.RawHeaders.XForwardedProto, Is.Empty); + Assert.That(result.RawHeaders.XForwardedHost, Is.Empty); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs new file mode 100644 index 0000000000..1437b17191 --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs @@ -0,0 +1,32 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests behavior when request has no forwarded headers. + /// + [TestFixture] + public class When_request_has_no_forwarded_headers : ForwardedHeadersTestBase + { + [SetUp] + public void SetUp() + { + ForwardedHeadersTestStartup.ConfigureDefaults(); + CreateServer(); + } + + [Test] + public async Task Original_values_should_be_preserved() + { + // No forwarded headers + var result = await SendRequestWithHeaders(); + + // Original request values should be used + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + Assert.That(result.RawHeaders.XForwardedFor, Is.Empty); + Assert.That(result.RawHeaders.XForwardedProto, Is.Empty); + Assert.That(result.RawHeaders.XForwardedHost, Is.Empty); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_trusted_source_sends_headers.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_trusted_source_sends_headers.cs new file mode 100644 index 0000000000..cdf160bb22 --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_trusted_source_sends_headers.cs @@ -0,0 +1,105 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests behavior when headers are sent from trusted sources (known proxies or networks). + /// Headers should be processed when the request comes from a trusted source. + /// + [TestFixture] + public class When_trusted_source_sends_headers : ForwardedHeadersTestBase + { + [Test] + public async Task Headers_should_be_processed_from_known_proxy() + { + ForwardedHeadersTestStartup.ConfigureWithKnownProxies("127.0.0.1", "::1"); + CreateServer(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + Assert.That(result.Processed.Host, Is.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Headers_should_be_processed_from_known_network() + { + ForwardedHeadersTestStartup.ConfigureWithKnownNetworks("127.0.0.0/8", "::1/128"); + CreateServer(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + Assert.That(result.Processed.Host, Is.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Configuration_should_show_known_proxies() + { + ForwardedHeadersTestStartup.ConfigureWithKnownProxies("127.0.0.1", "::1"); + CreateServer(); + + var result = await SendRequestWithHeaders(); + + Assert.That(result.Configuration.Enabled, Is.True); + Assert.That(result.Configuration.TrustAllProxies, Is.False); + Assert.That(result.Configuration.KnownProxies, Has.Length.GreaterThan(0)); + } + + [Test] + public async Task Configuration_should_show_known_networks() + { + ForwardedHeadersTestStartup.ConfigureWithKnownNetworks("127.0.0.0/8", "::1/128"); + CreateServer(); + + var result = await SendRequestWithHeaders(); + + Assert.That(result.Configuration.Enabled, Is.True); + Assert.That(result.Configuration.TrustAllProxies, Is.False); + Assert.That(result.Configuration.KnownNetworks, Has.Length.GreaterThan(0)); + } + + [Test] + public async Task Headers_should_be_processed_when_matching_either_proxy_or_network() + { + ForwardedHeadersTestStartup.ConfigureWithKnownProxiesAndNetworks( + new[] { "127.0.0.1" }, + new[] { "::1/128" }); + CreateServer(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + Assert.That(result.Processed.Host, Is.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Configuration_should_show_both_proxies_and_networks() + { + ForwardedHeadersTestStartup.ConfigureWithKnownProxiesAndNetworks( + new[] { "127.0.0.1" }, + new[] { "::1/128" }); + CreateServer(); + + var result = await SendRequestWithHeaders(); + + Assert.That(result.Configuration.Enabled, Is.True); + Assert.That(result.Configuration.TrustAllProxies, Is.False); + Assert.That(result.Configuration.KnownProxies, Has.Length.GreaterThan(0)); + Assert.That(result.Configuration.KnownNetworks, Has.Length.GreaterThan(0)); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_untrusted_source_sends_headers.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_untrusted_source_sends_headers.cs new file mode 100644 index 0000000000..07f13f1c06 --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/ForwardedHeaders/When_untrusted_source_sends_headers.cs @@ -0,0 +1,77 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.ForwardedHeaders +{ + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests behavior when headers are sent from untrusted sources. + /// When known proxies/networks are configured but request doesn't come from one, headers should be ignored. + /// + [TestFixture] + public class When_untrusted_source_sends_headers : ForwardedHeadersTestBase + { + [Test] + public async Task Headers_should_not_be_processed_from_unknown_proxy() + { + ForwardedHeadersTestStartup.ConfigureWithUnknownProxy(); + CreateServer(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.Not.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Headers_should_not_be_processed_from_unknown_network() + { + ForwardedHeadersTestStartup.ConfigureWithUnknownNetwork(); + CreateServer(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.Not.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Headers_should_remain_in_request_from_unknown_proxy() + { + ForwardedHeadersTestStartup.ConfigureWithUnknownProxy(); + CreateServer(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50")); + Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https")); + Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com")); + } + + [Test] + public async Task Headers_should_remain_in_request_from_unknown_network() + { + ForwardedHeadersTestStartup.ConfigureWithUnknownNetwork(); + CreateServer(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50")); + Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https")); + Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com")); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestBase.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestBase.cs new file mode 100644 index 0000000000..69a6b33341 --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestBase.cs @@ -0,0 +1,80 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.Https +{ + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Owin.Testing; + using NUnit.Framework; + + /// + /// Base class for HTTPS acceptance tests providing common setup, teardown, and helper methods. + /// + public abstract class HttpsTestBase + { + protected TestServer Server; + + [TearDown] + public void TearDown() + { + Server?.Dispose(); + HttpsTestStartup.Reset(); + } + + protected void CreateServer() + { + Server = TestServer.Create(); + } + + /// + /// Creates an HttpClient that does NOT follow redirects (to test redirect behavior). + /// + protected HttpClient CreateNonRedirectingClient() + { + return new HttpClient(Server.Handler) { BaseAddress = Server.BaseAddress }; + } + + /// + /// Sends a request and returns the response, optionally with forwarded headers. + /// + protected async Task SendRequest( + string path, + HttpMethod method = null, + string forwardedProto = null, + string forwardedHost = null, + bool followRedirects = true, + CancellationToken cancellationToken = default) + { + var request = new HttpRequestMessage(method ?? HttpMethod.Get, path); + + if (forwardedProto != null) + { + request.Headers.Add("X-Forwarded-Proto", forwardedProto); + } + if (forwardedHost != null) + { + request.Headers.Add("X-Forwarded-Host", forwardedHost); + } + + if (followRedirects) + { + return await Server.HttpClient.SendAsync(request, cancellationToken); + } + else + { + using (var client = CreateNonRedirectingClient()) + { + return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + } + } + } + + protected static string GetHstsHeaderValue(HttpResponseMessage response) + { + if (response.Headers.Contains("Strict-Transport-Security")) + { + return string.Join("", response.Headers.GetValues("Strict-Transport-Security")); + } + return null; + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestStartup.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestStartup.cs new file mode 100644 index 0000000000..c459b97e6b --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/HttpsTestStartup.cs @@ -0,0 +1,179 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.Https +{ + using System.Threading.Tasks; + using Microsoft.Owin; + using global::Owin; + using ServicePulse.Host.Owin; + + /// + /// Test OWIN startup class that configures the HTTPS middleware pipeline + /// with a debug endpoint for acceptance testing. + /// + public class HttpsTestStartup + { + public static HttpsOptions HttpsOptions { get; set; } = new HttpsOptions(); + public static ForwardedHeadersOptions ForwardedHeadersOptions { get; set; } = new ForwardedHeadersOptions(); + + /// + /// The scheme to simulate for incoming requests (http or https). + /// + public static string SimulatedScheme { get; set; } = "http"; + + /// + /// The host to simulate for incoming requests. + /// + public static string SimulatedHost { get; set; } = "localhost"; + + public void Configuration(IAppBuilder app) + { + // Inject simulated request properties for testing + app.Use(SimulatedScheme, SimulatedHost); + + // Forwarded headers middleware (processes X-Forwarded-* headers) + app.UseForwardedHeaders(ForwardedHeadersOptions); + + // HTTPS middleware (HSTS and redirect) + app.UseHttpsMiddleware(HttpsOptions); + + // Debug endpoint that returns request info + app.Use(); + } + + public static void Reset() + { + HttpsOptions = new HttpsOptions(); + ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false }; + SimulatedScheme = "http"; + SimulatedHost = "localhost"; + } + + public static void ConfigureHstsEnabled(int maxAgeSeconds = 31536000, bool includeSubDomains = false) + { + HttpsOptions = new HttpsOptions + { + EnableHsts = true, + HstsMaxAgeSeconds = maxAgeSeconds, + HstsIncludeSubDomains = includeSubDomains + }; + ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false }; + SimulatedScheme = "https"; + SimulatedHost = "localhost"; + } + + public static void ConfigureRedirectEnabled(int? httpsPort = null) + { + HttpsOptions = new HttpsOptions + { + RedirectHttpToHttps = true, + Port = httpsPort + }; + ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false }; + SimulatedScheme = "http"; + SimulatedHost = "localhost"; + } + + public static void ConfigureAllEnabled(int? httpsPort = null, int maxAgeSeconds = 31536000, bool includeSubDomains = false) + { + HttpsOptions = new HttpsOptions + { + Enabled = true, + RedirectHttpToHttps = true, + Port = httpsPort, + EnableHsts = true, + HstsMaxAgeSeconds = maxAgeSeconds, + HstsIncludeSubDomains = includeSubDomains + }; + ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false }; + SimulatedScheme = "http"; + SimulatedHost = "localhost"; + } + + public static void ConfigureDisabled() + { + HttpsOptions = new HttpsOptions + { + Enabled = false, + RedirectHttpToHttps = false, + EnableHsts = false + }; + ForwardedHeadersOptions = new ForwardedHeadersOptions { Enabled = false }; + SimulatedScheme = "http"; + SimulatedHost = "localhost"; + } + + public static void ConfigureWithForwardedHeaders() + { + HttpsOptions = new HttpsOptions + { + RedirectHttpToHttps = true, + EnableHsts = true + }; + ForwardedHeadersOptions = new ForwardedHeadersOptions + { + Enabled = true, + TrustAllProxies = true + }; + SimulatedScheme = "http"; + SimulatedHost = "localhost"; + } + } + + /// + /// Middleware that injects simulated request properties for testing. + /// + public class RequestSimulatorMiddleware : OwinMiddleware + { + readonly string scheme; + readonly string host; + + public RequestSimulatorMiddleware(OwinMiddleware next, string scheme, string host) : base(next) + { + this.scheme = scheme; + this.host = host; + } + + public override Task Invoke(IOwinContext context) + { + context.Request.Scheme = scheme; + context.Request.Host = new HostString(host); + return Next.Invoke(context); + } + } + + /// + /// Debug middleware that returns request information as JSON. + /// + public class HttpsDebugMiddleware : OwinMiddleware + { + public HttpsDebugMiddleware(OwinMiddleware next) : base(next) { } + + public override async Task Invoke(IOwinContext context) + { + if (context.Request.Path.ToString().Equals("/debug/https-info", System.StringComparison.OrdinalIgnoreCase)) + { + var response = context.Response; + response.ContentType = "application/json"; + + var scheme = context.Environment.TryGetValue("owin.RequestScheme", out var envScheme) + ? envScheme?.ToString() ?? context.Request.Scheme + : context.Request.Scheme; + + var host = context.Environment.TryGetValue("host.RequestHost", out var envHost) + ? envHost?.ToString() ?? context.Request.Host.ToString() + : context.Request.Host.ToString(); + + var json = $@"{{ + ""scheme"": ""{scheme}"", + ""host"": ""{host}"", + ""path"": ""{context.Request.Path}"", + ""queryString"": ""{context.Request.QueryString}"" +}}"; + + await response.WriteAsync(json).ConfigureAwait(false); + return; + } + + await Next.Invoke(context).ConfigureAwait(false); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_hsts_is_enabled.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_hsts_is_enabled.cs new file mode 100644 index 0000000000..3ee5d83b7b --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_hsts_is_enabled.cs @@ -0,0 +1,80 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.Https +{ + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests HSTS (HTTP Strict Transport Security) header behavior. + /// HSTS should only be added to HTTPS responses. + /// + [TestFixture] + public class When_hsts_is_enabled : HttpsTestBase + { + [Test] + public async Task Should_add_hsts_header_for_https_request() + { + HttpsTestStartup.ConfigureHstsEnabled(); + HttpsTestStartup.SimulatedScheme = "https"; + CreateServer(); + + var response = await SendRequest("/debug/https-info"); + response.EnsureSuccessStatusCode(); + + Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.True); + var hstsValue = GetHstsHeaderValue(response); + Assert.That(hstsValue, Does.Contain("max-age=31536000")); + } + + [Test] + public async Task Should_not_add_hsts_header_for_http_request() + { + HttpsTestStartup.ConfigureHstsEnabled(); + HttpsTestStartup.SimulatedScheme = "http"; + CreateServer(); + + var response = await SendRequest("/debug/https-info"); + response.EnsureSuccessStatusCode(); + + Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.False); + } + + [Test] + public async Task Should_use_custom_max_age() + { + HttpsTestStartup.ConfigureHstsEnabled(maxAgeSeconds: 86400); + HttpsTestStartup.SimulatedScheme = "https"; + CreateServer(); + + var response = await SendRequest("/debug/https-info"); + response.EnsureSuccessStatusCode(); + + Assert.That(GetHstsHeaderValue(response), Is.EqualTo("max-age=86400")); + } + + [Test] + public async Task Should_include_subdomains_when_configured() + { + HttpsTestStartup.ConfigureHstsEnabled(includeSubDomains: true); + HttpsTestStartup.SimulatedScheme = "https"; + CreateServer(); + + var response = await SendRequest("/debug/https-info"); + response.EnsureSuccessStatusCode(); + + Assert.That(GetHstsHeaderValue(response), Does.Contain("includeSubDomains")); + } + + [Test] + public async Task Should_not_include_subdomains_by_default() + { + HttpsTestStartup.ConfigureHstsEnabled(includeSubDomains: false); + HttpsTestStartup.SimulatedScheme = "https"; + CreateServer(); + + var response = await SendRequest("/debug/https-info"); + response.EnsureSuccessStatusCode(); + + Assert.That(GetHstsHeaderValue(response), Does.Not.Contain("includeSubDomains")); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_is_disabled.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_is_disabled.cs new file mode 100644 index 0000000000..006bf18e3a --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_is_disabled.cs @@ -0,0 +1,55 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.Https +{ + using System.Net; + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests behavior when HTTPS features are disabled. + /// No HSTS header should be added and no redirects should occur. + /// + [TestFixture] + public class When_https_is_disabled : HttpsTestBase + { + [SetUp] + public void SetUp() + { + HttpsTestStartup.ConfigureDisabled(); + CreateServer(); + } + + [Test] + public async Task Should_not_add_hsts_header() + { + HttpsTestStartup.SimulatedScheme = "https"; + + var response = await SendRequest("/debug/https-info"); + response.EnsureSuccessStatusCode(); + + Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.False); + } + + [Test] + public async Task Should_not_redirect_http_request() + { + HttpsTestStartup.SimulatedScheme = "http"; + + var response = await SendRequest("/debug/https-info"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(response.Headers.Location, Is.Null); + } + + [Test] + public async Task Should_pass_through_to_next_middleware() + { + HttpsTestStartup.SimulatedScheme = "http"; + + var response = await SendRequest("/debug/https-info"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + Assert.That(content, Does.Contain("\"scheme\": \"http\"")); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_redirect_is_enabled.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_redirect_is_enabled.cs new file mode 100644 index 0000000000..51c22ee60d --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_redirect_is_enabled.cs @@ -0,0 +1,140 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.Https +{ + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests HTTP to HTTPS redirect behavior. + /// HTTP requests should be redirected with 307 status code. + /// + [TestFixture] + public class When_https_redirect_is_enabled : HttpsTestBase + { + [Test] + public async Task Should_redirect_http_to_https() + { + HttpsTestStartup.ConfigureRedirectEnabled(); + HttpsTestStartup.SimulatedScheme = "http"; + HttpsTestStartup.SimulatedHost = "example.com"; + CreateServer(); + + var response = await SendRequest("/some/path", followRedirects: false); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com/some/path")); + } + + [Test] + public async Task Should_not_redirect_https_request() + { + HttpsTestStartup.ConfigureRedirectEnabled(); + HttpsTestStartup.SimulatedScheme = "https"; + CreateServer(); + + var response = await SendRequest("/debug/https-info"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task Should_include_custom_port_in_redirect() + { + HttpsTestStartup.ConfigureRedirectEnabled(httpsPort: 8443); + HttpsTestStartup.SimulatedScheme = "http"; + HttpsTestStartup.SimulatedHost = "example.com"; + CreateServer(); + + var response = await SendRequest("/test", followRedirects: false); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com:8443/test")); + } + + [Test] + public async Task Should_omit_default_https_port() + { + HttpsTestStartup.ConfigureRedirectEnabled(httpsPort: 443); + HttpsTestStartup.SimulatedScheme = "http"; + HttpsTestStartup.SimulatedHost = "example.com"; + CreateServer(); + + var response = await SendRequest("/test", followRedirects: false); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com/test")); + Assert.That(response.Headers.Location.ToString(), Does.Not.Contain(":443")); + } + + [Test] + public async Task Should_preserve_query_string() + { + HttpsTestStartup.ConfigureRedirectEnabled(); + HttpsTestStartup.SimulatedScheme = "http"; + HttpsTestStartup.SimulatedHost = "example.com"; + CreateServer(); + + var response = await SendRequest("/test?foo=bar&baz=qux", followRedirects: false); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com/test?foo=bar&baz=qux")); + } + + [Test] + public async Task Should_strip_port_from_host() + { + HttpsTestStartup.ConfigureRedirectEnabled(); + HttpsTestStartup.SimulatedScheme = "http"; + HttpsTestStartup.SimulatedHost = "example.com:8080"; + CreateServer(); + + var response = await SendRequest("/test", followRedirects: false); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://example.com/test")); + } + + [Test] + public async Task Should_use_307_temporary_redirect() + { + HttpsTestStartup.ConfigureRedirectEnabled(); + HttpsTestStartup.SimulatedScheme = "http"; + CreateServer(); + + var response = await SendRequest("/api/data", method: HttpMethod.Post, followRedirects: false); + + // 307 preserves the HTTP method (important for POST, PUT, etc.) + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + } + + [Test] + public async Task Should_handle_ipv6_host_in_redirect() + { + HttpsTestStartup.ConfigureRedirectEnabled(); + HttpsTestStartup.SimulatedScheme = "http"; + HttpsTestStartup.SimulatedHost = "[::1]"; + CreateServer(); + + var response = await SendRequest("/test", followRedirects: false); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location.ToString(), Does.StartWith("https://[::1]")); + } + + [Test] + public async Task Should_strip_port_from_ipv6_host() + { + HttpsTestStartup.ConfigureRedirectEnabled(); + HttpsTestStartup.SimulatedScheme = "http"; + HttpsTestStartup.SimulatedHost = "[::1]:8080"; + CreateServer(); + + var response = await SendRequest("/test", followRedirects: false); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location.ToString(), Does.StartWith("https://[::1]")); + Assert.That(response.Headers.Location.ToString(), Does.Not.Contain(":8080")); + } + } +} diff --git a/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_with_forwarded_headers.cs b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_with_forwarded_headers.cs new file mode 100644 index 0000000000..a1484e262e --- /dev/null +++ b/src/ServicePulse.Host.Tests/AcceptanceTests/Https/When_https_with_forwarded_headers.cs @@ -0,0 +1,96 @@ +namespace ServicePulse.Host.Tests.AcceptanceTests.Https +{ + using System.Net; + using System.Threading.Tasks; + using NUnit.Framework; + + /// + /// Tests HTTPS middleware behavior when used with forwarded headers. + /// This is the reverse proxy scenario where the proxy terminates SSL + /// and forwards requests to the application over HTTP. + /// + [TestFixture] + public class When_https_with_forwarded_headers : HttpsTestBase + { + [SetUp] + public void SetUp() + { + HttpsTestStartup.ConfigureWithForwardedHeaders(); + CreateServer(); + } + + [Test] + public async Task Should_add_hsts_when_forwarded_proto_is_https() + { + HttpsTestStartup.SimulatedScheme = "http"; // Original request is HTTP + + var response = await SendRequest("/debug/https-info", forwardedProto: "https"); + response.EnsureSuccessStatusCode(); + + Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.True); + } + + [Test] + public async Task Should_not_redirect_when_forwarded_proto_is_https() + { + HttpsTestStartup.SimulatedScheme = "http"; // Original request is HTTP + + var response = await SendRequest("/debug/https-info", forwardedProto: "https"); + + // Should NOT redirect because effective scheme is HTTPS + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task Should_redirect_when_forwarded_proto_is_http() + { + HttpsTestStartup.SimulatedScheme = "http"; + + var response = await SendRequest("/test", forwardedProto: "http", followRedirects: false); + + // Should redirect because effective scheme is HTTP + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + } + + [Test] + public async Task Should_use_forwarded_host_in_redirect() + { + HttpsTestStartup.SimulatedScheme = "http"; + HttpsTestStartup.SimulatedHost = "internal-server"; + + var response = await SendRequest("/test", + forwardedProto: "http", + forwardedHost: "public.example.com", + followRedirects: false); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + // Should use the forwarded host, not the original + Assert.That(response.Headers.Location.ToString(), Is.EqualTo("https://public.example.com/test")); + } + + [Test] + public async Task Should_not_add_hsts_when_forwarded_proto_is_http() + { + HttpsTestStartup.SimulatedScheme = "http"; + + var response = await SendRequest("/debug/https-info", forwardedProto: "http", followRedirects: false); + + // Will redirect, but if we could check the pre-redirect response, + // it should not have HSTS since the effective scheme is HTTP + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + } + + [TestCase("HTTPS")] + [TestCase("Https")] + [TestCase("hTtPs")] + public async Task Should_handle_case_insensitive_forwarded_proto(string proto) + { + HttpsTestStartup.SimulatedScheme = "http"; + + var response = await SendRequest("/debug/https-info", forwardedProto: proto); + + // Should not redirect - HTTPS proto handling should be case-insensitive + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + } +} diff --git a/src/ServicePulse.Host.Tests/CommandLineTests/ForwardedHeadersArgumentsTests.cs b/src/ServicePulse.Host.Tests/CommandLineTests/ForwardedHeadersArgumentsTests.cs new file mode 100644 index 0000000000..75bb36abb2 --- /dev/null +++ b/src/ServicePulse.Host.Tests/CommandLineTests/ForwardedHeadersArgumentsTests.cs @@ -0,0 +1,183 @@ +namespace ServicePulse.Host.Tests.CommandLineTests +{ + using System; + using NUnit.Framework; + using ServicePulse.Host.Hosting; + + [TestFixture] + public class ForwardedHeadersArgumentsTests + { + [Test] + public void ForwardedHeadersEnabled_Default_Is_True() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.ForwardedHeadersEnabled, Is.True); + } + + [Test] + public void ForwardedHeadersEnabled_Can_Be_Set_To_False() + { + var args = new[] { "--forwardedheadersenabled=false" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersEnabled, Is.False); + } + + [Test] + public void ForwardedHeadersEnabled_Can_Be_Set_To_True() + { + var args = new[] { "--forwardedheadersenabled=true" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersEnabled, Is.True); + } + + [Test] + public void ForwardedHeadersEnabled_Invalid_Value_Uses_Default() + { + var args = new[] { "--forwardedheadersenabled=invalid" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersEnabled, Is.True); + } + + [Test] + public void ForwardedHeadersTrustAllProxies_Default_Is_True() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.ForwardedHeadersTrustAllProxies, Is.True); + } + + [Test] + public void ForwardedHeadersTrustAllProxies_Can_Be_Set_To_False() + { + var args = new[] { "--forwardedheaderstrustallproxies=false" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersTrustAllProxies, Is.False); + } + + [Test] + public void ForwardedHeadersTrustAllProxies_Can_Be_Set_To_True() + { + var args = new[] { "--forwardedheaderstrustallproxies=true" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersTrustAllProxies, Is.True); + } + + [Test] + public void ForwardedHeadersKnownProxies_Default_Is_Empty() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.ForwardedHeadersKnownProxies, Is.Empty); + } + + [Test] + public void ForwardedHeadersKnownProxies_Can_Be_Set_Single_IP() + { + var args = new[] { "--forwardedheadersknownproxies=192.168.1.1" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(1)); + Assert.That(hostArgs.ForwardedHeadersKnownProxies[0], Is.EqualTo("192.168.1.1")); + } + + [Test] + public void ForwardedHeadersKnownProxies_Can_Be_Set_Multiple_IPs_Comma_Separated() + { + var args = new[] { "--forwardedheadersknownproxies=192.168.1.1,10.0.0.1,172.16.0.1" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(3)); + Assert.That(hostArgs.ForwardedHeadersKnownProxies[0], Is.EqualTo("192.168.1.1")); + Assert.That(hostArgs.ForwardedHeadersKnownProxies[1], Is.EqualTo("10.0.0.1")); + Assert.That(hostArgs.ForwardedHeadersKnownProxies[2], Is.EqualTo("172.16.0.1")); + } + + [Test] + public void ForwardedHeadersKnownProxies_Can_Be_Set_Multiple_IPs_Semicolon_Separated() + { + var args = new[] { "--forwardedheadersknownproxies=192.168.1.1;10.0.0.1" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(2)); + Assert.That(hostArgs.ForwardedHeadersKnownProxies[0], Is.EqualTo("192.168.1.1")); + Assert.That(hostArgs.ForwardedHeadersKnownProxies[1], Is.EqualTo("10.0.0.1")); + } + + [Test] + public void ForwardedHeadersKnownProxies_Trims_Whitespace() + { + var args = new[] { "--forwardedheadersknownproxies= 192.168.1.1 , 10.0.0.1 " }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(2)); + Assert.That(hostArgs.ForwardedHeadersKnownProxies[0], Is.EqualTo("192.168.1.1")); + Assert.That(hostArgs.ForwardedHeadersKnownProxies[1], Is.EqualTo("10.0.0.1")); + } + + [Test] + public void ForwardedHeadersKnownProxies_Empty_String_Returns_Empty_List() + { + var args = new[] { "--forwardedheadersknownproxies=" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersKnownProxies, Is.Empty); + } + + [Test] + public void ForwardedHeadersKnownNetworks_Default_Is_Empty() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.ForwardedHeadersKnownNetworks, Is.Empty); + } + + [Test] + public void ForwardedHeadersKnownNetworks_Can_Be_Set_Single_CIDR() + { + var args = new[] { "--forwardedheadersknownnetworks=192.168.1.0/24" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersKnownNetworks, Has.Count.EqualTo(1)); + Assert.That(hostArgs.ForwardedHeadersKnownNetworks[0], Is.EqualTo("192.168.1.0/24")); + } + + [Test] + public void ForwardedHeadersKnownNetworks_Can_Be_Set_Multiple_CIDRs() + { + var args = new[] { "--forwardedheadersknownnetworks=192.168.1.0/24,10.0.0.0/8" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersKnownNetworks, Has.Count.EqualTo(2)); + Assert.That(hostArgs.ForwardedHeadersKnownNetworks[0], Is.EqualTo("192.168.1.0/24")); + Assert.That(hostArgs.ForwardedHeadersKnownNetworks[1], Is.EqualTo("10.0.0.0/8")); + } + + [Test] + public void ForwardedHeaders_All_Options_Can_Be_Combined() + { + var args = new[] + { + "--forwardedheadersenabled=true", + "--forwardedheaderstrustallproxies=false", + "--forwardedheadersknownproxies=192.168.1.1,10.0.0.1", + "--forwardedheadersknownnetworks=192.168.0.0/16" + }; + var hostArgs = new HostArguments(args); + + Assert.That(hostArgs.ForwardedHeadersEnabled, Is.True); + Assert.That(hostArgs.ForwardedHeadersTrustAllProxies, Is.False); + Assert.That(hostArgs.ForwardedHeadersKnownProxies, Has.Count.EqualTo(2)); + Assert.That(hostArgs.ForwardedHeadersKnownNetworks, Has.Count.EqualTo(1)); + } + + [TestCase("--FORWARDEDHEADERSENABLED=false")] + [TestCase("--ForwardedHeadersEnabled=false")] + [TestCase("--forwardedheadersenabled=false")] + public void ForwardedHeaders_Argument_Names_Are_Case_Insensitive(string arg) + { + var args = new[] { arg }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersEnabled, Is.False); + } + + [TestCase("FALSE")] + [TestCase("False")] + [TestCase("false")] + public void ForwardedHeadersEnabled_Values_Are_Case_Insensitive(string value) + { + var args = new[] { $"--forwardedheadersenabled={value}" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.ForwardedHeadersEnabled, Is.False); + } + } +} diff --git a/src/ServicePulse.Host.Tests/CommandLineTests/HttpsArgumentsTests.cs b/src/ServicePulse.Host.Tests/CommandLineTests/HttpsArgumentsTests.cs new file mode 100644 index 0000000000..92dd8eefbe --- /dev/null +++ b/src/ServicePulse.Host.Tests/CommandLineTests/HttpsArgumentsTests.cs @@ -0,0 +1,190 @@ +namespace ServicePulse.Host.Tests.CommandLineTests +{ + using System; + using NUnit.Framework; + using ServicePulse.Host.Hosting; + + [TestFixture] + public class HttpsArgumentsTests + { + [Test] + public void HttpsEnabled_Default_Is_False() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.HttpsEnabled, Is.False); + } + + [Test] + public void HttpsEnabled_Can_Be_Set_To_True() + { + var args = new[] { "--httpsenabled=true" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsEnabled, Is.True); + } + + [Test] + public void HttpsEnabled_Can_Be_Set_To_False() + { + var args = new[] { "--httpsenabled=false" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsEnabled, Is.False); + } + + [Test] + public void HttpsEnabled_Invalid_Value_Uses_Default() + { + var args = new[] { "--httpsenabled=invalid" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsEnabled, Is.False); + } + + [Test] + public void HttpsRedirectHttpToHttps_Default_Is_False() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.HttpsRedirectHttpToHttps, Is.False); + } + + [Test] + public void HttpsRedirectHttpToHttps_Can_Be_Set_To_True() + { + var args = new[] { "--httpsredirecthttptohttps=true" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsRedirectHttpToHttps, Is.True); + } + + [Test] + public void HttpsRedirectHttpToHttps_Can_Be_Set_To_False() + { + var args = new[] { "--httpsredirecthttptohttps=false" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsRedirectHttpToHttps, Is.False); + } + + [Test] + public void HttpsPort_Default_Is_Null() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.HttpsPort, Is.Null); + } + + [Test] + public void HttpsPort_Can_Be_Set() + { + var args = new[] { "--httpsport=443" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsPort, Is.EqualTo(443)); + } + + [Test] + public void HttpsPort_Custom_Value() + { + var args = new[] { "--httpsport=8443" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsPort, Is.EqualTo(8443)); + } + + [Test] + public void HttpsPort_Invalid_Value_Returns_Null() + { + var args = new[] { "--httpsport=invalid" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsPort, Is.Null); + } + + [Test] + public void HttpsEnableHsts_Default_Is_False() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.HttpsEnableHsts, Is.False); + } + + [Test] + public void HttpsEnableHsts_Can_Be_Set_To_True() + { + var args = new[] { "--httpsenablehsts=true" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsEnableHsts, Is.True); + } + + [Test] + public void HttpsHstsMaxAgeSeconds_Default_Is_OneYear() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.HttpsHstsMaxAgeSeconds, Is.EqualTo(31536000)); + } + + [Test] + public void HttpsHstsMaxAgeSeconds_Can_Be_Set() + { + var args = new[] { "--httpshstsmaxageseconds=86400" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsHstsMaxAgeSeconds, Is.EqualTo(86400)); + } + + [Test] + public void HttpsHstsMaxAgeSeconds_Invalid_Value_Uses_Default() + { + var args = new[] { "--httpshstsmaxageseconds=invalid" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsHstsMaxAgeSeconds, Is.EqualTo(31536000)); + } + + [Test] + public void HttpsHstsIncludeSubDomains_Default_Is_False() + { + var hostArgs = new HostArguments(Array.Empty()); + Assert.That(hostArgs.HttpsHstsIncludeSubDomains, Is.False); + } + + [Test] + public void HttpsHstsIncludeSubDomains_Can_Be_Set_To_True() + { + var args = new[] { "--httpshstsincludesubdomains=true" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsHstsIncludeSubDomains, Is.True); + } + + [Test] + public void Https_All_Options_Can_Be_Combined() + { + var args = new[] + { + "--httpsenabled=true", + "--httpsredirecthttptohttps=true", + "--httpsport=443", + "--httpsenablehsts=true", + "--httpshstsmaxageseconds=86400", + "--httpshstsincludesubdomains=true" + }; + var hostArgs = new HostArguments(args); + + Assert.That(hostArgs.HttpsEnabled, Is.True); + Assert.That(hostArgs.HttpsRedirectHttpToHttps, Is.True); + Assert.That(hostArgs.HttpsPort, Is.EqualTo(443)); + Assert.That(hostArgs.HttpsEnableHsts, Is.True); + Assert.That(hostArgs.HttpsHstsMaxAgeSeconds, Is.EqualTo(86400)); + Assert.That(hostArgs.HttpsHstsIncludeSubDomains, Is.True); + } + + [TestCase("TRUE")] + [TestCase("True")] + [TestCase("true")] + public void HttpsEnabled_Is_Case_Insensitive(string value) + { + var args = new[] { $"--httpsenabled={value}" }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsEnabled, Is.True); + } + + [TestCase("--HTTPSENABLED=true")] + [TestCase("--HttpsEnabled=true")] + [TestCase("--httpsenabled=true")] + public void Https_Argument_Names_Are_Case_Insensitive(string arg) + { + var args = new[] { arg }; + var hostArgs = new HostArguments(args); + Assert.That(hostArgs.HttpsEnabled, Is.True); + } + } +} diff --git a/src/ServicePulse.Host.Tests/ServicePulse.Host.Tests.csproj b/src/ServicePulse.Host.Tests/ServicePulse.Host.Tests.csproj index 6f6f046605..b94d53c8ae 100644 --- a/src/ServicePulse.Host.Tests/ServicePulse.Host.Tests.csproj +++ b/src/ServicePulse.Host.Tests/ServicePulse.Host.Tests.csproj @@ -11,10 +11,11 @@ + - + diff --git a/src/ServicePulse.Host/Hosting/Host.cs b/src/ServicePulse.Host/Hosting/Host.cs index 73ffb7fd09..e102963455 100644 --- a/src/ServicePulse.Host/Hosting/Host.cs +++ b/src/ServicePulse.Host/Hosting/Host.cs @@ -1,6 +1,7 @@ -namespace ServicePulse.Host.Hosting +namespace ServicePulse.Host.Hosting { using System; + using System.Net; using System.ServiceProcess; using Microsoft.Owin.Hosting; using ServicePulse.Host.Owin; @@ -25,10 +26,75 @@ public void Run() protected override void OnStart(string[] args) { + ConfigureForwardedHeaders(); + ConfigureHttps(); + +#if DEBUG + // Enable debug endpoint when running interactively in debug builds + OwinBootstrapper.EnableDebugEndpoint = Environment.UserInteractive; +#endif + var hostingUrl = UrlHelper.RewriteLocalhostUrl(arguments.Url); owinHost = WebApp.Start(hostingUrl); } + void ConfigureForwardedHeaders() + { + var options = new ForwardedHeadersOptions + { + Enabled = arguments.ForwardedHeadersEnabled, + TrustAllProxies = arguments.ForwardedHeadersTrustAllProxies + }; + + // Parse known proxies + foreach (var proxyString in arguments.ForwardedHeadersKnownProxies) + { + if (IPAddress.TryParse(proxyString, out var proxy)) + { + options.KnownProxies.Add(proxy); + } + } + + // Parse known networks + foreach (var networkString in arguments.ForwardedHeadersKnownNetworks) + { + if (CidrNetwork.TryParse(networkString, out var network)) + { + options.KnownNetworks.Add(network); + } + } + + // If specific proxies or networks are configured, disable trust all proxies + if (options.KnownProxies.Count > 0 || options.KnownNetworks.Count > 0) + { + options.TrustAllProxies = false; + } + + // Set ForwardLimit based on TrustAllProxies (align with ASP.NET Core behavior) + if (options.TrustAllProxies) + { + options.ForwardLimit = null; // No limit - process all entries + } + // else ForwardLimit defaults to 1 + + OwinBootstrapper.ForwardedHeadersOptions = options; + } + + void ConfigureHttps() + { + var options = new HttpsOptions + { + Enabled = arguments.HttpsEnabled, + RedirectHttpToHttps = arguments.HttpsRedirectHttpToHttps, + Port = arguments.HttpsPort, + EnableHsts = arguments.HttpsEnableHsts, + HstsMaxAgeSeconds = arguments.HttpsHstsMaxAgeSeconds, + HstsIncludeSubDomains = arguments.HttpsHstsIncludeSubDomains + }; + + OwinBootstrapper.HttpsOptions = options; + } + protected override void OnStop() { owinHost.Dispose(); diff --git a/src/ServicePulse.Host/Hosting/HostArguments.cs b/src/ServicePulse.Host/Hosting/HostArguments.cs index 563157bc1c..0a7dc0d000 100644 --- a/src/ServicePulse.Host/Hosting/HostArguments.cs +++ b/src/ServicePulse.Host/Hosting/HostArguments.cs @@ -38,6 +38,56 @@ public HostArguments(string[] args) "url=", @"Configures ServicePulse to listen on the specified url.", s => { Url = s; } + }, + { + "forwardedheadersenabled=", + @"Enable processing of forwarded headers (default: true).", + s => { ForwardedHeadersEnabled = ParseBool(s, true); } + }, + { + "forwardedheaderstrustallproxies=", + @"Trust all proxies for forwarded headers (default: true).", + s => { ForwardedHeadersTrustAllProxies = ParseBool(s, true); } + }, + { + "forwardedheadersknownproxies=", + @"Comma-separated list of trusted proxy IP addresses.", + s => { ForwardedHeadersKnownProxies = ParseList(s); } + }, + { + "forwardedheadersknownnetworks=", + @"Comma-separated list of trusted proxy networks in CIDR notation.", + s => { ForwardedHeadersKnownNetworks = ParseList(s); } + }, + { + "httpsenabled=", + @"Enable HTTPS features (default: false). Note: SSL certificate must be bound at OS level using netsh.", + s => { HttpsEnabled = ParseBool(s, false); } + }, + { + "httpsredirecthttptohttps=", + @"Redirect HTTP requests to HTTPS (default: false).", + s => { HttpsRedirectHttpToHttps = ParseBool(s, false); } + }, + { + "httpsport=", + @"HTTPS port for redirect (required for reverse proxy scenarios).", + s => { HttpsPort = ParseNullableInt(s); } + }, + { + "httpsenablehsts=", + @"Enable HTTP Strict Transport Security (default: false).", + s => { HttpsEnableHsts = ParseBool(s, false); } + }, + { + "httpshstsmaxageseconds=", + @"HSTS max age in seconds (default: 31536000 = 1 year).", + s => { HttpsHstsMaxAgeSeconds = ParseInt(s, 31536000); } + }, + { + "httpshstsincludesubdomains=", + @"Include subdomains in HSTS policy (default: false).", + s => { HttpsHstsIncludeSubDomains = ParseBool(s, false); } } }; @@ -232,6 +282,46 @@ void ThrowIfUnknownArgs(List unknownArgsList) } } + static bool ParseBool(string value, bool defaultValue) + { + if (bool.TryParse(value, out var result)) + { + return result; + } + return defaultValue; + } + + static int ParseInt(string value, int defaultValue) + { + if (int.TryParse(value, out var result)) + { + return result; + } + return defaultValue; + } + + static int? ParseNullableInt(string value) + { + if (int.TryParse(value, out var result)) + { + return result; + } + return null; + } + + static List ParseList(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new List(); + } + + return value.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); + } + void ValidateArgs() { var validProtocols = new[] @@ -289,6 +379,20 @@ void ValidateArgs() public string ServiceControlMonitoringUrl { get; set; } + // Forwarded Headers settings + public bool ForwardedHeadersEnabled { get; set; } = true; + public bool ForwardedHeadersTrustAllProxies { get; set; } = true; + public List ForwardedHeadersKnownProxies { get; set; } = new List(); + public List ForwardedHeadersKnownNetworks { get; set; } = new List(); + + // HTTPS settings (certificate is bound at OS level via netsh, not in app) + public bool HttpsEnabled { get; set; } = false; + public bool HttpsRedirectHttpToHttps { get; set; } = false; + public int? HttpsPort { get; set; } = null; + public bool HttpsEnableHsts { get; set; } = false; + public int HttpsHstsMaxAgeSeconds { get; set; } = 31536000; + public bool HttpsHstsIncludeSubDomains { get; set; } = false; + public string DisplayName { get; set; } public string Description { get; set; } diff --git a/src/ServicePulse.Host/Owin/DebugRequestInfoMiddleware.cs b/src/ServicePulse.Host/Owin/DebugRequestInfoMiddleware.cs new file mode 100644 index 0000000000..83ac800a63 --- /dev/null +++ b/src/ServicePulse.Host/Owin/DebugRequestInfoMiddleware.cs @@ -0,0 +1,86 @@ +namespace ServicePulse.Host.Owin +{ + using System; + using System.Linq; + using System.Threading.Tasks; + using global::Microsoft.Owin; + using global::Owin; + + public class DebugRequestInfoMiddleware : OwinMiddleware + { + readonly ForwardedHeadersOptions options; + + public DebugRequestInfoMiddleware(OwinMiddleware next, ForwardedHeadersOptions options) : base(next) + { + this.options = options; + } + + public override async Task Invoke(IOwinContext context) + { + if (context.Request.Path.ToString().Equals("/debug/request-info", StringComparison.OrdinalIgnoreCase)) + { + var response = context.Response; + response.ContentType = "application/json"; + + // Check for forwarded values in environment, fall back to request properties + var remoteIpAddress = context.Environment.TryGetValue("server.RemoteIpAddress", out var forwardedIp) + ? forwardedIp?.ToString() ?? context.Request.RemoteIpAddress ?? "unknown" + : context.Request.RemoteIpAddress ?? "unknown"; + + var scheme = context.Environment.TryGetValue("owin.RequestScheme", out var forwardedScheme) + ? forwardedScheme?.ToString() ?? context.Request.Scheme ?? "unknown" + : context.Request.Scheme ?? "unknown"; + + var host = context.Environment.TryGetValue("host.RequestHost", out var forwardedHost) + ? forwardedHost?.ToString() ?? context.Request.Host.ToString() + : context.Request.Host.ToString(); + + // Raw headers (what remains after middleware processing) + var xForwardedFor = context.Request.Headers.Get("X-Forwarded-For") ?? ""; + var xForwardedProto = context.Request.Headers.Get("X-Forwarded-Proto") ?? ""; + var xForwardedHost = context.Request.Headers.Get("X-Forwarded-Host") ?? ""; + + // Configuration + var knownProxies = string.Join(", ", options.KnownProxies.Select(p => $"\"{p}\"")); + var knownNetworks = string.Join(", ", options.KnownNetworks.Select(n => $"\"{n.BaseAddress}/{n.PrefixLength}\"")); + + var json = $@"{{ + ""processed"": {{ + ""scheme"": ""{scheme}"", + ""host"": ""{host}"", + ""remoteIpAddress"": ""{remoteIpAddress}"" + }}, + ""rawHeaders"": {{ + ""xForwardedFor"": ""{xForwardedFor}"", + ""xForwardedProto"": ""{xForwardedProto}"", + ""xForwardedHost"": ""{xForwardedHost}"" + }}, + ""configuration"": {{ + ""enabled"": {options.Enabled.ToString().ToLowerInvariant()}, + ""trustAllProxies"": {options.TrustAllProxies.ToString().ToLowerInvariant()}, + ""knownProxies"": [{knownProxies}], + ""knownNetworks"": [{knownNetworks}] + }} +}}"; + + await response.WriteAsync(json).ConfigureAwait(false); + return; + } + + await Next.Invoke(context).ConfigureAwait(false); + } + } + + public static class DebugRequestInfoExtensions + { + public static IAppBuilder UseDebugRequestInfo(this IAppBuilder builder, ForwardedHeadersOptions options) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.Use(options); + } + } +} diff --git a/src/ServicePulse.Host/Owin/ForwardedHeadersMiddleware.cs b/src/ServicePulse.Host/Owin/ForwardedHeadersMiddleware.cs new file mode 100644 index 0000000000..56c8ee36d3 --- /dev/null +++ b/src/ServicePulse.Host/Owin/ForwardedHeadersMiddleware.cs @@ -0,0 +1,316 @@ +namespace ServicePulse.Host.Owin +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net; + using System.Threading.Tasks; + using global::Microsoft.Owin; + using global::Owin; + + public class ForwardedHeadersMiddleware : OwinMiddleware + { + readonly ForwardedHeadersOptions options; + + public ForwardedHeadersMiddleware(OwinMiddleware next, ForwardedHeadersOptions options) : base(next) + { + this.options = options; + } + + public override Task Invoke(IOwinContext context) + { + if (!options.Enabled) + { + return Next.Invoke(context); + } + + var request = context.Request; + + if (!IsTrustedProxy(request.RemoteIpAddress)) + { + return Next.Invoke(context); + } + + // Process X-Forwarded-Proto (take leftmost value - original client's scheme) + var forwardedProto = request.Headers.Get("X-Forwarded-Proto"); + if (!string.IsNullOrEmpty(forwardedProto)) + { + var values = forwardedProto.Split(',').Select(v => v.Trim()).ToList(); + var scheme = values[0]; + context.Environment["owin.RequestScheme"] = scheme; + + // Consume the header - when TrustAllProxies, consume all values (consistent with ASP.NET Core) + if (options.TrustAllProxies) + { + request.Headers.Remove("X-Forwarded-Proto"); + } + else + { + values.RemoveAt(0); + if (values.Count > 0) + { + request.Headers.Set("X-Forwarded-Proto", string.Join(", ", values)); + } + else + { + request.Headers.Remove("X-Forwarded-Proto"); + } + } + } + + // Process X-Forwarded-Host (take leftmost value - original client's host) + var forwardedHost = request.Headers.Get("X-Forwarded-Host"); + if (!string.IsNullOrEmpty(forwardedHost)) + { + var values = forwardedHost.Split(',').Select(v => v.Trim()).ToList(); + var host = values[0]; + context.Environment["host.RequestHost"] = host; + request.Headers.Set("Host", host); + + // Consume the header - when TrustAllProxies, consume all values (consistent with ASP.NET Core) + if (options.TrustAllProxies) + { + request.Headers.Remove("X-Forwarded-Host"); + } + else + { + values.RemoveAt(0); + if (values.Count > 0) + { + request.Headers.Set("X-Forwarded-Host", string.Join(", ", values)); + } + else + { + request.Headers.Remove("X-Forwarded-Host"); + } + } + } + + // Process X-Forwarded-For (right-to-left with ForwardLimit, consume processed entries) + var forwardedFor = request.Headers.Get("X-Forwarded-For"); + if (!string.IsNullOrEmpty(forwardedFor)) + { + var entries = forwardedFor.Split(',').Select(v => v.Trim()).ToList(); + var entriesProcessed = 0; + // When TrustAllProxies is enabled, ignore ForwardLimit (consistent with ASP.NET Core) + var limit = options.TrustAllProxies ? int.MaxValue : (options.ForwardLimit ?? int.MaxValue); + + // Process from right to left + while (entries.Count > 0 && entriesProcessed < limit) + { + var currentEntry = entries[entries.Count - 1]; + entries.RemoveAt(entries.Count - 1); + entriesProcessed++; + + // Strip port from IP address if present (e.g., "192.168.1.1:8080" or "[::1]:8080") + var currentIp = StripPort(currentEntry); + context.Environment["server.RemoteIpAddress"] = currentIp; + + // If there are more entries, check if we should continue + if (entries.Count > 0 && entriesProcessed < limit) + { + // For TrustAllProxies, continue processing all + // For known proxies/networks, check if current IP is trusted + if (!options.TrustAllProxies && !IsTrustedIp(currentIp)) + { + break; + } + } + } + + // Update header with remaining entries + if (entries.Count > 0) + { + request.Headers.Set("X-Forwarded-For", string.Join(", ", entries)); + } + else + { + request.Headers.Remove("X-Forwarded-For"); + } + } + + return Next.Invoke(context); + } + + static string StripPort(string ipAddress) + { + if (string.IsNullOrEmpty(ipAddress)) + { + return ipAddress; + } + + // IPv6 addresses are enclosed in brackets: [::1] or [::1]:8080 + if (ipAddress.StartsWith("[")) + { + var closingBracket = ipAddress.IndexOf(']'); + if (closingBracket > 0) + { + // Return just the IPv6 address without brackets or port + return ipAddress.Substring(1, closingBracket - 1); + } + // Malformed, return as-is + return ipAddress; + } + + // IPv4 or hostname: look for port separator + var colonIndex = ipAddress.IndexOf(':'); + return colonIndex > 0 ? ipAddress.Substring(0, colonIndex) : ipAddress; + } + + bool IsTrustedProxy(string remoteIpAddress) + { + if (options.TrustAllProxies) + { + return true; + } + + return IsTrustedIp(remoteIpAddress); + } + + bool IsTrustedIp(string ipAddress) + { + if (string.IsNullOrEmpty(ipAddress)) + { + return false; + } + + if (!IPAddress.TryParse(ipAddress, out var ip)) + { + return false; + } + + // Normalize IPv4-mapped IPv6 addresses (::ffff:192.168.1.1) to IPv4 + if (ip.IsIPv4MappedToIPv6) + { + ip = ip.MapToIPv4(); + } + + // Check known proxies (also normalizing for comparison) + foreach (var proxy in options.KnownProxies) + { + var normalizedProxy = proxy.IsIPv4MappedToIPv6 ? proxy.MapToIPv4() : proxy; + if (normalizedProxy.Equals(ip)) + { + return true; + } + } + + // Check known networks + foreach (var network in options.KnownNetworks) + { + if (network.Contains(ip)) + { + return true; + } + } + + return false; + } + } + + public class ForwardedHeadersOptions + { + public bool Enabled { get; set; } = true; + public bool TrustAllProxies { get; set; } = true; + public int? ForwardLimit { get; set; } = 1; + public List KnownProxies { get; set; } = new List(); + public List KnownNetworks { get; set; } = new List(); + } + + public class CidrNetwork + { + public IPAddress BaseAddress { get; } + public int PrefixLength { get; } + + public CidrNetwork(IPAddress baseAddress, int prefixLength) + { + BaseAddress = baseAddress; + PrefixLength = prefixLength; + } + + public static bool TryParse(string value, out CidrNetwork network) + { + network = null; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var parts = value.Split('/'); + if (parts.Length != 2) + { + return false; + } + + if (!IPAddress.TryParse(parts[0], out var address)) + { + return false; + } + + if (!int.TryParse(parts[1], out var prefixLength)) + { + return false; + } + + if (prefixLength < 0 || prefixLength > (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128)) + { + return false; + } + + network = new CidrNetwork(address, prefixLength); + return true; + } + + public bool Contains(IPAddress address) + { + if (address.AddressFamily != BaseAddress.AddressFamily) + { + return false; + } + + var baseBytes = BaseAddress.GetAddressBytes(); + var addressBytes = address.GetAddressBytes(); + + var wholeBytes = PrefixLength / 8; + var remainingBits = PrefixLength % 8; + + for (var i = 0; i < wholeBytes; i++) + { + if (baseBytes[i] != addressBytes[i]) + { + return false; + } + } + + if (remainingBits > 0 && wholeBytes < baseBytes.Length) + { + var mask = (byte)(0xFF << (8 - remainingBits)); + if ((baseBytes[wholeBytes] & mask) != (addressBytes[wholeBytes] & mask)) + { + return false; + } + } + + return true; + } + } + + public static class ForwardedHeadersExtensions + { + public static IAppBuilder UseForwardedHeaders(this IAppBuilder builder, ForwardedHeadersOptions options) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + return builder.Use(options); + } + } +} diff --git a/src/ServicePulse.Host/Owin/HttpsMiddleware.cs b/src/ServicePulse.Host/Owin/HttpsMiddleware.cs new file mode 100644 index 0000000000..9101edaf09 --- /dev/null +++ b/src/ServicePulse.Host/Owin/HttpsMiddleware.cs @@ -0,0 +1,143 @@ +namespace ServicePulse.Host.Owin +{ + using System; + using System.Threading.Tasks; + using global::Microsoft.Owin; + using global::Owin; + + public class HttpsMiddleware : OwinMiddleware + { + readonly HttpsOptions options; + + public HttpsMiddleware(OwinMiddleware next, HttpsOptions options) : base(next) + { + this.options = options; + } + + public override async Task Invoke(IOwinContext context) + { + // Skip if no HTTPS features are enabled + // Enabled=true activates all features (direct HTTPS scenario) + // Individual feature flags also activate the middleware (reverse proxy scenario) + if (!options.Enabled && !options.EnableHsts && !options.RedirectHttpToHttps) + { + await Next.Invoke(context).ConfigureAwait(false); + return; + } + + var request = context.Request; + var response = context.Response; + + // Get the effective scheme (may have been set by ForwardedHeadersMiddleware) + var scheme = context.Environment.TryGetValue("owin.RequestScheme", out var envScheme) + ? envScheme?.ToString() ?? request.Scheme + : request.Scheme; + + // Add HSTS header for HTTPS requests + if (options.EnableHsts && string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase)) + { + var hstsValue = $"max-age={options.HstsMaxAgeSeconds}"; + if (options.HstsIncludeSubDomains) + { + hstsValue += "; includeSubDomains"; + } + response.Headers.Set("Strict-Transport-Security", hstsValue); + } + + // Redirect HTTP to HTTPS + if (options.RedirectHttpToHttps && string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase)) + { + // Get the effective host (may have been set by ForwardedHeadersMiddleware) + var host = context.Environment.TryGetValue("host.RequestHost", out var envHost) + ? envHost?.ToString() ?? request.Host.ToString() + : request.Host.ToString(); + + // Skip redirect if host is empty or null (malformed request) + if (string.IsNullOrEmpty(host)) + { + await Next.Invoke(context).ConfigureAwait(false); + return; + } + + // Remove port from host if present, we'll add the HTTPS port + // IPv6 addresses are enclosed in brackets (e.g., [::1]:8080), so we need to handle them specially + var hostWithoutPort = GetHostWithoutPort(host); + + // Build the HTTPS URL with optional port + string httpsUrl; + if (options.Port.HasValue && options.Port.Value != 443) + { + httpsUrl = $"https://{hostWithoutPort}:{options.Port.Value}{request.PathBase}{request.Path}{request.QueryString}"; + } + else + { + httpsUrl = $"https://{hostWithoutPort}{request.PathBase}{request.Path}{request.QueryString}"; + } + + response.StatusCode = 307; // Temporary redirect (preserves method) + response.Headers.Set("Location", httpsUrl); + return; + } + + await Next.Invoke(context).ConfigureAwait(false); + } + + static string GetHostWithoutPort(string host) + { + if (string.IsNullOrEmpty(host)) + { + return host; + } + + // IPv6 addresses are enclosed in brackets: [::1] or [::1]:8080 + if (host.StartsWith("[")) + { + var closingBracket = host.IndexOf(']'); + if (closingBracket > 0) + { + // Return just the bracketed IPv6 address without any port + return host.Substring(0, closingBracket + 1); + } + // Malformed IPv6, return as-is + return host; + } + + // IPv4 or hostname: look for port separator + var colonIndex = host.IndexOf(':'); + return colonIndex > 0 ? host.Substring(0, colonIndex) : host; + } + } + + public class HttpsOptions + { + public bool Enabled { get; set; } = false; + public bool RedirectHttpToHttps { get; set; } = false; + public int? Port { get; set; } = null; + public bool EnableHsts { get; set; } = false; + public int HstsMaxAgeSeconds { get; set; } = 31536000; + public bool HstsIncludeSubDomains { get; set; } = false; + } + + public static class HttpsExtensions + { + public static IAppBuilder UseHttpsMiddleware(this IAppBuilder builder, HttpsOptions options) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (options.Port.HasValue && (options.Port.Value < 1 || options.Port.Value > 65535)) + { + throw new ArgumentOutOfRangeException(nameof(options), "Port must be between 1 and 65535."); + } + + return builder.Use(options); + } + } +} diff --git a/src/ServicePulse.Host/Owin/OwinBootstrapper.cs b/src/ServicePulse.Host/Owin/OwinBootstrapper.cs index 95ca100c85..40aa8065b0 100644 --- a/src/ServicePulse.Host/Owin/OwinBootstrapper.cs +++ b/src/ServicePulse.Host/Owin/OwinBootstrapper.cs @@ -1,11 +1,29 @@ -namespace ServicePulse.Host.Owin +namespace ServicePulse.Host.Owin { using global::Owin; public class OwinBootstrapper { + public static ForwardedHeadersOptions ForwardedHeadersOptions { get; set; } = new ForwardedHeadersOptions(); + public static HttpsOptions HttpsOptions { get; set; } = new HttpsOptions(); + public static bool EnableDebugEndpoint { get; set; } + public void Configuration(IAppBuilder app) { + // Forwarded headers must be first in the pipeline + app.UseForwardedHeaders(ForwardedHeadersOptions); + + // HTTPS middleware (HSTS and HTTP-to-HTTPS redirect) + app.UseHttpsMiddleware(HttpsOptions); + + // Debug endpoint for testing forwarded headers (only in debug builds) +#if DEBUG + if (EnableDebugEndpoint) + { + app.UseDebugRequestInfo(ForwardedHeadersOptions); + } +#endif + app.UseIndexUrlRewriter(); app.UseStaticFiles(); } diff --git a/src/ServicePulse.Host/ServicePulse.Host.csproj b/src/ServicePulse.Host/ServicePulse.Host.csproj index 2327fbfbf4..acbc561e9a 100644 --- a/src/ServicePulse.Host/ServicePulse.Host.csproj +++ b/src/ServicePulse.Host/ServicePulse.Host.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/ServicePulse.Tests/.editorconfig b/src/ServicePulse.Tests/.editorconfig new file mode 100644 index 0000000000..5527b268b4 --- /dev/null +++ b/src/ServicePulse.Tests/.editorconfig @@ -0,0 +1,16 @@ +[*.cs] + +# Justification: Test project +dotnet_diagnostic.CA2007.severity = none + +# Justification: Tests don't support cancellation and don't need to forward IMessageHandlerContext.CancellationToken +dotnet_diagnostic.NSB0002.severity = suggestion + +# Justification: Test middleware doesn't need cancellation token - it just sets RemoteIpAddress and calls next +dotnet_diagnostic.PS0018.severity = suggestion + +# Justification: Test project - collection initialization style less critical +dotnet_diagnostic.IDE0028.severity = suggestion + +# Justification: Test project - allow simplified collection expressions +dotnet_diagnostic.IDE0305.severity = suggestion diff --git a/src/ServicePulse.Tests/ForwardedHeaders/ForwardedHeadersTestBase.cs b/src/ServicePulse.Tests/ForwardedHeaders/ForwardedHeadersTestBase.cs new file mode 100644 index 0000000000..ea6f692974 --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/ForwardedHeadersTestBase.cs @@ -0,0 +1,47 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using System.Text.Json; +using ServicePulse.Tests.Infrastructure; + +/// +/// Base class for ForwardedHeaders tests providing common setup, teardown, and helper methods. +/// +public abstract class ForwardedHeadersTestBase +{ + protected ServicePulseWebApplicationFactory Factory = null!; + protected HttpClient Client = null!; + + [TearDown] + public void TearDown() + { + Client.Dispose(); + Factory.Dispose(); + } + + protected async Task SendRequestWithHeaders( + string? forwardedFor = null, + string? forwardedProto = null, + string? forwardedHost = null) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/debug/request-info"); + + if (forwardedFor != null) + { + request.Headers.Add("X-Forwarded-For", forwardedFor); + } + if (forwardedProto != null) + { + request.Headers.Add("X-Forwarded-Proto", forwardedProto); + } + if (forwardedHost != null) + { + request.Headers.Add("X-Forwarded-Host", forwardedHost); + } + + var response = await Client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content)!; + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs new file mode 100644 index 0000000000..e12089d732 --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_combined_proxies_and_networks_are_configured.cs @@ -0,0 +1,45 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when both known proxies and known networks are configured. +/// +[TestFixture] +public class When_combined_proxies_and_networks_are_configured : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + // Configure both a specific proxy and a network + Factory = TestConfiguration.CreateWithKnownProxiesAndNetworks( + ["10.0.0.1"], + ["127.0.0.0/8"]); + Client = Factory.CreateClient(); + } + + [Test] + public async Task Headers_should_be_processed_when_matching_network() + { + // Request comes from 127.0.0.1 which is in the known network + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should be processed since request comes from known network + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + Assert.That(result.Processed.Host, Is.EqualTo("example.com")); + } + + [Test] + public async Task Configuration_should_show_both_proxies_and_networks() + { + var result = await SendRequestWithHeaders(); + + Assert.That(result.Configuration.Enabled, Is.True); + Assert.That(result.Configuration.TrustAllProxies, Is.False); + Assert.That(result.Configuration.KnownProxies, Does.Contain("10.0.0.1")); + Assert.That(result.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_forward_limit_is_applied.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_forward_limit_is_applied.cs new file mode 100644 index 0000000000..17727ceb05 --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_forward_limit_is_applied.cs @@ -0,0 +1,82 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests ForwardLimit behavior which controls how many proxy hops are processed. +/// When TrustAllProxies=true, ForwardLimit is ignored (all entries processed). +/// When TrustAllProxies=false, ForwardLimit defaults to 1 (only rightmost entry processed). +/// +[TestFixture] +public class When_forward_limit_is_applied : ForwardedHeadersTestBase +{ + [Test] + public async Task Trust_all_proxies_should_process_entire_chain() + { + // TrustAllProxies=true means ForwardLimit=null (no limit) + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + forwardedProto: "https"); + + // Should process all entries and return the leftmost (original client) + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + + // All entries should be consumed + Assert.That(result.RawHeaders.XForwardedFor, Is.Empty); + } + + [Test] + public async Task Known_proxies_should_apply_forward_limit_of_one() + { + // TrustAllProxies=false means ForwardLimit=1 + Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1"); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + forwardedProto: "https"); + + // With ForwardLimit=1, should only process the rightmost entry + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("192.168.1.1")); + + // Remaining entries should stay in the header + Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50,10.0.0.1")); + } + + [Test] + public async Task Single_entry_should_be_processed_regardless_of_limit() + { + Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1"); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https"); + + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + Assert.That(result.RawHeaders.XForwardedFor, Is.Empty); + } + + [Test] + public async Task Proto_and_host_should_use_rightmost_value() + { + // Proto and host always use the rightmost (most recent proxy) value + Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1"); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50, 10.0.0.1", + forwardedProto: "https, http", + forwardedHost: "public.example.com, internal.example.com"); + + // Proto and host use rightmost value (most recent proxy) + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + Assert.That(result.Processed.Host, Is.EqualTo("internal.example.com")); + + // ForwardedFor also uses rightmost with ForwardLimit=1 + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("10.0.0.1")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_for_contains_ports.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_for_contains_ports.cs new file mode 100644 index 0000000000..d16924708e --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_for_contains_ports.cs @@ -0,0 +1,60 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when X-Forwarded-For header contains IP addresses with port numbers. +/// +[TestFixture] +public class When_forwarded_for_contains_ports : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + } + + [Test] + public async Task Should_strip_port_from_ipv4_address() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50:8080", + forwardedProto: "https"); + + // Port should be stripped, leaving just the IP + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Should_strip_port_from_ipv6_address() + { + var result = await SendRequestWithHeaders( + forwardedFor: "[2001:db8::1]:443", + forwardedProto: "https"); + + // Port and brackets should be stripped + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::1")); + } + + [Test] + public async Task Should_handle_mixed_ports_in_chain() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50:12345, 10.0.0.1:8080, 192.168.1.1", + forwardedProto: "https"); + + // Should use leftmost IP with port stripped + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Should_handle_address_without_port() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https"); + + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs new file mode 100644 index 0000000000..640e438061 --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_disabled.cs @@ -0,0 +1,44 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when forwarded headers processing is disabled. +/// +[TestFixture] +public class When_forwarded_headers_are_disabled : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDisabled(); + Client = Factory.CreateClient(); + } + + [Test] + public async Task Headers_should_not_be_processed() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should NOT be processed when disabled + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com")); + } + + [Test] + public async Task Headers_should_remain_unmodified() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should remain in request when disabled + Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50")); + Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https")); + Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_sent.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_sent.cs new file mode 100644 index 0000000000..70b23b00a5 --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_forwarded_headers_are_sent.cs @@ -0,0 +1,44 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests default behavior when forwarded headers are sent and all proxies are trusted. +/// +[TestFixture] +public class When_forwarded_headers_are_sent : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + } + + [Test] + public async Task Headers_should_be_applied_when_trust_all_proxies() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + Assert.That(result.Processed.Host, Is.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Headers_should_be_consumed_after_processing() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should be consumed (removed) after processing + Assert.That(result.RawHeaders.XForwardedFor, Is.Empty); + Assert.That(result.RawHeaders.XForwardedProto, Is.Empty); + Assert.That(result.RawHeaders.XForwardedHost, Is.Empty); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_headers_are_malformed.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_headers_are_malformed.cs new file mode 100644 index 0000000000..93b6e583eb --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_headers_are_malformed.cs @@ -0,0 +1,97 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when forwarded headers contain malformed or edge-case values. +/// +[TestFixture] +public class When_headers_are_malformed : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + } + + [Test] + public async Task Should_handle_empty_forwarded_for_entries() + { + // Header with empty entry in the middle + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50, , 192.168.1.1", + forwardedProto: "https"); + + // Should still process and use a valid IP + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + } + + [Test] + public async Task Should_handle_whitespace_only_entries() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50, ,192.168.1.1", + forwardedProto: "https"); + + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + } + + [Test] + public async Task Should_handle_single_value_with_trailing_comma() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50,", + forwardedProto: "https"); + + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Should_fallback_when_ip_has_whitespace() + { + var result = await SendRequestWithHeaders( + forwardedFor: " 203.0.113.50 , 10.0.0.1 ", + forwardedProto: "https"); + + // ASP.NET Core doesn't trim whitespace from IP addresses - falls back to original + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("127.0.0.1")); + } + + [Test] + public async Task Should_preserve_original_scheme_for_invalid_proto() + { + // Invalid protocol value - should either be ignored or handled gracefully + var result = await SendRequestWithHeaders(forwardedProto: "ftp"); + + // Behavior depends on implementation - at minimum shouldn't crash + Assert.That(result.Processed.Scheme, Is.Not.Null); + } + + [Test] + public async Task Should_handle_empty_proto_value() + { + var result = await SendRequestWithHeaders(forwardedProto: ""); + + // Empty proto should fall back to original + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + } + + [Test] + public async Task Should_fallback_when_proto_has_whitespace() + { + var result = await SendRequestWithHeaders(forwardedProto: " https "); + + // ASP.NET Core doesn't trim whitespace from proto values - falls back to original + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + } + + [Test] + public async Task Should_fallback_to_original_host_when_whitespace_present() + { + var result = await SendRequestWithHeaders(forwardedHost: " example.com "); + + // ASP.NET Core doesn't trim whitespace from host values - falls back to original + Assert.That(result.Processed.Host, Is.EqualTo("localhost")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_ipv4_mapped_ipv6_addresses_are_used.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_ipv4_mapped_ipv6_addresses_are_used.cs new file mode 100644 index 0000000000..aeb6fb4548 --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_ipv4_mapped_ipv6_addresses_are_used.cs @@ -0,0 +1,79 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are used. +/// These should be normalized and match their IPv4 equivalents in KnownProxies. +/// +[TestFixture] +public class When_ipv4_mapped_ipv6_addresses_are_used : ForwardedHeadersTestBase +{ + [Test] + public async Task Ipv4_mapped_ipv6_proxy_should_match_ipv4_known_proxy() + { + // Configure 127.0.0.1 as known proxy + // Request comes from ::ffff:127.0.0.1 (IPv4-mapped IPv6) + Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1") + .WithRemoteIpAddress("::ffff:127.0.0.1"); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should be processed since ::ffff:127.0.0.1 matches 127.0.0.1 + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + Assert.That(result.Processed.Host, Is.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Ipv4_proxy_does_not_match_ipv4_mapped_ipv6_known_proxy() + { + // Configure ::ffff:127.0.0.1 as known proxy + // Request comes from 127.0.0.1 (plain IPv4) + Factory = TestConfiguration.CreateWithKnownProxies("::ffff:127.0.0.1"); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // ASP.NET Core doesn't normalize IPv4-mapped IPv6 - no match, headers not processed + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + Assert.That(result.Processed.Host, Is.EqualTo("localhost")); + } + + [Test] + public async Task Ipv4_mapped_address_in_forwarded_for_should_be_processed() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "::ffff:203.0.113.50", + forwardedProto: "https"); + + // The IPv4-mapped address should be processed + // Note: The stored value format depends on implementation + Assert.That(result.Processed.RemoteIpAddress, Is.Not.Null.And.Not.Empty); + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + } + + [Test] + public async Task Mixed_ipv4_and_mapped_addresses_in_chain() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "::ffff:203.0.113.50, 10.0.0.1, ::ffff:192.168.1.1", + forwardedProto: "https"); + + // Should process the chain and return leftmost + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_ipv6_addresses_are_used.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_ipv6_addresses_are_used.cs new file mode 100644 index 0000000000..1e21ccc37f --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_ipv6_addresses_are_used.cs @@ -0,0 +1,82 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when IPv6 addresses are used in X-Forwarded-For headers. +/// +[TestFixture] +public class When_ipv6_addresses_are_used : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + } + + [Test] + public async Task Should_process_unbracketed_ipv6_address() + { + var result = await SendRequestWithHeaders( + forwardedFor: "2001:db8::1", + forwardedProto: "https"); + + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::1")); + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + } + + [Test] + public async Task Should_process_bracketed_ipv6_address() + { + var result = await SendRequestWithHeaders( + forwardedFor: "[2001:db8::1]", + forwardedProto: "https"); + + // Brackets should be stripped + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::1")); + } + + [Test] + public async Task Should_process_bracketed_ipv6_address_with_port() + { + var result = await SendRequestWithHeaders( + forwardedFor: "[2001:db8::1]:8080", + forwardedProto: "https"); + + // Should extract just the IPv6 address without brackets or port + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::1")); + } + + [Test] + public async Task Should_process_loopback_ipv6_address() + { + var result = await SendRequestWithHeaders( + forwardedFor: "::1", + forwardedProto: "https"); + + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("::1")); + } + + [Test] + public async Task Should_process_ipv6_chain() + { + var result = await SendRequestWithHeaders( + forwardedFor: "2001:db8::100, 2001:db8::1, ::1", + forwardedProto: "https"); + + // Should use the leftmost (original client) IP + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("2001:db8::100")); + } + + [Test] + public async Task Should_process_mixed_ipv4_ipv6_chain() + { + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50, 2001:db8::1, 192.168.1.1", + forwardedProto: "https"); + + // Should use the leftmost (original client) IP + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_only_proto_header_is_sent.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_only_proto_header_is_sent.cs new file mode 100644 index 0000000000..9dd1fc4c4a --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_only_proto_header_is_sent.cs @@ -0,0 +1,38 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when only the X-Forwarded-Proto header is sent. +/// +[TestFixture] +public class When_only_proto_header_is_sent : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + } + + [Test] + public async Task Scheme_should_be_updated() + { + // Only X-Forwarded-Proto, no X-Forwarded-For or X-Forwarded-Host + var result = await SendRequestWithHeaders(forwardedProto: "https"); + + // Scheme should be updated + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + } + + [Test] + public async Task Host_and_ip_should_use_original_values() + { + // Only X-Forwarded-Proto, no X-Forwarded-For or X-Forwarded-Host + var result = await SendRequestWithHeaders(forwardedProto: "https"); + + // Host and IP should remain original + Assert.That(result.Processed.Host, Does.Contain("localhost")); + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("127.0.0.1")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs new file mode 100644 index 0000000000..46f49edd60 --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_proxy_chain_headers_are_sent.cs @@ -0,0 +1,51 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when proxy chain headers contain multiple values. +/// ASP.NET Core's ForwardedHeaders middleware uses the FIRST (leftmost) value +/// when processing comma-separated header values. +/// +[TestFixture] +public class When_proxy_chain_headers_are_sent : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + } + + [Test] + public async Task Should_use_leftmost_ip_from_chain() + { + // Leftmost is original client, rightmost is the most recent proxy + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50, 10.0.0.1, 192.168.1.1", + forwardedProto: "https"); + + // ASP.NET Core processes the chain from right to left and ends with the leftmost client IP + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Should_use_first_proto_from_chain() + { + // ASP.NET Core uses the first value in comma-separated list + var result = await SendRequestWithHeaders(forwardedProto: "https, http"); + + // Should use the first (leftmost) protocol value + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + } + + [Test] + public async Task Should_use_first_host_from_chain() + { + // ASP.NET Core uses the first value in comma-separated list + var result = await SendRequestWithHeaders(forwardedHost: "public.example.com, proxy.internal, original.com"); + + // Should use the first (leftmost) host value + Assert.That(result.Processed.Host, Is.EqualTo("public.example.com")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs new file mode 100644 index 0000000000..e3d18a76ce --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_request_has_no_forwarded_headers.cs @@ -0,0 +1,29 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when no forwarded headers are present in the request. +/// +[TestFixture] +public class When_request_has_no_forwarded_headers : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithForwardedHeadersDefaults(); + Client = Factory.CreateClient(); + } + + [Test] + public async Task Original_values_should_be_preserved() + { + // No forwarded headers + var result = await SendRequestWithHeaders(); + + // Original values should be used + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + Assert.That(result.Processed.Host, Does.Contain("localhost")); + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("127.0.0.1")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_trusted_source_sends_headers.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_trusted_source_sends_headers.cs new file mode 100644 index 0000000000..43a7be238a --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_trusted_source_sends_headers.cs @@ -0,0 +1,57 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when headers are sent from a trusted source (known proxy or within a known network). +/// +[TestFixture] +public class When_trusted_source_sends_headers : ForwardedHeadersTestBase +{ + [TestCase("127.0.0.1", null, TestName = "Known_proxy")] + [TestCase(null, "127.0.0.0/8", TestName = "Known_network")] + public async Task Headers_should_be_processed(string? knownProxy, string? knownNetwork) + { + // Configure known proxy or network that matches the simulated remote IP (127.0.0.1) + Factory = knownProxy != null + ? TestConfiguration.CreateWithKnownProxies(knownProxy) + : TestConfiguration.CreateWithKnownNetworks(knownNetwork!); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should be processed since request comes from trusted source + Assert.That(result.Processed.Scheme, Is.EqualTo("https")); + Assert.That(result.Processed.Host, Is.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.EqualTo("203.0.113.50")); + } + + [Test] + public async Task Configuration_should_show_known_proxies() + { + Factory = TestConfiguration.CreateWithKnownProxies("127.0.0.1"); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders(); + + Assert.That(result.Configuration.Enabled, Is.True); + Assert.That(result.Configuration.TrustAllProxies, Is.False); + Assert.That(result.Configuration.KnownProxies, Does.Contain("127.0.0.1")); + } + + [Test] + public async Task Configuration_should_show_known_networks() + { + Factory = TestConfiguration.CreateWithKnownNetworks("127.0.0.0/8"); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders(); + + Assert.That(result.Configuration.Enabled, Is.True); + Assert.That(result.Configuration.TrustAllProxies, Is.False); + Assert.That(result.Configuration.KnownNetworks, Does.Contain("127.0.0.0/8")); + } +} diff --git a/src/ServicePulse.Tests/ForwardedHeaders/When_untrusted_source_sends_headers.cs b/src/ServicePulse.Tests/ForwardedHeaders/When_untrusted_source_sends_headers.cs new file mode 100644 index 0000000000..4a305ce038 --- /dev/null +++ b/src/ServicePulse.Tests/ForwardedHeaders/When_untrusted_source_sends_headers.cs @@ -0,0 +1,57 @@ +namespace ServicePulse.Tests.ForwardedHeaders; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when headers are sent from an untrusted source (neither a known proxy nor within a known network). +/// +[TestFixture] +public class When_untrusted_source_sends_headers : ForwardedHeadersTestBase +{ + [SetUp] + public void SetUp() + { + // Default setup - will be overridden by test cases + } + + [TestCase("10.0.0.1", null, TestName = "Unknown_proxy")] + [TestCase(null, "10.0.0.0/8", TestName = "Unknown_network")] + public async Task Headers_should_not_be_processed(string? knownProxy, string? knownNetwork) + { + // Configure known proxy or network that doesn't match the simulated remote IP + Factory = knownProxy != null + ? TestConfiguration.CreateWithKnownProxies(knownProxy).WithRemoteIpAddress("192.168.1.100") + : TestConfiguration.CreateWithKnownNetworks(knownNetwork!).WithRemoteIpAddress("192.168.1.100"); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should NOT be processed since request doesn't come from trusted source + Assert.That(result.Processed.Scheme, Is.EqualTo("http")); + Assert.That(result.Processed.Host, Is.Not.EqualTo("example.com")); + Assert.That(result.Processed.RemoteIpAddress, Is.Not.EqualTo("203.0.113.50")); + } + + [TestCase("10.0.0.1", null, TestName = "Unknown_proxy")] + [TestCase(null, "10.0.0.0/8", TestName = "Unknown_network")] + public async Task Headers_should_remain_in_request(string? knownProxy, string? knownNetwork) + { + Factory = knownProxy != null + ? TestConfiguration.CreateWithKnownProxies(knownProxy).WithRemoteIpAddress("192.168.1.100") + : TestConfiguration.CreateWithKnownNetworks(knownNetwork!).WithRemoteIpAddress("192.168.1.100"); + Client = Factory.CreateClient(); + + var result = await SendRequestWithHeaders( + forwardedFor: "203.0.113.50", + forwardedProto: "https", + forwardedHost: "example.com"); + + // Headers should remain unprocessed when from untrusted source + Assert.That(result.RawHeaders.XForwardedFor, Is.EqualTo("203.0.113.50")); + Assert.That(result.RawHeaders.XForwardedProto, Is.EqualTo("https")); + Assert.That(result.RawHeaders.XForwardedHost, Is.EqualTo("example.com")); + } +} diff --git a/src/ServicePulse.Tests/Https/HttpsTestBase.cs b/src/ServicePulse.Tests/Https/HttpsTestBase.cs new file mode 100644 index 0000000000..8c6a53f254 --- /dev/null +++ b/src/ServicePulse.Tests/Https/HttpsTestBase.cs @@ -0,0 +1,67 @@ +namespace ServicePulse.Tests.Https; + +using Microsoft.AspNetCore.Mvc.Testing; +using ServicePulse.Tests.Infrastructure; + +/// +/// Base class for HTTPS tests providing common setup, teardown, and helper methods. +/// +public abstract class HttpsTestBase +{ + protected ServicePulseWebApplicationFactory Factory = null!; + protected HttpClient Client = null!; + + [TearDown] + public void TearDown() + { + Client?.Dispose(); + Factory?.Dispose(); + } + + protected HttpClient CreateHttpClient(bool allowAutoRedirect = true) + { + return Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = allowAutoRedirect, + BaseAddress = new Uri("http://localhost") + }); + } + + protected HttpClient CreateHttpsClient(bool allowAutoRedirect = true) + { + return Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = allowAutoRedirect, + BaseAddress = new Uri("https://localhost") + }); + } + + protected async Task SendHttpRequest( + string path, + HttpMethod? method = null, + string? forwardedProto = null, + string? forwardedHost = null) + { + var request = new HttpRequestMessage(method ?? HttpMethod.Get, path); + + if (forwardedProto != null) + { + request.Headers.Add("X-Forwarded-Proto", forwardedProto); + } + if (forwardedHost != null) + { + request.Headers.Add("X-Forwarded-Host", forwardedHost); + } + + return await Client.SendAsync(request); + } + + protected static string? GetHstsHeaderValue(HttpResponseMessage response) + { + if (response.Headers.Contains("Strict-Transport-Security")) + { + return string.Join("", response.Headers.GetValues("Strict-Transport-Security")); + } + return null; + } +} diff --git a/src/ServicePulse.Tests/Https/When_hsts_is_enabled.cs b/src/ServicePulse.Tests/Https/When_hsts_is_enabled.cs new file mode 100644 index 0000000000..77994281fd --- /dev/null +++ b/src/ServicePulse.Tests/Https/When_hsts_is_enabled.cs @@ -0,0 +1,68 @@ +namespace ServicePulse.Tests.Https; + +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests HSTS (HTTP Strict Transport Security) header behavior. +/// HSTS requires Production environment (disabled in Development by ASP.NET Core). +/// +[TestFixture] +public class When_hsts_is_enabled : HttpsTestBase +{ + [Test] + public async Task Should_add_hsts_header_for_https_request() + { + Factory = TestConfiguration.CreateWithHstsEnabled(); + Client = CreateHttpsClient(); + + var response = await Client.GetAsync("/js/app.constants.js"); + + var hstsValue = GetHstsHeaderValue(response); + Assert.That(hstsValue, Is.Not.Null); + Assert.That(hstsValue, Does.Contain("max-age=31536000")); + } + + [Test] + public async Task Should_not_add_hsts_header_for_http_request() + { + Factory = TestConfiguration.CreateWithHstsEnabled(); + Client = CreateHttpClient(); + + var response = await Client.GetAsync("/js/app.constants.js"); + + Assert.That(GetHstsHeaderValue(response), Is.Null); + } + + [Test] + public async Task Should_use_custom_max_age() + { + Factory = TestConfiguration.CreateWithHstsEnabled(maxAgeSeconds: 86400); + Client = CreateHttpsClient(); + + var response = await Client.GetAsync("/js/app.constants.js"); + + Assert.That(GetHstsHeaderValue(response), Is.EqualTo("max-age=86400")); + } + + [Test] + public async Task Should_include_subdomains_when_configured() + { + Factory = TestConfiguration.CreateWithHstsEnabled(includeSubDomains: true); + Client = CreateHttpsClient(); + + var response = await Client.GetAsync("/js/app.constants.js"); + + Assert.That(GetHstsHeaderValue(response), Does.Contain("includeSubDomains")); + } + + [Test] + public async Task Should_not_include_subdomains_by_default() + { + Factory = TestConfiguration.CreateWithHstsEnabled(includeSubDomains: false); + Client = CreateHttpsClient(); + + var response = await Client.GetAsync("/js/app.constants.js"); + + Assert.That(GetHstsHeaderValue(response), Does.Not.Contain("includeSubDomains")); + } +} diff --git a/src/ServicePulse.Tests/Https/When_https_is_disabled.cs b/src/ServicePulse.Tests/Https/When_https_is_disabled.cs new file mode 100644 index 0000000000..880e9f624a --- /dev/null +++ b/src/ServicePulse.Tests/Https/When_https_is_disabled.cs @@ -0,0 +1,45 @@ +namespace ServicePulse.Tests.Https; + +using System.Net; +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests behavior when all HTTPS features are disabled. +/// +[TestFixture] +public class When_https_is_disabled : HttpsTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithHttpsDisabled(); + Client = CreateHttpClient(); + } + + [Test] + public async Task Should_not_add_hsts_header() + { + var response = await Client.GetAsync("/js/app.constants.js"); + + Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.False); + } + + [Test] + public async Task Should_not_redirect_http_request() + { + var response = await Client.GetAsync("/js/app.constants.js"); + + // Should not redirect + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.PermanentRedirect)); + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.MovedPermanently)); + } + + [Test] + public async Task Should_serve_content_normally() + { + var response = await Client.GetAsync("/js/app.constants.js"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } +} diff --git a/src/ServicePulse.Tests/Https/When_https_redirect_is_enabled.cs b/src/ServicePulse.Tests/Https/When_https_redirect_is_enabled.cs new file mode 100644 index 0000000000..786f16908f --- /dev/null +++ b/src/ServicePulse.Tests/Https/When_https_redirect_is_enabled.cs @@ -0,0 +1,89 @@ +namespace ServicePulse.Tests.Https; + +using System.Net; +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests HTTP to HTTPS redirect behavior. +/// +[TestFixture] +public class When_https_redirect_is_enabled : HttpsTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithHttpsRedirectEnabled(); + Client = CreateHttpClient(allowAutoRedirect: false); + } + + [Test] + public async Task Should_redirect_http_to_https() + { + var response = await Client.GetAsync("/some/path"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location?.ToString(), Does.StartWith("https://")); + } + + [Test] + public async Task Should_not_redirect_https_request() + { + using var httpsClient = CreateHttpsClient(allowAutoRedirect: false); + + var response = await httpsClient.GetAsync("/js/app.constants.js"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task Should_include_custom_port_in_redirect() + { + Factory.Dispose(); + Client.Dispose(); + + Factory = TestConfiguration.CreateWithHttpsRedirectEnabled(httpsPort: 8443); + Client = CreateHttpClient(allowAutoRedirect: false); + + var response = await Client.GetAsync("/test"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location?.ToString(), Does.Contain(":8443")); + } + + [Test] + public async Task Should_omit_port_443_in_redirect() + { + // Port 443 is the default HTTPS port and should be omitted + var response = await Client.GetAsync("/test"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location?.ToString(), Does.Not.Contain(":443")); + } + + [Test] + public async Task Should_preserve_path_in_redirect() + { + var response = await Client.GetAsync("/some/path"); + + Assert.That(response.Headers.Location?.ToString(), Does.Contain("/some/path")); + } + + [Test] + public async Task Should_preserve_query_string() + { + var response = await Client.GetAsync("/test?foo=bar&baz=qux"); + + Assert.That(response.Headers.Location?.ToString(), Does.Contain("foo=bar")); + Assert.That(response.Headers.Location?.ToString(), Does.Contain("baz=qux")); + } + + [Test] + public async Task Should_use_307_to_preserve_http_method() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/data"); + var response = await Client.SendAsync(request); + + // 307 preserves the HTTP method (important for POST, PUT, etc.) + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + } +} diff --git a/src/ServicePulse.Tests/Https/When_https_with_forwarded_headers.cs b/src/ServicePulse.Tests/Https/When_https_with_forwarded_headers.cs new file mode 100644 index 0000000000..bd8463e8cb --- /dev/null +++ b/src/ServicePulse.Tests/Https/When_https_with_forwarded_headers.cs @@ -0,0 +1,99 @@ +namespace ServicePulse.Tests.Https; + +using System.Net; +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests HTTPS behavior when behind a reverse proxy using forwarded headers. +/// This simulates scenarios where TLS termination happens at the proxy. +/// +/// Note: ASP.NET Core's UseHsts middleware adds HSTS header based on the processed scheme +/// after ForwardedHeaders middleware has run. +/// +[TestFixture] +public class When_https_with_forwarded_headers : HttpsTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithForwardedHeadersAndHttps(); + Client = CreateHttpClient(allowAutoRedirect: false); + } + + [Test] + public async Task Should_not_redirect_when_forwarded_proto_is_https() + { + var response = await SendHttpRequest("/js/app.constants.js", forwardedProto: "https"); + + // Should not redirect because effective scheme is already HTTPS (from forwarded header) + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task Should_redirect_when_no_forwarded_proto() + { + var response = await SendHttpRequest("/test"); + + // Should redirect because effective scheme is HTTP + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + } + + [Test] + public async Task Should_use_forwarded_host_in_redirect_when_http() + { + var response = await SendHttpRequest("/test", forwardedHost: "example.com"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location?.ToString(), Does.Contain("example.com")); + } + + [Test] + public async Task Should_add_hsts_when_request_is_https() + { + // When using HTTPS directly (not forwarded), HSTS should be added + using var httpsClient = CreateHttpsClient(); + + var response = await httpsClient.GetAsync("/js/app.constants.js"); + response.EnsureSuccessStatusCode(); + + Assert.That(response.Headers.Contains("Strict-Transport-Security"), Is.True); + } + + [Test] + public async Task Redirect_should_preserve_path() + { + var response = await SendHttpRequest("/some/path"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location?.ToString(), Does.Contain("/some/path")); + } + + [Test] + public async Task Should_add_hsts_when_forwarded_proto_is_https() + { + // HSTS should be added when the effective scheme is HTTPS via X-Forwarded-Proto + var response = await SendHttpRequest("/js/app.constants.js", forwardedProto: "https"); + + response.EnsureSuccessStatusCode(); + Assert.That(GetHstsHeaderValue(response), Is.Not.Null); + } + + [TestCase("HTTPS")] + [TestCase("Https")] + [TestCase("hTtPs")] + public async Task Should_handle_case_insensitive_forwarded_proto(string proto) + { + var response = await SendHttpRequest("/js/app.constants.js", forwardedProto: proto); + + // Should not redirect - HTTPS proto handling should be case-insensitive + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task Should_redirect_when_forwarded_proto_is_http() + { + var response = await SendHttpRequest("/test", forwardedProto: "http"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + } +} diff --git a/src/ServicePulse.Tests/Https/When_ipv6_host_is_used.cs b/src/ServicePulse.Tests/Https/When_ipv6_host_is_used.cs new file mode 100644 index 0000000000..04290a875d --- /dev/null +++ b/src/ServicePulse.Tests/Https/When_ipv6_host_is_used.cs @@ -0,0 +1,74 @@ +namespace ServicePulse.Tests.Https; + +using System.Net; +using ServicePulse.Tests.Infrastructure; + +/// +/// Tests HTTPS redirect behavior with IPv6 addresses. +/// IPv6 addresses in Host headers must be bracketed: [::1] +/// +[TestFixture] +public class When_ipv6_host_is_used : HttpsTestBase +{ + [SetUp] + public void SetUp() + { + Factory = TestConfiguration.CreateWithHttpsRedirectEnabled(); + Client = CreateHttpClient(allowAutoRedirect: false); + } + + [Test] + public async Task Should_redirect_to_https_with_ipv6_host() + { + var response = await SendHttpRequest("/test", forwardedHost: "[::1]"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location?.ToString(), Does.StartWith("https://[::1]")); + } + + [Test] + public async Task Should_preserve_ipv6_address_format_in_redirect() + { + var response = await SendHttpRequest("/test", forwardedHost: "[2001:db8::1]"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location?.ToString(), Does.Contain("[2001:db8::1]")); + } + + [Test] + public async Task Should_handle_ipv6_with_port_in_redirect() + { + Factory.Dispose(); + Client.Dispose(); + + Factory = TestConfiguration.CreateWithHttpsRedirectEnabled(httpsPort: 8443); + Client = CreateHttpClient(allowAutoRedirect: false); + + var response = await SendHttpRequest("/test", forwardedHost: "[::1]"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + // IPv6 with port should be [::1]:8443 + Assert.That(response.Headers.Location?.ToString(), Does.Contain("[::1]:8443")); + } + + [Test] + public async Task Should_strip_port_from_ipv6_host_before_redirect() + { + // Host header with IPv6 and port: [::1]:8080 + // Should redirect to HTTPS without duplicating port handling + var response = await SendHttpRequest("/test", forwardedHost: "[::1]:8080"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + // The redirect should use the HTTPS port (443 by default, omitted) + Assert.That(response.Headers.Location?.ToString(), Does.StartWith("https://[::1]")); + } + + [Test] + public async Task Should_handle_ipv4_mapped_ipv6_in_host() + { + var response = await SendHttpRequest("/test", forwardedHost: "[::ffff:127.0.0.1]"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.TemporaryRedirect)); + Assert.That(response.Headers.Location?.ToString(), Does.Contain("[::ffff:127.0.0.1]")); + } +} diff --git a/src/ServicePulse.Tests/Infrastructure/DebugRequestInfoResponse.cs b/src/ServicePulse.Tests/Infrastructure/DebugRequestInfoResponse.cs new file mode 100644 index 0000000000..7154db8791 --- /dev/null +++ b/src/ServicePulse.Tests/Infrastructure/DebugRequestInfoResponse.cs @@ -0,0 +1,57 @@ +namespace ServicePulse.Tests.Infrastructure; + +using System.Text.Json.Serialization; + +/// +/// Represents the JSON response from the /debug/request-info endpoint. +/// +public class DebugRequestInfoResponse +{ + [JsonPropertyName("processed")] + public ProcessedValues Processed { get; set; } = new(); + + [JsonPropertyName("rawHeaders")] + public RawHeaders RawHeaders { get; set; } = new(); + + [JsonPropertyName("configuration")] + public ConfigurationValues Configuration { get; set; } = new(); +} + +public class ProcessedValues +{ + [JsonPropertyName("scheme")] + public string Scheme { get; set; } = string.Empty; + + [JsonPropertyName("host")] + public string Host { get; set; } = string.Empty; + + [JsonPropertyName("remoteIpAddress")] + public string? RemoteIpAddress { get; set; } +} + +public class RawHeaders +{ + [JsonPropertyName("xForwardedFor")] + public string XForwardedFor { get; set; } = string.Empty; + + [JsonPropertyName("xForwardedProto")] + public string XForwardedProto { get; set; } = string.Empty; + + [JsonPropertyName("xForwardedHost")] + public string XForwardedHost { get; set; } = string.Empty; +} + +public class ConfigurationValues +{ + [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/ServicePulse.Tests/Infrastructure/FakeRemoteIpAddressMiddleware.cs b/src/ServicePulse.Tests/Infrastructure/FakeRemoteIpAddressMiddleware.cs new file mode 100644 index 0000000000..b76d1b1314 --- /dev/null +++ b/src/ServicePulse.Tests/Infrastructure/FakeRemoteIpAddressMiddleware.cs @@ -0,0 +1,24 @@ +namespace ServicePulse.Tests.Infrastructure; + +/// +/// Middleware that injects a simulated remote IP address for testing. +/// WebApplicationFactory/TestServer doesn't set RemoteIpAddress by default. +/// +public class FakeRemoteIpAddressMiddleware +{ + readonly RequestDelegate next; + readonly FakeRemoteIpAddressOptions options; + + public FakeRemoteIpAddressMiddleware(RequestDelegate next, FakeRemoteIpAddressOptions options) + { + this.next = next; + this.options = options; + } + + public Task InvokeAsync(HttpContext context) + { + // Set the RemoteIpAddress - ASP.NET Core allows setting this directly + context.Connection.RemoteIpAddress = options.IpAddress; + return next(context); + } +} diff --git a/src/ServicePulse.Tests/Infrastructure/ServicePulseWebApplicationFactory.cs b/src/ServicePulse.Tests/Infrastructure/ServicePulseWebApplicationFactory.cs new file mode 100644 index 0000000000..d35f353982 --- /dev/null +++ b/src/ServicePulse.Tests/Infrastructure/ServicePulseWebApplicationFactory.cs @@ -0,0 +1,120 @@ +namespace ServicePulse.Tests.Infrastructure; + +using System.Net; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +public class ServicePulseWebApplicationFactory : WebApplicationFactory +{ + readonly Dictionary environmentVariables = new(); + readonly Dictionary originalValues = new(); + string environment = "Development"; + string simulatedRemoteIp = "127.0.0.1"; + bool clearHstsExcludedHosts; + + /// + /// Sets an environment variable for this test. The value will be restored after the test. + /// + public ServicePulseWebApplicationFactory WithEnvironmentVariable(string name, string? value) + { + environmentVariables[name] = value; + return this; + } + + /// + /// Sets the ASP.NET Core environment (Development, Production, etc.) + /// + public ServicePulseWebApplicationFactory WithEnvironment(string env) + { + environment = env; + return this; + } + + /// + /// Sets the simulated remote IP address for incoming requests. + /// + public ServicePulseWebApplicationFactory WithRemoteIpAddress(string ipAddress) + { + simulatedRemoteIp = ipAddress; + return this; + } + + /// + /// Clears the HSTS excluded hosts list so HSTS headers are added for localhost. + /// By default, ASP.NET Core excludes localhost, 127.0.0.1, and [::1] from HSTS. + /// + public ServicePulseWebApplicationFactory WithHstsExcludedHostsCleared() + { + clearHstsExcludedHosts = true; + return this; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Store original values and set new values + foreach (var kvp in environmentVariables) + { + originalValues[kvp.Key] = Environment.GetEnvironmentVariable(kvp.Key); + Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); + } + + builder.UseEnvironment(environment); + + builder.ConfigureServices(services => + { + // Register the simulated IP address options + services.AddSingleton(new FakeRemoteIpAddressOptions { IpAddress = IPAddress.Parse(simulatedRemoteIp) }); + + // Insert the startup filter at the beginning to ensure our middleware runs first + services.Insert(0, ServiceDescriptor.Transient()); + + // Clear HSTS excluded hosts if requested (for testing HSTS with localhost) + if (clearHstsExcludedHosts) + { + services.Configure(options => + { + options.ExcludedHosts.Clear(); + }); + } + }); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + // Restore original environment variables + foreach (var kvp in originalValues) + { + Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); + } + } + + base.Dispose(disposing); + } +} + +public class FakeRemoteIpAddressOptions +{ + public IPAddress IpAddress { get; set; } = IPAddress.Loopback; +} + +public class FakeRemoteIpAddressStartupFilter : IStartupFilter +{ + readonly FakeRemoteIpAddressOptions options; + + public FakeRemoteIpAddressStartupFilter(FakeRemoteIpAddressOptions options) + { + this.options = options; + } + + public Action Configure(Action next) + { + return app => + { + app.UseMiddleware(options); + next(app); + }; + } +} diff --git a/src/ServicePulse.Tests/Infrastructure/TestConfiguration.cs b/src/ServicePulse.Tests/Infrastructure/TestConfiguration.cs new file mode 100644 index 0000000000..e61178ad42 --- /dev/null +++ b/src/ServicePulse.Tests/Infrastructure/TestConfiguration.cs @@ -0,0 +1,159 @@ +namespace ServicePulse.Tests.Infrastructure; + +/// +/// Provides common test configuration methods for ServicePulse tests. +/// +public static class TestConfiguration +{ + // Environment variable names + public const string ForwardedHeadersEnabled = "SERVICEPULSE_FORWARDEDHEADERS_ENABLED"; + public const string ForwardedHeadersTrustAllProxies = "SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES"; + public const string ForwardedHeadersKnownProxies = "SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES"; + public const string ForwardedHeadersKnownNetworks = "SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS"; + + public const string HttpsEnabled = "SERVICEPULSE_HTTPS_ENABLED"; + public const string HttpsRedirectHttpToHttps = "SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS"; + public const string HttpsPort = "SERVICEPULSE_HTTPS_PORT"; + public const string HttpsEnableHsts = "SERVICEPULSE_HTTPS_ENABLEHSTS"; + public const string HttpsHstsMaxAgeSeconds = "SERVICEPULSE_HTTPS_HSTSMAXAGESECONDS"; + public const string HttpsHstsIncludeSubDomains = "SERVICEPULSE_HTTPS_HSTSINCLUDESUBDOMAINS"; + + /// + /// Creates a factory with default forwarded headers configuration (enabled, trust all proxies). + /// + public static ServicePulseWebApplicationFactory CreateWithForwardedHeadersDefaults() + { + return new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(ForwardedHeadersEnabled, "true") + .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "true") + .WithEnvironmentVariable(ForwardedHeadersKnownProxies, null) + .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, null) + .WithEnvironment("Development"); + } + + /// + /// Creates a factory with forwarded headers disabled. + /// + public static ServicePulseWebApplicationFactory CreateWithForwardedHeadersDisabled() + { + return new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(ForwardedHeadersEnabled, "false") + .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, null) + .WithEnvironmentVariable(ForwardedHeadersKnownProxies, null) + .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, null) + .WithEnvironment("Development"); + } + + /// + /// Creates a factory with specific known proxies. + /// + public static ServicePulseWebApplicationFactory CreateWithKnownProxies(params string[] proxies) + { + return new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(ForwardedHeadersEnabled, "true") + .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "false") + .WithEnvironmentVariable(ForwardedHeadersKnownProxies, string.Join(",", proxies)) + .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, null) + .WithEnvironment("Development"); + } + + /// + /// Creates a factory with specific known networks. + /// + public static ServicePulseWebApplicationFactory CreateWithKnownNetworks(params string[] networks) + { + return new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(ForwardedHeadersEnabled, "true") + .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "false") + .WithEnvironmentVariable(ForwardedHeadersKnownProxies, null) + .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, string.Join(",", networks)) + .WithEnvironment("Development"); + } + + /// + /// Creates a factory with both known proxies and networks. + /// + public static ServicePulseWebApplicationFactory CreateWithKnownProxiesAndNetworks(string[] proxies, string[] networks) + { + return new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(ForwardedHeadersEnabled, "true") + .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "false") + .WithEnvironmentVariable(ForwardedHeadersKnownProxies, string.Join(",", proxies)) + .WithEnvironmentVariable(ForwardedHeadersKnownNetworks, string.Join(",", networks)) + .WithEnvironment("Development"); + } + + /// + /// Creates a factory with HSTS enabled (requires Production environment). + /// + public static ServicePulseWebApplicationFactory CreateWithHstsEnabled( + int maxAgeSeconds = 31536000, + bool includeSubDomains = false) + { + return new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(HttpsEnableHsts, "true") + .WithEnvironmentVariable(HttpsHstsMaxAgeSeconds, maxAgeSeconds.ToString()) + .WithEnvironmentVariable(HttpsHstsIncludeSubDomains, includeSubDomains.ToString()) + .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "false") + .WithEnvironment("Production") // HSTS requires non-Development + .WithHstsExcludedHostsCleared(); // Allow HSTS for localhost in tests + } + + /// + /// Creates a factory with HTTPS redirect enabled. + /// + public static ServicePulseWebApplicationFactory CreateWithHttpsRedirectEnabled(int httpsPort = 443) + { + return new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "true") + .WithEnvironmentVariable(HttpsPort, httpsPort.ToString()) + .WithEnvironmentVariable(HttpsEnableHsts, "false") + .WithEnvironment("Development"); + } + + /// + /// Creates a factory with all HTTPS features enabled. + /// + public static ServicePulseWebApplicationFactory CreateWithAllHttpsEnabled( + int httpsPort = 443, + int maxAgeSeconds = 31536000, + bool includeSubDomains = false) => + new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "true") + .WithEnvironmentVariable(HttpsPort, httpsPort.ToString()) + .WithEnvironmentVariable(HttpsEnableHsts, "true") + .WithEnvironmentVariable(HttpsHstsMaxAgeSeconds, maxAgeSeconds.ToString()) + .WithEnvironmentVariable(HttpsHstsIncludeSubDomains, includeSubDomains.ToString()) + .WithEnvironment("Production") // HSTS requires non-Development + .WithHstsExcludedHostsCleared(); // Allow HSTS for localhost in tests + + /// + /// Creates a factory with HTTPS disabled. + /// + public static ServicePulseWebApplicationFactory CreateWithHttpsDisabled() + { + return new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(HttpsEnabled, "false") + .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "false") + .WithEnvironmentVariable(HttpsPort, null) + .WithEnvironmentVariable(HttpsEnableHsts, "false") + .WithEnvironmentVariable(HttpsHstsMaxAgeSeconds, null) + .WithEnvironmentVariable(HttpsHstsIncludeSubDomains, null) + .WithEnvironment("Development"); + } + + /// + /// Creates a factory with forwarded headers and HTTPS features (reverse proxy scenario). + /// + public static ServicePulseWebApplicationFactory CreateWithForwardedHeadersAndHttps(int httpsPort = 443) + { + return new ServicePulseWebApplicationFactory() + .WithEnvironmentVariable(ForwardedHeadersEnabled, "true") + .WithEnvironmentVariable(ForwardedHeadersTrustAllProxies, "true") + .WithEnvironmentVariable(HttpsRedirectHttpToHttps, "true") + .WithEnvironmentVariable(HttpsPort, httpsPort.ToString()) + .WithEnvironmentVariable(HttpsEnableHsts, "true") + .WithEnvironment("Production") // HSTS requires non-Development + .WithHstsExcludedHostsCleared(); // Allow HSTS for localhost in tests + } +} diff --git a/src/ServicePulse.Tests/Properties/launchSettings.json b/src/ServicePulse.Tests/Properties/launchSettings.json new file mode 100644 index 0000000000..2e42b012d9 --- /dev/null +++ b/src/ServicePulse.Tests/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ServicePulse.Tests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:57663;http://localhost:57664" + } + } +} \ No newline at end of file diff --git a/src/ServicePulse.Tests/ServicePulse.Tests.csproj b/src/ServicePulse.Tests/ServicePulse.Tests.csproj new file mode 100644 index 0000000000..27a074a049 --- /dev/null +++ b/src/ServicePulse.Tests/ServicePulse.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/src/ServicePulse.sln b/src/ServicePulse.sln index 2d35b6f089..1114063682 100644 --- a/src/ServicePulse.sln +++ b/src/ServicePulse.sln @@ -36,6 +36,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServicePulse", "ServicePulse\ServicePulse.csproj", "{084808CF-4B93-4097-BFA1-2604AA7B4594}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServicePulse.Tests", "ServicePulse.Tests\ServicePulse.Tests.csproj", "{9B75F526-937E-4B25-A9F6-2862129993EB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -88,6 +90,10 @@ Global {084808CF-4B93-4097-BFA1-2604AA7B4594}.Debug|Any CPU.Build.0 = Debug|Any CPU {084808CF-4B93-4097-BFA1-2604AA7B4594}.Release|Any CPU.ActiveCfg = Release|Any CPU {084808CF-4B93-4097-BFA1-2604AA7B4594}.Release|Any CPU.Build.0 = Release|Any CPU + {9B75F526-937E-4B25-A9F6-2862129993EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B75F526-937E-4B25-A9F6-2862129993EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B75F526-937E-4B25-A9F6-2862129993EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B75F526-937E-4B25-A9F6-2862129993EB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/ServicePulse/ConstantsFile.cs b/src/ServicePulse/ConstantsFile.cs index 9ab55df879..fc76fb95da 100644 --- a/src/ServicePulse/ConstantsFile.cs +++ b/src/ServicePulse/ConstantsFile.cs @@ -18,8 +18,23 @@ public static string GetContent(Settings settings) } else { - serviceControlUrl = settings.ServiceControlUri.ToString(); - monitoringUrl = settings.MonitoringUri?.ToString() ?? "!"; + // When HTTPS is enabled, upgrade backend URLs to HTTPS + var scUri = settings.HttpsEnabled + ? UpgradeToHttps(settings.ServiceControlUri) + : settings.ServiceControlUri; + serviceControlUrl = scUri.ToString(); + + if (settings.MonitoringUri != null) + { + var mUri = settings.HttpsEnabled + ? UpgradeToHttps(settings.MonitoringUri) + : settings.MonitoringUri; + monitoringUrl = mUri.ToString(); + } + else + { + monitoringUrl = "!"; + } } var constantsFile = $$""" @@ -51,4 +66,20 @@ static string GetVersionInformation() return majorMinorPatch; } + + static Uri UpgradeToHttps(Uri uri) + { + if (uri.Scheme == Uri.UriSchemeHttps) + { + return uri; + } + + var builder = new UriBuilder(uri) + { + Scheme = Uri.UriSchemeHttps, + Port = uri.IsDefaultPort ? -1 : uri.Port + }; + + return builder.Uri; + } } diff --git a/src/ServicePulse/LoggerUtil.cs b/src/ServicePulse/LoggerUtil.cs new file mode 100644 index 0000000000..48e23d8020 --- /dev/null +++ b/src/ServicePulse/LoggerUtil.cs @@ -0,0 +1,26 @@ +namespace ServicePulse; + +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +static class LoggerUtil +{ + static readonly ConcurrentDictionary factories = new(); + + static ILoggerFactory GetOrCreateLoggerFactory(LogLevel level) + { + if (!factories.TryGetValue(level, out var factory)) + { + factory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(level)); + factories[level] = factory; + } + + return factory; + } + + public static ILogger CreateStaticLogger(LogLevel level = LogLevel.Information) + { + var factory = GetOrCreateLoggerFactory(level); + return factory.CreateLogger(); + } +} diff --git a/src/ServicePulse/Program.cs b/src/ServicePulse/Program.cs index a4c304be50..9b4ca85024 100644 --- a/src/ServicePulse/Program.cs +++ b/src/ServicePulse/Program.cs @@ -6,6 +6,9 @@ var settings = Settings.GetFromEnvironmentVariables(); +// Configure Kestrel for HTTPS if enabled +builder.ConfigureHttps(settings); + if (settings.EnableReverseProxy) { var (routes, clusters) = ReverseProxy.GetConfiguration(settings); @@ -14,6 +17,12 @@ var app = builder.Build(); +// Forwarded headers must be first in the pipeline for correct scheme/host detection +app.UseForwardedHeaders(settings); + +// HTTPS middleware (HSTS and redirect) +app.UseHttpsConfiguration(settings); + var manifestEmbeddedFileProvider = new ManifestEmbeddedFileProvider(typeof(Program).Assembly, "wwwroot"); var fileProvider = new CompositeFileProvider(builder.Environment.WebRootFileProvider, manifestEmbeddedFileProvider); @@ -37,3 +46,8 @@ }); app.Run(); + +// Make Program class accessible for WebApplicationFactory in tests +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +public partial class Program { } +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member diff --git a/src/ServicePulse/ReverseProxy.cs b/src/ServicePulse/ReverseProxy.cs index 90ccc591d9..61e11c833f 100644 --- a/src/ServicePulse/ReverseProxy.cs +++ b/src/ServicePulse/ReverseProxy.cs @@ -10,12 +10,17 @@ public static (List routes, List clusters) GetConfig var routes = new List(); var clusters = new List(); + // When HTTPS is enabled on ServicePulse, assume ServiceControl also uses HTTPS + var serviceControlUri = settings.HttpsEnabled + ? UpgradeToHttps(settings.ServiceControlUri) + : settings.ServiceControlUri; + var serviceControlInstance = new ClusterConfig { ClusterId = "serviceControlInstance", Destinations = new Dictionary { - { "instance", new DestinationConfig { Address = settings.ServiceControlUri.ToString() } } + { "instance", new DestinationConfig { Address = serviceControlUri.ToString() } } } }; var serviceControlRoute = new RouteConfig @@ -33,12 +38,17 @@ public static (List routes, List clusters) GetConfig if (settings.MonitoringUri != null) { + // When HTTPS is enabled on ServicePulse, assume Monitoring also uses HTTPS + var monitoringUri = settings.HttpsEnabled + ? UpgradeToHttps(settings.MonitoringUri) + : settings.MonitoringUri; + var monitoringInstance = new ClusterConfig { ClusterId = "monitoringInstance", Destinations = new Dictionary { - { "instance", new DestinationConfig { Address = settings.MonitoringUri.ToString() } } + { "instance", new DestinationConfig { Address = monitoringUri.ToString() } } } }; @@ -55,4 +65,20 @@ public static (List routes, List clusters) GetConfig return (routes, clusters); } + + static Uri UpgradeToHttps(Uri uri) + { + if (uri.Scheme == Uri.UriSchemeHttps) + { + return uri; + } + + var builder = new UriBuilder(uri) + { + Scheme = Uri.UriSchemeHttps, + Port = uri.IsDefaultPort ? -1 : uri.Port + }; + + return builder.Uri; + } } diff --git a/src/ServicePulse/ServicePulse.csproj b/src/ServicePulse/ServicePulse.csproj index bd2c0b7f43..41f67538e4 100644 --- a/src/ServicePulse/ServicePulse.csproj +++ b/src/ServicePulse/ServicePulse.csproj @@ -9,9 +9,13 @@ - + + + + + - + diff --git a/src/ServicePulse/Settings.cs b/src/ServicePulse/Settings.cs index 51717b5cbe..494136b38e 100644 --- a/src/ServicePulse/Settings.cs +++ b/src/ServicePulse/Settings.cs @@ -1,9 +1,14 @@ namespace ServicePulse; +using System.Net; using System.Text.Json; +using Microsoft.Extensions.Logging; +using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; class Settings { + static readonly ILogger logger = LoggerUtil.CreateStaticLogger(); + public required Uri ServiceControlUri { get; init; } public required Uri? MonitoringUri { get; init; } @@ -14,6 +19,66 @@ class Settings public required bool EnableReverseProxy { get; init; } + /// + /// Indicates whether forwarded headers processing for reverse proxy scenarios is enabled. + /// + public required bool ForwardedHeadersEnabled { get; init; } + + /// + /// Indicates whether all proxies are trusted for forwarded headers. + /// + public required bool ForwardedHeadersTrustAllProxies { get; init; } + + /// + /// List of known proxy IP addresses for forwarded headers. + /// + public required IReadOnlyList ForwardedHeadersKnownProxies { get; init; } + + /// + /// List of known networks for forwarded headers. + /// + public required IReadOnlyList ForwardedHeadersKnownNetworks { get; init; } + + /// + /// Indicates whether HTTPS is enabled. + /// + public required bool HttpsEnabled { get; init; } + + /// + /// Path to the HTTPS certificate file. + /// + public required string? HttpsCertificatePath { get; init; } + + /// + /// Password for the HTTPS certificate. + /// + public required string? HttpsCertificatePassword { get; init; } + + /// + /// Indicates whether HTTP requests should be redirected to HTTPS. + /// + public required bool HttpsRedirectHttpToHttps { get; init; } + + /// + /// The HTTPS port to use. + /// + public required int? HttpsPort { get; init; } + + /// + /// Indicates whether HSTS is enabled. + /// + public required bool HttpsEnableHsts { get; init; } + + /// + /// The max age for HSTS in seconds. + /// + public required int HttpsHstsMaxAgeSeconds { get; init; } + + /// + /// Indicates whether HSTS should include subdomains. + /// + public required bool HttpsHstsIncludeSubDomains { get; init; } + public static Settings GetFromEnvironmentVariables() { var serviceControlUrl = Environment.GetEnvironmentVariable("SERVICECONTROL_URL") ?? "http://localhost:33333/api/"; @@ -50,14 +115,148 @@ public static Settings GetFromEnvironmentVariables() enableReverseProxy = true; } - return new Settings + var forwardedHeadersEnabled = ParseBool( + Environment.GetEnvironmentVariable("SERVICEPULSE_FORWARDEDHEADERS_ENABLED"), + defaultValue: true); + + var forwardedHeadersTrustAllProxies = ParseBool( + Environment.GetEnvironmentVariable("SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES"), + defaultValue: true); + + var forwardedHeadersKnownProxies = ParseIpAddresses( + Environment.GetEnvironmentVariable("SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES")); + var forwardedHeadersKnownNetworks = ParseNetworks( + Environment.GetEnvironmentVariable("SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS")); + + // If specific proxies or networks are configured, disable trust all proxies + if (forwardedHeadersKnownProxies.Count > 0 || forwardedHeadersKnownNetworks.Count > 0) + { + forwardedHeadersTrustAllProxies = false; + } + + // HTTPS settings + var httpsEnabled = ParseBool( + Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_ENABLED"), + defaultValue: false); + + var httpsCertificatePath = Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_CERTIFICATEPATH"); + + var httpsCertificatePassword = Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD"); + + var httpsRedirectHttpToHttps = ParseBool( + Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_REDIRECTHTTPTOHTTPS"), + defaultValue: false); + + var httpsPort = ParseNullableInt( + Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_PORT")); + + var httpsEnableHsts = ParseBool( + Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_ENABLEHSTS"), + defaultValue: false); + + var httpsHstsMaxAgeSeconds = ParseInt( + Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_HSTSMAXAGESECONDS"), + defaultValue: 31536000); // 1 year + + var httpsHstsIncludeSubDomains = ParseBool( + Environment.GetEnvironmentVariable("SERVICEPULSE_HTTPS_HSTSINCLUDESUBDOMAINS"), + defaultValue: false); + + var settings = new Settings { ServiceControlUri = serviceControlUri, MonitoringUri = monitoringUri, DefaultRoute = defaultRoute, ShowPendingRetry = showPendingRetry, - EnableReverseProxy = enableReverseProxy + EnableReverseProxy = enableReverseProxy, + ForwardedHeadersEnabled = forwardedHeadersEnabled, + ForwardedHeadersTrustAllProxies = forwardedHeadersTrustAllProxies, + ForwardedHeadersKnownProxies = forwardedHeadersKnownProxies, + ForwardedHeadersKnownNetworks = forwardedHeadersKnownNetworks, + HttpsEnabled = httpsEnabled, + HttpsCertificatePath = httpsCertificatePath, + HttpsCertificatePassword = httpsCertificatePassword, + HttpsRedirectHttpToHttps = httpsRedirectHttpToHttps, + HttpsPort = httpsPort, + HttpsEnableHsts = httpsEnableHsts, + HttpsHstsMaxAgeSeconds = httpsHstsMaxAgeSeconds, + HttpsHstsIncludeSubDomains = httpsHstsIncludeSubDomains }; + + settings.LogForwardedHeadersConfiguration(); + settings.ValidateHttpsCertificateConfiguration(); + settings.LogHttpsConfiguration(); + + return settings; + } + + static bool ParseBool(string? value, bool defaultValue) + { + if (bool.TryParse(value, out var result)) + { + return result; + } + return defaultValue; + } + + static int ParseInt(string? value, int defaultValue) + { + if (int.TryParse(value, out var result)) + { + return result; + } + return defaultValue; + } + + static int? ParseNullableInt(string? value) + { + if (int.TryParse(value, out var result)) + { + return result; + } + return null; + } + + static IReadOnlyList ParseIpAddresses(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + var addresses = new List(); + var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var part in parts) + { + if (IPAddress.TryParse(part, out var address)) + { + addresses.Add(address); + } + } + + return addresses; + } + + static IReadOnlyList ParseNetworks(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + var networks = new List(); + var parts = value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var part in parts) + { + if (IPNetwork.TryParse(part, out var network)) + { + networks.Add(network); + } + } + + return networks; } static string? ParseLegacyMonitoringValue(string? value) @@ -95,4 +294,130 @@ class MonitoringUrls { public string[] Addresses { get; set; } = []; } + + /// + /// Logs the forwarded headers configuration and warns about potential misconfigurations. + /// + void LogForwardedHeadersConfiguration() + { + var hasProxyConfig = ForwardedHeadersKnownProxies.Count > 0 || ForwardedHeadersKnownNetworks.Count > 0; + var knownProxiesDisplay = ForwardedHeadersKnownProxies.Count > 0 + ? string.Join(", ", ForwardedHeadersKnownProxies) + : "(none)"; + var knownNetworksDisplay = ForwardedHeadersKnownNetworks.Count > 0 + ? string.Join(", ", ForwardedHeadersKnownNetworks) + : "(none)"; + + logger.LogInformation("Forwarded headers settings: Enabled={Enabled}, TrustAllProxies={TrustAllProxies}, KnownProxies={KnownProxies}, KnownNetworks={KnownNetworks}", + ForwardedHeadersEnabled, ForwardedHeadersTrustAllProxies, knownProxiesDisplay, knownNetworksDisplay); + + // Warn about potential misconfigurations + if (!ForwardedHeadersEnabled && hasProxyConfig) + { + logger.LogWarning("Forwarded headers processing is disabled but proxy configuration is present. SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES and SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS settings will be ignored"); + } + + if (ForwardedHeadersEnabled && ForwardedHeadersTrustAllProxies) + { + logger.LogWarning("Forwarded headers is configured to trust all proxies. Any client can spoof X-Forwarded-* headers. Consider configuring SERVICEPULSE_FORWARDEDHEADERS_KNOWNPROXIES or SERVICEPULSE_FORWARDEDHEADERS_KNOWNNETWORKS for production environments"); + } + + if (!ForwardedHeadersEnabled && ForwardedHeadersTrustAllProxies) + { + logger.LogWarning("Forwarded headers is disabled but TrustAllProxies is true. SERVICEPULSE_FORWARDEDHEADERS_TRUSTALLPROXIES setting will be ignored"); + } + + if (ForwardedHeadersEnabled && !ForwardedHeadersTrustAllProxies && !hasProxyConfig) + { + logger.LogWarning("Forwarded headers is enabled but no trusted proxies are configured. X-Forwarded-* headers will not be processed"); + } + + if (ForwardedHeadersEnabled && ForwardedHeadersKnownProxies.Count > 0 && ForwardedHeadersKnownNetworks.Count > 0) + { + logger.LogInformation("Forwarded headers has both KnownProxies and KnownNetworks configured. Both settings will be used to determine trusted proxies"); + } + } + + /// + /// Validates the HTTPS certificate configuration when HTTPS is enabled. + /// + /// Thrown when HTTPS is enabled but the certificate path is not set or the file does not exist. + void ValidateHttpsCertificateConfiguration() + { + if (!HttpsEnabled) + { + return; + } + + if (string.IsNullOrWhiteSpace(HttpsCertificatePath)) + { + var message = "SERVICEPULSE_HTTPS_CERTIFICATEPATH is required when HTTPS is enabled. Please specify the path to a valid HTTPS certificate file (.pfx or .pem)"; + logger.LogCritical(message); + throw new InvalidOperationException(message); + } + + if (!File.Exists(HttpsCertificatePath)) + { + var message = $"SERVICEPULSE_HTTPS_CERTIFICATEPATH does not exist. Current value: '{HttpsCertificatePath}'"; + logger.LogCritical(message); + throw new InvalidOperationException(message); + } + } + + /// + /// Logs the HTTPS configuration and warns about potential misconfigurations. + /// + void LogHttpsConfiguration() + { + var httpsPortDisplay = HttpsPort.HasValue ? HttpsPort.Value.ToString() : "(null)"; + + logger.LogInformation("HTTPS settings: Enabled={Enabled}, CertificatePath={CertificatePath}, HasCertificatePassword={HasCertificatePassword}, RedirectHttpToHttps={RedirectHttpToHttps}, HttpsPort={HttpsPort}, EnableHsts={EnableHsts}, HstsMaxAgeSeconds={HstsMaxAgeSeconds}, HstsIncludeSubDomains={HstsIncludeSubDomains}", + HttpsEnabled, HttpsCertificatePath, !string.IsNullOrEmpty(HttpsCertificatePassword), HttpsRedirectHttpToHttps, httpsPortDisplay, HttpsEnableHsts, HttpsHstsMaxAgeSeconds, HttpsHstsIncludeSubDomains); + + if (HttpsEnabled && EnableReverseProxy) + { + logger.LogInformation("HTTPS is enabled with reverse proxy. Backend connections to ServiceControl and Monitoring will be upgraded to HTTPS"); + } + + if (HttpsEnabled && !EnableReverseProxy) + { + logger.LogInformation("HTTPS is enabled without reverse proxy. ServiceControl and Monitoring URLs will be upgraded to HTTPS"); + } + + // Warn about potential misconfigurations + if (!HttpsEnabled) + { + logger.LogWarning("Kestrel HTTPS is disabled. Local communication will not be encrypted unless TLS is terminated by a reverse proxy"); + } + + if (!HttpsEnabled && (!string.IsNullOrEmpty(HttpsCertificatePath) || !string.IsNullOrEmpty(HttpsCertificatePassword))) + { + logger.LogWarning("HTTPS is disabled but certificate settings are provided. SERVICEPULSE_HTTPS_CERTIFICATEPATH and SERVICEPULSE_HTTPS_CERTIFICATEPASSWORD will be ignored"); + } + + if (HttpsRedirectHttpToHttps && !HttpsEnableHsts) + { + logger.LogWarning("HTTPS redirect is enabled but HSTS is disabled. Consider enabling SERVICEPULSE_HTTPS_ENABLEHSTS for better security"); + } + + if (!HttpsEnabled && HttpsEnableHsts) + { + logger.LogWarning("HSTS is enabled but Kestrel HTTPS is disabled. HSTS headers will only be effective if TLS is terminated by a reverse proxy"); + } + + if (!HttpsEnabled && HttpsRedirectHttpToHttps) + { + logger.LogWarning("HTTPS redirect is enabled but Kestrel HTTPS is disabled. Redirect will only work if TLS is terminated by a reverse proxy"); + } + + if (HttpsPort.HasValue && !HttpsRedirectHttpToHttps) + { + logger.LogWarning("SERVICEPULSE_HTTPS_PORT is configured but HTTPS redirect is disabled. The port setting will be ignored"); + } + + if (HttpsRedirectHttpToHttps && !HttpsPort.HasValue) + { + logger.LogInformation("SERVICEPULSE_HTTPS_PORT is not configured. HTTPS redirect will be ignored"); + } + } } diff --git a/src/ServicePulse/WebApplicationBuilderExtensions.cs b/src/ServicePulse/WebApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..60bcd32cba --- /dev/null +++ b/src/ServicePulse/WebApplicationBuilderExtensions.cs @@ -0,0 +1,72 @@ +namespace ServicePulse; + +using System.Security.Cryptography.X509Certificates; + +static class WebApplicationBuilderExtensions +{ + public static void ConfigureHttps(this WebApplicationBuilder builder, Settings settings) + { + // EnableHsts is disabled by default + // Hsts is automatically disabled in Development environments + if (settings.HttpsEnableHsts) + { + builder.Services.AddHsts(options => + { + options.MaxAge = TimeSpan.FromSeconds(settings.HttpsHstsMaxAgeSeconds); + options.IncludeSubDomains = settings.HttpsHstsIncludeSubDomains; + }); + } + + // RedirectHttpToHttps is disabled by default. HttpsPort is null by default. + if (settings.HttpsRedirectHttpToHttps && settings.HttpsPort.HasValue) + { + builder.Services.AddHttpsRedirection(options => + { + options.HttpsPort = settings.HttpsPort.Value; + }); + } + + // Kestrel HTTPS is disabled by default + if (settings.HttpsEnabled) + { + var certificate = LoadCertificate(settings); + + builder.WebHost.ConfigureKestrel(serverOptions => + { + serverOptions.ConfigureHttpsDefaults(httpsOptions => + { + httpsOptions.ServerCertificate = certificate; + }); + }); + + // Change URL scheme to HTTPS when HTTPS is enabled. + // If ASPNETCORE_URLS is set, convert http:// to https://. + // Otherwise, use a default HTTPS URL. + var configuredUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS"); + if (!string.IsNullOrEmpty(configuredUrls)) + { + var httpsUrls = configuredUrls.Replace("http://", "https://"); + builder.WebHost.UseUrls(httpsUrls); + } + else + { + builder.WebHost.UseUrls("https://*:5291"); + } + } + } + + static X509Certificate2 LoadCertificate(Settings settings) + { + var certPath = settings.HttpsCertificatePath + ?? throw new InvalidOperationException("HTTPS is enabled but HTTPS_CERTIFICATEPATH is not set."); + + if (!File.Exists(certPath)) + { + throw new FileNotFoundException($"Certificate file not found: {certPath}"); + } + + return string.IsNullOrEmpty(settings.HttpsCertificatePassword) + ? new X509Certificate2(certPath) + : new X509Certificate2(certPath, settings.HttpsCertificatePassword); + } +} diff --git a/src/ServicePulse/WebApplicationExtensions.cs b/src/ServicePulse/WebApplicationExtensions.cs new file mode 100644 index 0000000000..3f2f76da01 --- /dev/null +++ b/src/ServicePulse/WebApplicationExtensions.cs @@ -0,0 +1,101 @@ +namespace ServicePulse; + +using Microsoft.AspNetCore.HttpOverrides; + +static class WebApplicationExtensions +{ + public static void UseForwardedHeaders(this WebApplication app, Settings 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.ForwardedHeadersKnownProxies.Select(p => p.ToString()).ToArray(); + var knownNetworks = settings.ForwardedHeadersKnownNetworks.Select(n => $"{n.Prefix}/{n.PrefixLength}").ToArray(); + + return new + { + processed = new { scheme, host, remoteIpAddress }, + rawHeaders = new { xForwardedFor, xForwardedProto, xForwardedHost }, + configuration = new + { + enabled = settings.ForwardedHeadersEnabled, + trustAllProxies = settings.ForwardedHeadersTrustAllProxies, + knownProxies, + knownNetworks + } + }; + }); + } + + // Forwarded headers processing is enabled by default + if (!settings.ForwardedHeadersEnabled) + { + return; + } + + // Attempt to process all forwarded headers + var options = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.All + }; + + // Clear default loopback-only restrictions + options.KnownProxies.Clear(); + options.KnownNetworks.Clear(); + + // Enabled by default + if (settings.ForwardedHeadersTrustAllProxies) + { + // Trust all proxies: remove hop limit + options.ForwardLimit = null; + } + else + { + // Only trust explicitly configured proxies and networks + foreach (var proxy in settings.ForwardedHeadersKnownProxies) + { + options.KnownProxies.Add(proxy); + } + + foreach (var network in settings.ForwardedHeadersKnownNetworks) + { + options.KnownNetworks.Add(network); + } + } + + app.UseForwardedHeaders(options); + } + + public static void UseHttpsConfiguration(this WebApplication app, Settings settings) + { + // EnableHsts is disabled by default + // Hsts is automatically disabled in Development environments + if (settings.HttpsEnableHsts && !app.Environment.IsDevelopment()) + { + app.UseHsts(); + } + + // RedirectHttpToHttps is disabled by default + if (settings.HttpsRedirectHttpToHttps) + { + app.UseHttpsRedirection(); + } + } +}