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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 224 | 224 | for _, p := range githttp.Patterns { | |
| 225 | 225 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 226 | 226 | } | |
| 227 | + | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | |
| 227 | 228 | site, err := newWeb(cfg, f.API, f.Sessions, limiter) | |
| 228 | 229 | if err != nil { | |
| 229 | 230 | 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 ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "net/http" | |
| 9 | 9 | "net/netip" | |
| 10 | + | "net/url" | |
| 11 | + | "strings" | |
| 10 | 12 | "time" | |
| 11 | 13 | ||
| 14 | + | "github.com/bnema/zerowrap" | |
| 15 | + | ||
| 12 | 16 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 13 | 17 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 14 | 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 | 49 | 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 | 82 | // webSessions adapts the session use cases to web.SessionAuth: a valid token | |
| 49 | 83 | // gives the context of the owner, whose principal the API calls of the page | |
| 50 | 84 | // 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), nil | 49 | 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 token | 82 | // 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 page | 83 | // 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 ( | |||
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | 14 | "connectrpc.com/connect" | |
| 15 | + | "github.com/bnema/zerowrap" | |
| 15 | 16 | "github.com/golang-jwt/jwt/v5" | |
| 16 | 17 | "github.com/stretchr/testify/assert" | |
| 17 | 18 | "github.com/stretchr/testify/require" | |
| @@ -511,3 +512,47 @@ func TestWebOwnerIsLabelledInLogs(t *testing.T) { | |||
| 511 | 512 | browser{c.harness}.get("/secret", as(token)) | |
| 512 | 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 | + | } | |
| @@ -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 | + | } | |||