Repair cross-platform lockfile and verify secure production login behind Nginx
This commit is contained in:
parent
49d02be623
commit
41f8d805e8
19
.github/workflows/web.yml
vendored
19
.github/workflows/web.yml
vendored
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<ForwardedHeadersOptions>(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<IStore>();
|
||||
await store.Initialize();
|
||||
if (args.Contains("--bootstrap"))
|
||||
if (bootstrap)
|
||||
{
|
||||
var email = Environment.GetEnvironmentVariable("BOOTSTRAP_EMAIL")?.Trim().ToLowerInvariant() ?? "";
|
||||
var password = Environment.GetEnvironmentVariable("BOOTSTRAP_PASSWORD") ?? "";
|
||||
|
||||
54
tests/production_smoke.py
Normal file
54
tests/production_smoke.py
Normal file
@ -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.")
|
||||
21
web/package-lock.json
generated
21
web/package-lock.json
generated
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user