From aaa16c2e1c88aa4b449607ea0fc412da490809fc Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 5 Jul 2026 13:31:21 -0400 Subject: [PATCH] Always use HTTPS for e2e tests --- internal/e2e/harness_test.go | 90 +++++++++++++++++++---------------- internal/e2e/webauthn_test.go | 11 ++--- 2 files changed, 53 insertions(+), 48 deletions(-) diff --git a/internal/e2e/harness_test.go b/internal/e2e/harness_test.go index 116af5a..f40bffa 100644 --- a/internal/e2e/harness_test.go +++ b/internal/e2e/harness_test.go @@ -26,6 +26,7 @@ package e2e import ( "context" "crypto/rand" + "crypto/tls" "encoding/json" "fmt" "net" @@ -42,6 +43,7 @@ import ( cdplog "github.com/chromedp/cdproto/log" "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/cdproto/security" "github.com/chromedp/chromedp" meshcore "github.com/meshcore-go/meshcore-go" @@ -93,8 +95,8 @@ type e2eServer struct { store *store.Store ctx context.Context ts *httptest.Server - hostURL string // http://127.0.0.1:PORT — reachable from this process - browserURL string // http://host.docker.internal:PORT — reachable from the container + hostURL string // https://127.0.0.1:PORT — reachable from this process + browserURL string // https://host.docker.internal:PORT — reachable from the container } // hostLayout names the three surfaces for a test server. The surface whose name @@ -125,26 +127,17 @@ func rootReachableHosts() hostLayout { return hostLayout{app: "app." + browserHost(), auth: "auth." + browserHost(), root: browserHost()} } -// newE2EServer stands up the app (plain HTTP) on a 0.0.0.0 listener so the -// browser container can connect back to it, and returns both address forms. An -// optional hostLayout selects which surface the browser can reach (defaults to -// the app surface). +// newE2EServer stands up the app over HTTPS (a self-signed cert) on a 0.0.0.0 +// listener so the browser container can connect back to it, and returns both +// address forms. An optional hostLayout selects which surface the browser can +// reach (defaults to the app surface). +// +// The suite is HTTPS throughout for two reasons: WebAuthn ceremonies require a +// secure context (a plain-HTTP non-localhost origin is not one), and serving TLS +// exercises the same Secure/__Host- cookie path production uses. The browser +// trusts the self-signed cert via Security.setIgnoreCertificateErrors (applied +// for every test in startBrowser); the host-side client skips verification too. func newE2EServer(t *testing.T, layout ...hostLayout) *e2eServer { - return buildE2EServer(t, false, layout...) -} - -// newE2ETLSServer is like newE2EServer but serves HTTPS (a self-signed cert) and -// marks cookies Secure. WebAuthn ceremonies require a secure context, which a -// plain-HTTP non-localhost origin is not; the browser accepts the self-signed -// cert via Security.setIgnoreCertificateErrors (see the passkey test). -func newE2ETLSServer(t *testing.T, layout ...hostLayout) *e2eServer { - return buildE2EServer(t, true, layout...) -} - -// buildE2EServer is the shared constructor. It listens first so the WebAuthn RP -// origins can carry the actual (dynamic) port, then builds the surfaces. secure -// selects HTTPS + Secure cookies. -func buildE2EServer(t *testing.T, secure bool, layout ...hostLayout) *e2eServer { t.Helper() hosts := defaultHosts() if len(layout) > 0 { @@ -161,18 +154,13 @@ func buildE2EServer(t *testing.T, secure bool, layout ...hostLayout) *e2eServer _, _ = rand.Read(masterKey[:]) idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey) - // Listen up front so the RP origins can include the concrete port. + // Listen up front so the RP origins can include the concrete (dynamic) port. ln, err := net.Listen("tcp", "0.0.0.0:0") if err != nil { t.Fatalf("listen: %v", err) } port := ln.Addr().(*net.TCPAddr).Port - - scheme := "http" - if secure { - scheme = "https" - } - origin := func(h string) string { return fmt.Sprintf("%s://%s:%d", scheme, h, port) } + origin := func(h string) string { return fmt.Sprintf("https://%s:%d", h, port) } // The server runs across three distinct hosts so the Dispatcher can tell the // surfaces apart. The browser can navigate only the one named browserHost() @@ -184,13 +172,13 @@ func buildE2EServer(t *testing.T, secure bool, layout ...hostLayout) *e2eServer RPID: browserHost(), RPDisplayName: "test", RPOrigins: []string{origin(hosts.app), origin(hosts.auth), origin(hosts.root)}, AppHost: hosts.app, AuthHost: hosts.auth, RootHost: hosts.root, - Secure: secure, + Secure: true, }) if err != nil { t.Fatalf("auth: %v", err) } srv, err := core.NewServer(st, authSvc, idSvc, &config.Config{ - PrimaryHost: hosts.app, AuthHost: hosts.auth, RootHost: hosts.root, Secure: secure, + PrimaryHost: hosts.app, AuthHost: hosts.auth, RootHost: hosts.root, Secure: true, }) if err != nil { t.Fatalf("server: %v", err) @@ -199,20 +187,16 @@ func buildE2EServer(t *testing.T, secure bool, layout ...hostLayout) *e2eServer ts := httptest.NewUnstartedServer(srv.Handler()) ts.Listener.Close() // drop the default 127.0.0.1 listener ts.Listener = ln - if secure { - ts.StartTLS() - } else { - ts.Start() - } + ts.StartTLS() t.Cleanup(ts.Close) - ts.URL = fmt.Sprintf("%s://127.0.0.1:%d", scheme, port) + ts.URL = fmt.Sprintf("https://127.0.0.1:%d", port) return &e2eServer{ store: st, ctx: ctx, ts: ts, hostURL: ts.URL, - browserURL: fmt.Sprintf("%s://%s:%d", scheme, browserHost(), port), + browserURL: fmt.Sprintf("https://%s:%d", browserHost(), port), } } @@ -237,8 +221,13 @@ func (e *e2eServer) login(t *testing.T, username string) (*store.User, *http.Coo jar, _ := cookiejar.New(nil) req, _ := http.NewRequest(http.MethodGet, e.hostURL+"/session/callback?code="+code+"&state=s1", nil) - req.AddCookie(&http.Cookie{Name: "mt_state", Value: "s1"}) - client := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + req.AddCookie(&http.Cookie{Name: stateCookieName, Value: "s1"}) + // The server uses a self-signed cert; skip verification for the host client. + client := &http.Client{ + Jar: jar, + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // G402: test client to the harness's own self-signed server + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } resp, err := client.Do(req) if err != nil { t.Fatalf("session callback: %v", err) @@ -247,7 +236,7 @@ func (e *e2eServer) login(t *testing.T, username string) (*store.User, *http.Coo base, _ := url.Parse(e.hostURL) for _, c := range jar.Cookies(base) { - if c.Name == "meshtender_session" { + if c.Name == sessionCookieName { return u, c } } @@ -255,6 +244,13 @@ func (e *e2eServer) login(t *testing.T, username string) (*store.User, *http.Coo return nil, nil } +// Cookie names as the server emits them over HTTPS (Secure ⇒ __Host- prefix; see +// auth.cookieName). The suite always serves TLS, so these are stable. +const ( + sessionCookieName = "__Host-meshtender_session" + stateCookieName = "__Host-mt_state" +) + // newRepeater creates a repeater owned by ownerID with a valid MeshCore key. func (e *e2eServer) newRepeater(t *testing.T, ownerID int64, name string) *store.Repeater { t.Helper() @@ -333,6 +329,14 @@ func startBrowser(t *testing.T) (context.Context, context.CancelFunc, *consoleWa }) cancel := func() { cancelCtx(); cancelAlloc() } + + // The suite serves a self-signed cert; trust it for every test (also gives + // WebAuthn its required secure context). This first Run also creates the + // target the listener above attaches to. + if err := chromedp.Run(ctx, security.SetIgnoreCertificateErrors(true)); err != nil { + cancel() + t.Fatalf("ignore certificate errors: %v", err) + } return ctx, cancel, watch } @@ -372,10 +376,14 @@ func devtoolsWebSocket(t *testing.T) string { // so the browser is authenticated before it navigates. func setSessionCookie(c *http.Cookie) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { + // __Host- prefixed cookies must be host-only (no Domain), Secure, Path=/. + // WithURL scopes the cookie to browserHost as host-only (cookies ignore + // port, so the scheme+host is enough). return network.SetCookie(c.Name, c.Value). - WithDomain(browserHost()). + WithURL("https://" + browserHost()). WithPath("/"). WithHTTPOnly(true). + WithSecure(true). Do(ctx) }) } diff --git a/internal/e2e/webauthn_test.go b/internal/e2e/webauthn_test.go index b11d95b..5a1bf8d 100644 --- a/internal/e2e/webauthn_test.go +++ b/internal/e2e/webauthn_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/chromedp/cdproto/security" "github.com/chromedp/cdproto/webauthn" "github.com/chromedp/chromedp" @@ -17,13 +16,11 @@ import ( // virtualAuthenticator enables the WebAuthn CDP domain and installs a virtual // authenticator that auto-approves user presence/verification, so -// navigator.credentials.create/get succeed headlessly. It also tells the browser -// to accept the harness's self-signed cert (WebAuthn needs a secure context, and -// an HTTPS origin — even with an untrusted cert — qualifies, whereas plain HTTP -// on a non-localhost host does not). +// navigator.credentials.create/get succeed headlessly. (The secure context +// WebAuthn requires comes from the HTTPS harness; startBrowser already trusts +// its self-signed cert.) func virtualAuthenticator(ctx context.Context) error { return chromedp.Run(ctx, - security.SetIgnoreCertificateErrors(true), webauthn.Enable(), chromedp.ActionFunc(func(ctx context.Context) error { _, err := webauthn.AddVirtualAuthenticator(&webauthn.VirtualAuthenticatorOptions{ @@ -65,7 +62,7 @@ func waitForUser(t *testing.T, e *e2eServer, username string) *store.User { // account get written (the deferred-creation flow). This is the browser-level // proof of item 3 that the Go tests can't give (they can't complete a ceremony). func TestPasskeySignupCeremony(t *testing.T) { - e := newE2ETLSServer(t, authReachableHosts()) + e := newE2EServer(t, authReachableHosts()) ctx, cancel, watch := startBrowser(t) defer cancel()