diff --git a/internal/config/endpoints.go b/internal/config/endpoints.go index c4b2d197..c5a2e9a5 100644 --- a/internal/config/endpoints.go +++ b/internal/config/endpoints.go @@ -63,16 +63,26 @@ func GetInviteURL(token string) string { // pages do not live on the API origin, so there is nothing to fall back to, // and a share link pointing at the wrong process is worse than none. func FormsBaseURL() string { - if host := strings.TrimSpace(os.Getenv("FORMS_DOMAIN")); host != "" { - host = strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://") - host = strings.TrimRight(host, "/") - scheme := "https" - if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") { - scheme = "http" - } - return scheme + "://" + host + host := NormalizeTrackingHost(os.Getenv("FORMS_DOMAIN")) + if host == "" { + return "" } - return "" + return formsScheme(host) + "://" + host +} + +// formsScheme is https except where TLS cannot be terminated: a form page on a +// loopback or private-network host is a development or LAN install. The port is +// deliberately not a signal, because an install can terminate TLS on any port +// and inferring http from one handed an https deployment http:// share links. +func formsScheme(host string) string { + name := hostWithoutPort(NormalizeTrackingHost(host)) + if name == "localhost" || strings.HasSuffix(name, ".localhost") { + return "http" + } + if ip := net.ParseIP(strings.Trim(name, "[]")); ip != nil && (ip.IsLoopback() || ip.IsPrivate()) { + return "http" + } + return "https" } // FormsHostname is the bare host this install serves forms on. It is the @@ -97,18 +107,7 @@ func FormURLOn(host, publicID string) string { if host == "" { return GetFormURL(publicID) } - scheme := "https" - if name, port, err := net.SplitHostPort(host); err == nil { - if port != "" && port != "443" { - scheme = "http" - } - if name == "localhost" || strings.HasSuffix(name, ".localhost") { - scheme = "http" - } - } else if host == "localhost" || strings.HasSuffix(host, ".localhost") { - scheme = "http" - } - return scheme + "://" + host + "/f/" + url.PathEscape(publicID) + return formsScheme(host) + "://" + host + "/f/" + url.PathEscape(publicID) } // GetFormURL is the hosted page for one form; empty when no base is known. diff --git a/internal/config/endpoints_test.go b/internal/config/endpoints_test.go index ca9c7e4d..dfab96e9 100644 --- a/internal/config/endpoints_test.go +++ b/internal/config/endpoints_test.go @@ -2,22 +2,56 @@ package config import "testing" -// Clients dial whatever GET /v1/auth/config advertises, so every form an -// operator plausibly writes has to normalise to the Phoenix transport path. -func TestWebsocketURLNormalisation(t *testing.T) { - cases := map[string]string{ - "wss://ws.example.com": "wss://ws.example.com/socket/websocket", - "wss://ws.example.com/": "wss://ws.example.com/socket/websocket", - "wss://ws.example.com/socket": "wss://ws.example.com/socket/websocket", - "wss://ws.example.com/socket/": "wss://ws.example.com/socket/websocket", - "wss://ws.example.com/socket/websocket": "wss://ws.example.com/socket/websocket", - "ws://localhost:4000/socket/websocket": "ws://localhost:4000/socket/websocket", - "": "", +// The hosted form URL has to be reachable on every install shape: the shared +// host keeps its port (a share link that drops it points at nothing), and the +// scheme follows the host rather than the port, because an install can +// terminate TLS on any port and a ported https deployment must not be handed +// http:// links (PR #368). +func TestFormURLsFollowTheInstallHost(t *testing.T) { + for _, tc := range []struct { + formsDomain string + wantBase string + wantShare string + wantCNAME string + }{ + {"localhost:8090", "http://localhost:8090", "http://localhost:8090/f/abc", "localhost"}, + {"127.0.0.1:8090", "http://127.0.0.1:8090", "http://127.0.0.1:8090/f/abc", "127.0.0.1"}, + {"192.168.1.5:8090", "http://192.168.1.5:8090", "http://192.168.1.5:8090/f/abc", "192.168.1.5"}, + {"forms.example.com", "https://forms.example.com", "https://forms.example.com/f/abc", "forms.example.com"}, + {"forms.example.com:8443", "https://forms.example.com:8443", "https://forms.example.com:8443/f/abc", "forms.example.com"}, + {"https://Forms.Example.com/", "https://forms.example.com", "https://forms.example.com/f/abc", "forms.example.com"}, + } { + t.Run(tc.formsDomain, func(t *testing.T) { + t.Setenv("FORMS_DOMAIN", tc.formsDomain) + if got := FormsBaseURL(); got != tc.wantBase { + t.Errorf("FormsBaseURL() = %q, want %q", got, tc.wantBase) + } + if got := GetFormURL("abc"); got != tc.wantShare { + t.Errorf("GetFormURL() = %q, want %q", got, tc.wantShare) + } + // What the handler stamps on every form: the shared host, resolved + // through FormsHost, then built into a URL. + if got := FormURLOn(FormsURLHost(), "abc"); got != tc.wantShare { + t.Errorf("FormURLOn(FormsURLHost()) = %q, want %q", got, tc.wantShare) + } + // The CNAME target is a DNS name, so it never carries the port. + if got := FormsHostname(); got != tc.wantCNAME { + t.Errorf("FormsHostname() = %q, want %q", got, tc.wantCNAME) + } + }) } - for in, want := range cases { - t.Setenv("WEBSOCKET_URL", in) - if got := WebsocketURL(); got != want { - t.Errorf("WebsocketURL(%q) = %q, want %q", in, got, want) - } +} + +// A verified custom forms domain replaces the shared host and is always a bare +// name, so its links stay https whatever the install runs on. +func TestFormURLOnCustomDomain(t *testing.T) { + t.Setenv("FORMS_DOMAIN", "localhost:8090") + if got := FormURLOn("forms.acme.com", "abc"); got != "https://forms.acme.com/f/abc" { + t.Errorf("custom domain URL = %q", got) + } + // No host and no configured base is an empty URL, never a relative one. + t.Setenv("FORMS_DOMAIN", "") + if got := FormURLOn("", "abc"); got != "" { + t.Errorf("unconfigured install URL = %q, want empty", got) } }