detached a658df0

feat(serve): warn at startup when browser sign-in cannot work over http

The session cookie is Secure and __Host- prefixed, so a browser drops it
from an http public URL unless the host is loopback (localhost,
127.0.0.0/8, ::1). Log a warning instead of refusing to start: pages, the
API and Git do not need the cookie.
commit
a658df08d7481cbd4218f9db4d2dcbe83e7b4704
author
brice · · 2026-10-10 19:26 UTC
committer
brice · · 2026-10-10 19:28 UTC
parent
6833c9f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +80 -0
M cmd/ermite/forge.go +1 -0
@@ -224,6 +224,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
224224 for _, p := range githttp.Patterns {
225225 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
226226 }
227+ warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
227228 site, err := newWeb(cfg, f.API, f.Sessions, limiter)
228229 if err != nil {
229230 return nil, fmt.Errorf("web: %w", err)
@@ -224,6 +224,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
224 for _, p := range githttp.Patterns {224 for _, p := range githttp.Patterns {
225 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})225 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
226 }226 }
227+ warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
227 site, err := newWeb(cfg, f.API, f.Sessions, limiter)228 site, err := newWeb(cfg, f.API, f.Sessions, limiter)
228 if err != nil {229 if err != nil {
229 return nil, fmt.Errorf("web: %w", err)230 return nil, fmt.Errorf("web: %w", err)
M cmd/ermite/web.go +34 -0
@@ -7,8 +7,12 @@ import (
77 "errors"
88 "net/http"
99 "net/netip"
10+ "net/url"
11+ "strings"
1012 "time"
1113
14+ "github.com/bnema/zerowrap"
15+
1216 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1317 "bnema.dev/ermite/internal/adapters/httpserver"
1418 "bnema.dev/ermite/internal/adapters/rpc"
@@ -45,6 +49,36 @@ func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *cl
4549 return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil
4650 }
4751
52+// sessionsNeedHTTPS reports whether browser sign-in cannot work on this public
53+// URL: the session cookie is Secure (and __Host-), which a browser keeps only
54+// from https, except on a loopback host (localhost, 127.0.0.0/8, ::1), where
55+// browsers treat http as secure.
56+func sessionsNeedHTTPS(publicURL string) bool {
57+ u, err := url.Parse(publicURL)
58+ if err != nil || !strings.EqualFold(u.Scheme, "http") {
59+ return false
60+ }
61+ host := strings.ToLower(u.Hostname())
62+ if host == "localhost" {
63+ return false
64+ }
65+ ip, err := netip.ParseAddr(host)
66+ return err != nil || !ip.Unmap().IsLoopback()
67+}
68+
69+// warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will
70+// not work on an http public URL. It does not stop the server: the pages,
71+// the API and Git do not need the cookie.
72+func warnSessionsNeedHTTPS(ctx context.Context, publicURL string) {
73+ if !sessionsNeedHTTPS(publicURL) {
74+ return
75+ }
76+ log := zerowrap.FromCtx(ctx)
77+ log.Warn().Str("public_url", publicURL).Msg(
78+ "browser sign-in cannot work: " + config.EnvPublicURL + " is http:// on a host that is not loopback, " +
79+ "and the session cookie is Secure (__Host-), so browsers drop it; serve the site over https")
80+}
81+
4882 // webSessions adapts the session use cases to web.SessionAuth: a valid token
4983 // gives the context of the owner, whose principal the API calls of the page
5084 // carry (rpc.API.InProcessClient).
@@ -7,8 +7,12 @@ import (
7 "errors"7 "errors"
8 "net/http"8 "net/http"
9 "net/netip"9 "net/netip"
10+ "net/url"
11+ "strings"
10 "time"12 "time"
11 13
14+ "github.com/bnema/zerowrap"
15+
12 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"16 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
13 "bnema.dev/ermite/internal/adapters/httpserver"17 "bnema.dev/ermite/internal/adapters/httpserver"
14 "bnema.dev/ermite/internal/adapters/rpc"18 "bnema.dev/ermite/internal/adapters/rpc"
@@ -45,6 +49,36 @@ func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *cl
45 return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil49 return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil
46 }50 }
47 51
52+// sessionsNeedHTTPS reports whether browser sign-in cannot work on this public
53+// URL: the session cookie is Secure (and __Host-), which a browser keeps only
54+// from https, except on a loopback host (localhost, 127.0.0.0/8, ::1), where
55+// browsers treat http as secure.
56+func sessionsNeedHTTPS(publicURL string) bool {
57+ u, err := url.Parse(publicURL)
58+ if err != nil || !strings.EqualFold(u.Scheme, "http") {
59+ return false
60+ }
61+ host := strings.ToLower(u.Hostname())
62+ if host == "localhost" {
63+ return false
64+ }
65+ ip, err := netip.ParseAddr(host)
66+ return err != nil || !ip.Unmap().IsLoopback()
67+}
68+
69+// warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will
70+// not work on an http public URL. It does not stop the server: the pages,
71+// the API and Git do not need the cookie.
72+func warnSessionsNeedHTTPS(ctx context.Context, publicURL string) {
73+ if !sessionsNeedHTTPS(publicURL) {
74+ return
75+ }
76+ log := zerowrap.FromCtx(ctx)
77+ log.Warn().Str("public_url", publicURL).Msg(
78+ "browser sign-in cannot work: " + config.EnvPublicURL + " is http:// on a host that is not loopback, " +
79+ "and the session cookie is Secure (__Host-), so browsers drop it; serve the site over https")
80+}
81+
48 // webSessions adapts the session use cases to web.SessionAuth: a valid token82 // webSessions adapts the session use cases to web.SessionAuth: a valid token
49 // gives the context of the owner, whose principal the API calls of the page83 // gives the context of the owner, whose principal the API calls of the page
50 // carry (rpc.API.InProcessClient).84 // carry (rpc.API.InProcessClient).
M cmd/ermite/web_session_test.go +45 -0
@@ -12,6 +12,7 @@ import (
1212 "time"
1313
1414 "connectrpc.com/connect"
15+ "github.com/bnema/zerowrap"
1516 "github.com/golang-jwt/jwt/v5"
1617 "github.com/stretchr/testify/assert"
1718 "github.com/stretchr/testify/require"
@@ -511,3 +512,47 @@ func TestWebOwnerIsLabelledInLogs(t *testing.T) {
511512 browser{c.harness}.get("/secret", as(token))
512513 assert.Contains(t, c.logs.String(), `"principal":"owner"`)
513514 }
515+
516+// GO-004: a Secure cookie is kept by browsers only from https (or a loopback
517+// host), so an http public URL on another host cannot sign anyone in.
518+func TestSessionsNeedHTTPS(t *testing.T) {
519+ for in, want := range map[string]bool{
520+ "http://forge.example": true,
521+ "http://forge.example:8080": true,
522+ "HTTP://Forge.Example": true,
523+ "http://10.0.0.5:3000": true,
524+ "http://192.168.1.2": true,
525+ "http://[2001:db8::1]:3000": true,
526+ "http://localhost.example": true,
527+ "http://127.0.0.1.example": true,
528+ "http://example.test": true,
529+ "https://forge.example": false,
530+ "https://localhost": false,
531+ "http://localhost": false,
532+ "http://LOCALHOST:3000": false,
533+ "http://127.0.0.1:3000": false,
534+ "http://127.1.2.3": false,
535+ "http://[::1]:3000": false,
536+ "http://[::ffff:127.0.0.1]:1": false,
537+ "": false,
538+ } {
539+ assert.Equal(t, want, sessionsNeedHTTPS(in), in)
540+ }
541+}
542+
543+// The warning is logged once at startup, with the harness's http://example.test
544+// as public URL, and the server starts all the same.
545+func TestStartupWarnsWhenSignInCannotWork(t *testing.T) {
546+ c := newCLIHarness(t)
547+ logs := c.logs.String()
548+ assert.Contains(t, logs, "browser sign-in cannot work")
549+ assert.Contains(t, logs, `"level":"warn"`)
550+
551+ var buf syncBuffer
552+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &buf}))
553+ warnSessionsNeedHTTPS(ctx, "https://forge.example")
554+ warnSessionsNeedHTTPS(ctx, "http://127.0.0.1:3000")
555+ assert.Empty(t, buf.String(), "no warning for https or loopback")
556+ warnSessionsNeedHTTPS(ctx, "http://forge.example")
557+ assert.Contains(t, buf.String(), "ERMITE_PUBLIC_URL")
558+}
@@ -12,6 +12,7 @@ import (
12 "time"12 "time"
13 13
14 "connectrpc.com/connect"14 "connectrpc.com/connect"
15+ "github.com/bnema/zerowrap"
15 "github.com/golang-jwt/jwt/v5"16 "github.com/golang-jwt/jwt/v5"
16 "github.com/stretchr/testify/assert"17 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
@@ -511,3 +512,47 @@ func TestWebOwnerIsLabelledInLogs(t *testing.T) {
511 browser{c.harness}.get("/secret", as(token))512 browser{c.harness}.get("/secret", as(token))
512 assert.Contains(t, c.logs.String(), `"principal":"owner"`)513 assert.Contains(t, c.logs.String(), `"principal":"owner"`)
513 }514 }
515+
516+// GO-004: a Secure cookie is kept by browsers only from https (or a loopback
517+// host), so an http public URL on another host cannot sign anyone in.
518+func TestSessionsNeedHTTPS(t *testing.T) {
519+ for in, want := range map[string]bool{
520+ "http://forge.example": true,
521+ "http://forge.example:8080": true,
522+ "HTTP://Forge.Example": true,
523+ "http://10.0.0.5:3000": true,
524+ "http://192.168.1.2": true,
525+ "http://[2001:db8::1]:3000": true,
526+ "http://localhost.example": true,
527+ "http://127.0.0.1.example": true,
528+ "http://example.test": true,
529+ "https://forge.example": false,
530+ "https://localhost": false,
531+ "http://localhost": false,
532+ "http://LOCALHOST:3000": false,
533+ "http://127.0.0.1:3000": false,
534+ "http://127.1.2.3": false,
535+ "http://[::1]:3000": false,
536+ "http://[::ffff:127.0.0.1]:1": false,
537+ "": false,
538+ } {
539+ assert.Equal(t, want, sessionsNeedHTTPS(in), in)
540+ }
541+}
542+
543+// The warning is logged once at startup, with the harness's http://example.test
544+// as public URL, and the server starts all the same.
545+func TestStartupWarnsWhenSignInCannotWork(t *testing.T) {
546+ c := newCLIHarness(t)
547+ logs := c.logs.String()
548+ assert.Contains(t, logs, "browser sign-in cannot work")
549+ assert.Contains(t, logs, `"level":"warn"`)
550+
551+ var buf syncBuffer
552+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &buf}))
553+ warnSessionsNeedHTTPS(ctx, "https://forge.example")
554+ warnSessionsNeedHTTPS(ctx, "http://127.0.0.1:3000")
555+ assert.Empty(t, buf.String(), "no warning for https or loopback")
556+ warnSessionsNeedHTTPS(ctx, "http://forge.example")
557+ assert.Contains(t, buf.String(), "ERMITE_PUBLIC_URL")
558+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close