mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
237 lines
7.6 KiB
Go
237 lines
7.6 KiB
Go
package instancecheck
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
)
|
|
|
|
const (
|
|
docsAddresses = "/development/configuration/#addresses"
|
|
docsSSO = "/development/accounts-and-access/#single-sign-on"
|
|
docsRealtime = "/development/instance-health/#realtime"
|
|
docsDelivery = "/guides/deliverability/"
|
|
)
|
|
|
|
func urlChecks() []check {
|
|
return []check{
|
|
{id: "app_url_unset", run: checkAppURLUnset},
|
|
{id: "app_url_insecure", run: checkAppURLInsecure},
|
|
{id: "app_url_host_mismatch", run: checkAppURLHostMismatch},
|
|
{id: "cors_missing_origin", run: checkCORSMissingOrigin},
|
|
{id: "api_public_url_unset_oidc", run: checkAPIPublicURLUnsetOIDC},
|
|
{id: "oidc_discovery_failed", run: checkOIDCDiscoveryFailed},
|
|
{id: "websocket_unreachable", run: checkWebsocketUnreachable},
|
|
{id: "tracking_domain_unreachable", run: checkTrackingDomainUnreachable},
|
|
{id: "app_origin_wildcard", run: checkAppOriginWildcard},
|
|
}
|
|
}
|
|
|
|
func checkAppURLUnset(ctx context.Context, d Deps, in Input) *Finding {
|
|
if appURLConfigured() {
|
|
return nil
|
|
}
|
|
return result(CategoryURLs, SeverityError, "APP_URL is not set",
|
|
"APP_URL is not set, so password reset, invitation and setup links are being built against https://app.warmbly.com. "+
|
|
"Those links go to the hosted service, not to your instance, and a reset token in one of them leaves your deployment. "+
|
|
"Set APP_URL to your dashboard origin.",
|
|
docsAddresses)
|
|
}
|
|
|
|
func checkAppURLInsecure(ctx context.Context, d Deps, in Input) *Finding {
|
|
raw := appURL()
|
|
if !appURLConfigured() {
|
|
return nil
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Scheme != "http" || isLoopbackHost(u.Hostname()) {
|
|
return nil
|
|
}
|
|
return result(CategoryURLs, SeverityWarning, "The dashboard is not behind HTTPS",
|
|
fmt.Sprintf("APP_URL is %s. Browsers refuse WebAuthn outside a secure context, so passkeys are disabled, "+
|
|
"and session cookies are sent in the clear. Put the dashboard behind HTTPS.", raw),
|
|
docsAddresses)
|
|
}
|
|
|
|
func checkAppURLHostMismatch(ctx context.Context, d Deps, in Input) *Finding {
|
|
reached := hostOnly(in.Host)
|
|
configured := hostOf(appURL())
|
|
// Skip on loopback: a local stack legitimately reaches the API on one port
|
|
// and the dashboard on another.
|
|
if reached == "" || configured == "" || !appURLConfigured() || isLoopbackHost(reached) {
|
|
return nil
|
|
}
|
|
if strings.EqualFold(reached, configured) {
|
|
return nil
|
|
}
|
|
return result(CategoryURLs, SeverityWarning, "APP_URL does not match this host",
|
|
fmt.Sprintf("You reached this panel on %s but APP_URL is %s. "+
|
|
"Emailed links are built from APP_URL, so they will point somewhere other than where people actually reach this instance.",
|
|
reached, appURL()),
|
|
docsAddresses)
|
|
}
|
|
|
|
func checkCORSMissingOrigin(ctx context.Context, d Deps, in Input) *Finding {
|
|
allowed := runtimeOf(d).CORSOrigins
|
|
if len(allowed) == 0 {
|
|
allowed = splitList(env("CORS_ALLOW_ORIGINS"))
|
|
}
|
|
// An empty list means the backend derived one at boot and did not hand it
|
|
// over; there is nothing to compare against, so this check cannot run.
|
|
if len(allowed) == 0 {
|
|
return nil
|
|
}
|
|
|
|
wanted := []string{}
|
|
if appURLConfigured() {
|
|
wanted = append(wanted, strings.TrimRight(appURL(), "/"))
|
|
}
|
|
if in.Origin != "" {
|
|
wanted = append(wanted, strings.TrimRight(in.Origin, "/"))
|
|
}
|
|
|
|
for _, origin := range wanted {
|
|
if origin == "" || containsOrigin(allowed, origin) {
|
|
continue
|
|
}
|
|
return result(CategoryURLs, SeverityWarning, "An origin is missing from the CORS allowlist",
|
|
fmt.Sprintf("%s is not in the allowed CORS origins, so the browser will block its API calls. "+
|
|
"Add it to CORS_ALLOW_ORIGINS.", origin),
|
|
docsAddresses)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkAPIPublicURLUnsetOIDC(ctx context.Context, d Deps, in Input) *Finding {
|
|
if env("OIDC_ISSUER_URL") == "" {
|
|
return nil
|
|
}
|
|
if env("API_PUBLIC_URL") != "" || env("OIDC_REDIRECT_URL") != "" {
|
|
return nil
|
|
}
|
|
return result(CategoryURLs, SeverityError, "OIDC has no redirect URL",
|
|
"OIDC is configured but there is no redirect URL: API_PUBLIC_URL is empty and OIDC_REDIRECT_URL is not set, "+
|
|
"so the OIDC login path is disabled. Set API_PUBLIC_URL to this backend's public base.",
|
|
docsSSO)
|
|
}
|
|
|
|
func checkOIDCDiscoveryFailed(ctx context.Context, d Deps, in Input) *Finding {
|
|
rt := runtimeOf(d)
|
|
if rt.OIDCDiscoveryErr == "" {
|
|
return nil
|
|
}
|
|
return result(CategoryURLs, SeverityError, "OIDC discovery failed",
|
|
fmt.Sprintf("Discovery against %s failed at boot, so the single sign-on button is not shown: %s.",
|
|
env("OIDC_ISSUER_URL"), rt.OIDCDiscoveryErr),
|
|
docsSSO)
|
|
}
|
|
|
|
func checkWebsocketUnreachable(ctx context.Context, d Deps, in Input) *Finding {
|
|
raw := runtimeOf(d).WebsocketURL
|
|
if raw == "" {
|
|
raw = env("WEBSOCKET_URL")
|
|
}
|
|
health := realtimeHealthURL(raw)
|
|
if health == "" {
|
|
return nil
|
|
}
|
|
if probeHTTP(ctx, health) {
|
|
return nil
|
|
}
|
|
// Running out of budget is not evidence of a down service.
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return result(CategoryURLs, SeverityWarning, "The realtime service is not reachable",
|
|
fmt.Sprintf("The realtime service is not reachable at %s, so the dashboard will not update live "+
|
|
"and presence will be empty.", raw),
|
|
docsRealtime)
|
|
}
|
|
|
|
func checkTrackingDomainUnreachable(ctx context.Context, d Deps, in Input) *Finding {
|
|
domain := env("TRACKING_DOMAIN")
|
|
if domain == "" {
|
|
return nil
|
|
}
|
|
// Outbound mail always builds https:// links, so that is the probe that
|
|
// matters; http is tried second so a local sink is not reported as down.
|
|
for _, candidate := range trackingProbeURLs(domain) {
|
|
if probeHTTP(ctx, candidate) {
|
|
return nil
|
|
}
|
|
}
|
|
// Running out of budget is not evidence of a down service.
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return result(CategoryURLs, SeverityWarning, "The tracking domain did not answer",
|
|
fmt.Sprintf("%s did not answer, so open pixels and click links in campaign mail will not record. "+
|
|
"Recipients still receive the mail.", domain),
|
|
docsDelivery)
|
|
}
|
|
|
|
func checkAppOriginWildcard(ctx context.Context, d Deps, in Input) *Finding {
|
|
if env("APP_ORIGIN") != "" {
|
|
return nil
|
|
}
|
|
// APP_ORIGIN is only needed when APP_URL cannot supply the origin, since
|
|
// the callback page derives its postMessage target from APP_URL otherwise.
|
|
if u, err := url.Parse(config.AppBaseURL()); err == nil && u.Scheme != "" && u.Host != "" {
|
|
return nil
|
|
}
|
|
return result(CategoryURLs, SeverityInfo, "No OAuth callback target origin",
|
|
"Neither APP_ORIGIN nor a usable APP_URL is set, so the mailbox OAuth callback page posts the authorization "+
|
|
"code back to the dashboard with a wildcard target origin. Set APP_URL to your dashboard origin, "+
|
|
"or APP_ORIGIN if the dashboard is served somewhere else.",
|
|
docsAddresses)
|
|
}
|
|
|
|
// realtimeHealthURL mirrors wsHealthURL in cmd/backend/main.go.
|
|
func realtimeHealthURL(wsURI string) string {
|
|
if wsURI == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(wsURI)
|
|
if err != nil || u.Host == "" {
|
|
return ""
|
|
}
|
|
if u.Scheme == "wss" {
|
|
u.Scheme = "https"
|
|
} else {
|
|
u.Scheme = "http"
|
|
}
|
|
u.Path = "/health"
|
|
u.RawQuery = ""
|
|
return u.String()
|
|
}
|
|
|
|
func trackingProbeURLs(domain string) []string {
|
|
if strings.Contains(domain, "://") {
|
|
return []string{strings.TrimRight(domain, "/") + "/health"}
|
|
}
|
|
domain = strings.TrimRight(domain, "/")
|
|
return []string{"https://" + domain + "/health", "http://" + domain + "/health"}
|
|
}
|
|
|
|
func splitList(raw string) []string {
|
|
out := []string{}
|
|
for _, part := range strings.Split(raw, ",") {
|
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
|
out = append(out, trimmed)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func containsOrigin(list []string, origin string) bool {
|
|
for _, item := range list {
|
|
if item == "*" || strings.EqualFold(strings.TrimRight(item, "/"), origin) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|