From 41f8d805e84f20287ca02cdd9d6ceeaaf82bdca7 Mon Sep 17 00:00:00 2001 From: wolf-demon Date: Wed, 9 Sep 2026 14:23:10 +0100 Subject: [PATCH] Repair cross-platform lockfile and verify secure production login behind Nginx --- .github/workflows/web.yml | 19 +++++++++++++ compose.yml | 9 ++++++- docs/deployment.md | 2 ++ src/GuestOps.Api/Program.cs | 17 ++++++++++-- tests/production_smoke.py | 54 +++++++++++++++++++++++++++++++++++++ web/package-lock.json | 21 ++++++++++++--- 6 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 tests/production_smoke.py diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 61fbcc6..240e31b 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -45,6 +45,25 @@ jobs: - name: Package reviewed images if: github.event_name != 'pull_request' run: docker save guestops-api:${{ github.sha }} guestops-worker:${{ github.sha }} | gzip > guestops-images.tar.gz + - name: Smoke test production containers and restart persistence + env: + GUESTOPS_API_IMAGE: guestops-api:${{ github.sha }} + GUESTOPS_WORKER_IMAGE: guestops-worker:${{ github.sha }} + BOOTSTRAP_EMAIL: ci-owner@example.invalid + BOOTSTRAP_HOTEL: CI test hotel + run: | + export MONGO_ROOT_PASSWORD=$(openssl rand -hex 32) + export MONGO_APP_PASSWORD=$(openssl rand -hex 32) + export BOOTSTRAP_PASSWORD=$(openssl rand -hex 24) + trap 'docker compose down --volumes' EXIT + docker compose config --quiet + docker compose up -d --no-build + curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health + docker compose run --rm --no-deps -e BOOTSTRAP_EMAIL -e BOOTSTRAP_HOTEL -e BOOTSTRAP_PASSWORD api --bootstrap + python3 tests/production_smoke.py + docker compose restart api worker + curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health + python3 tests/production_smoke.py --read - uses: actions/upload-artifact@v4 if: github.event_name != 'pull_request' with: diff --git a/compose.yml b/compose.yml index ce6c37e..548a035 100644 --- a/compose.yml +++ b/compose.yml @@ -20,7 +20,8 @@ services: environment: <<: *app-env ASPNETCORE_ENVIRONMENT: Production - AllowedHosts: sandbox-guestops.futuresens.co.uk;localhost + AllowedHosts: sandbox-guestops.futuresens.co.uk;localhost;127.0.0.1 + Proxy__KnownAddress: ${GUESTOPS_GATEWAY:-172.30.87.1} volumes: ["app-keys:/var/lib/guestops/keys"] depends_on: mongo: { condition: service_healthy } @@ -59,3 +60,9 @@ services: volumes: mongo-data: app-keys: +networks: + default: + ipam: + config: + - subnet: ${GUESTOPS_SUBNET:-172.30.87.0/24} + gateway: ${GUESTOPS_GATEWAY:-172.30.87.1} diff --git a/docs/deployment.md b/docs/deployment.md index ebcc7c0..09bd5e8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -52,6 +52,8 @@ Verify the hostname's DNS resolves to this server. Obtain a valid certificate fo Merge `deploy/nginx.conf` into the existing host configuration, check with `nginx -t`, then reload Nginx. Confirm HTTPS serves the login page. API cookies are always Secure outside preview mode; logging in through plain HTTP is intentionally unsupported. +Compose reserves the private bridge `172.30.87.0/24`, with gateway `172.30.87.1`. The API trusts the host gateway's `X-Forwarded-Proto` header so Nginx's HTTPS connections receive secure session and CSRF cookies. Check for an existing network using that range. If it conflicts, set both `GUESTOPS_SUBNET` and `GUESTOPS_GATEWAY` in `.env` to a free matching subnet and gateway. Do not replace this with unrestricted forwarded-header trust. + The initial rate limiter keys off the direct peer address. Behind this loopback reverse proxy it is shared across users (10 login attempts/minute), which is conservative for a pilot. Before scaling, configure explicitly trusted forwarded headers and per-account/IP rate limits; never trust arbitrary client-supplied forwarding headers. ## 5. Configure Google diff --git a/src/GuestOps.Api/Program.cs b/src/GuestOps.Api/Program.cs index 5ebaea6..3bbe651 100644 --- a/src/GuestOps.Api/Program.cs +++ b/src/GuestOps.Api/Program.cs @@ -8,8 +8,11 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.HttpOverrides; +using System.Net; -var builder = WebApplication.CreateBuilder(args); +var bootstrap = args.Contains("--bootstrap"); +var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap").ToArray()); builder.Logging.ClearProviders(); builder.Logging.AddConsole(); builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning); @@ -38,6 +41,15 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc }; }); builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner"))); +builder.Services.Configure(o => +{ + // Trust only HTTPS information from the configured host reverse proxy. + // Client IP forwarding remains disabled until per-client limits are introduced. + o.ForwardedHeaders = ForwardedHeaders.XForwardedProto; + o.ForwardLimit = 1; + if (builder.Configuration["Proxy:KnownAddress"] is { Length: > 0 } address) + o.KnownProxies.Add(IPAddress.Parse(address)); +}); builder.Services.AddAntiforgery(o => { o.HeaderName = "X-CSRF-TOKEN"; o.Cookie.Name = "guestops.csrf"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict; o.Cookie.SecurePolicy = preview ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; }); builder.Services.AddRateLimiter(o => { @@ -45,9 +57,10 @@ builder.Services.AddRateLimiter(o => o.AddPolicy("login", context => RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new() { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); }); var app = builder.Build(); +app.UseForwardedHeaders(); var store = app.Services.GetRequiredService(); await store.Initialize(); -if (args.Contains("--bootstrap")) +if (bootstrap) { var email = Environment.GetEnvironmentVariable("BOOTSTRAP_EMAIL")?.Trim().ToLowerInvariant() ?? ""; var password = Environment.GetEnvironmentVariable("BOOTSTRAP_PASSWORD") ?? ""; diff --git a/tests/production_smoke.py b/tests/production_smoke.py new file mode 100644 index 0000000..1c30c3d --- /dev/null +++ b/tests/production_smoke.py @@ -0,0 +1,54 @@ +"""Exercise disposable CI containers through the host's trusted proxy address. + +The forwarded HTTPS header simulates Nginx TLS termination; this is never run +against an existing hotel database. Cookie values and credentials are not logged. +""" +import json +import os +import sys +from http.cookies import SimpleCookie +from urllib.request import Request, urlopen +from urllib.error import HTTPError + +cookies = SimpleCookie() +csrf = "" + + +def request(path, method="GET", data=None, expected=200): + headers = {"X-Forwarded-Proto": "https", "Content-Type": "application/json", + "Cookie": "; ".join(f"{k}={v.value}" for k, v in cookies.items()), + "X-CSRF-TOKEN": csrf} + req = Request("http://127.0.0.1:8080" + path, method=method, headers=headers, + data=json.dumps(data).encode() if data is not None else None) + try: + response = urlopen(req, timeout=15) + except HTTPError as error: + response = error + assert response.status == expected, f"{method} {path}: {response.status}, expected {expected}" + for value in response.headers.get_all("Set-Cookie", []): + parsed = SimpleCookie(value) + for cookie in parsed.values(): + assert cookie["secure"] and cookie["httponly"], "Authentication cookies must be secure and HttpOnly" + cookies.load(value) + body = response.read().decode() + return json.loads(body) if "application/json" in response.headers.get("Content-Type", "") else body + + +assert "root" in request("/"), "Container must serve the built React interface" +request("/api/hotel", expected=401) +session = request("/api/session") +assert session["preview"] is False and session["user"] is None +csrf = session["csrfToken"] +request("/api/auth/login", "POST", {"email": os.environ["BOOTSTRAP_EMAIL"], "password": os.environ["BOOTSTRAP_PASSWORD"]}) +session = request("/api/session") +assert session["user"]["role"] == "Owner" +csrf = session["csrfToken"] +hotel = request("/api/hotel") +if "--read" in sys.argv: + assert hotel["signature"] == "Persisted across container restart" +else: + hotel["signature"] = "Persisted across container restart" + request("/api/hotel", "PUT", hotel) +request("/api/auth/logout", "POST") +request("/api/hotel", expected=401) +print("Production smoke checks passed: built UI, secure cookies, owner login, MongoDB settings and logout.") diff --git a/web/package-lock.json b/web/package-lock.json index d431bca..029e698 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -252,6 +252,23 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", @@ -798,10 +815,6 @@ "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, - "node_modules/rolldown/node_modules/@rolldown/binding-openharmony-arm64": { - "dev": true, - "optional": true - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",