From 354ef852e0363a9f268a82a4f1c187989bedad21 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Mon, 12 Oct 2026 01:24:17 +0200 Subject: [PATCH 1/2] fix(dash0): stop pre-filling a webmail domain as the first check target The zero-check dashboard pre-fills the signup's email domain as the URL to monitor, skipping free webmail. The skip list only had .com names, so a @gmx.de, @yahoo.fr or @orange.fr signup was offered its mail provider as its first check. Country variants of the big providers (gmx.de, yahoo.co.uk, hotmail.fr, mail.ru) are now matched by brand, and national providers (web.de, t-online.de, orange.fr, free.fr, laposte.net...) are listed. The same rule backs the auto-join pattern check, so an admin can no longer claim @gmx\.de and auto-admit every signup from that provider. Co-Authored-By: Claude Opus 5.5 --- .../internal/handlers/auth/autojoin_regex.go | 79 +++++++++++++++--- .../handlers/auth/autojoin_regex_test.go | 48 +++++++++++ .../src/lib/quick-target-suggestion.test.ts | 26 ++++++ web/dash0/src/lib/quick-target-suggestion.ts | 80 +++++++++++++------ 4 files changed, 197 insertions(+), 36 deletions(-) diff --git a/server/internal/handlers/auth/autojoin_regex.go b/server/internal/handlers/auth/autojoin_regex.go index 102209a8b..825a21170 100644 --- a/server/internal/handlers/auth/autojoin_regex.go +++ b/server/internal/handlers/auth/autojoin_regex.go @@ -3,6 +3,7 @@ package auth import ( "fmt" "regexp" + "slices" "strings" ) @@ -10,11 +11,71 @@ import ( // used as the sole post-@ component of an auto-join regex. Setting one of // these as the org's registration_email_pattern would inhale every signup // from that provider. +// +// Country variants of the providers in freeMailBrands (yahoo.fr, gmx.de, +// hotmail.co.uk) are matched by isFreeMailDomain and need no entry here. This +// list holds the providers that only exist under one name, mostly national +// ISPs and mailbox services. +// +// web/dash0/src/lib/quick-target-suggestion.ts mirrors this list and +// freeMailBrands verbatim. Keep both in sync. var freeMailDomains = []string{ //nolint:gochecknoglobals "gmail.com", "googlemail.com", "yahoo.com", "outlook.com", "hotmail.com", - "live.com", "msn.com", "icloud.com", "me.com", "protonmail.com", - "proton.me", "aol.com", "gmx.com", "mail.com", "yandex.com", - "tutanota.com", "zoho.com", "qq.com", "163.com", "126.com", + "live.com", "msn.com", "icloud.com", "me.com", "mac.com", "protonmail.com", + "proton.me", "pm.me", "aol.com", "gmx.com", "mail.com", "yandex.com", + "tutanota.com", "tuta.io", "zoho.com", "qq.com", "163.com", "126.com", + "yeah.net", "sina.com", "naver.com", "daum.net", "hanmail.net", "rediffmail.com", + "fastmail.com", "fastmail.fm", "hey.com", "mailbox.org", "posteo.de", "posteo.net", + // Germany, Austria, Switzerland + "web.de", "t-online.de", "freenet.de", "arcor.de", "bluewin.ch", + // France + "orange.fr", "wanadoo.fr", "free.fr", "laposte.net", "sfr.fr", "neuf.fr", "bbox.fr", + // Rest of Europe + "libero.it", "virgilio.it", "tiscali.it", "telenet.be", "skynet.be", "ziggo.nl", + "seznam.cz", "wp.pl", "o2.pl", "interia.pl", "onet.pl", "sapo.pt", + "inbox.ru", "list.ru", "bk.ru", "rambler.ru", "ukr.net", "abv.bg", + // UK, North America, Oceania, Brazil + "btinternet.com", "sky.com", "virginmedia.com", "comcast.net", "verizon.net", + "att.net", "sbcglobal.net", "shaw.ca", "rogers.com", "bigpond.com", + "uol.com.br", "bol.com.br", +} + +// freeMailBrands are webmail providers that run the same free service under +// many country domains. A domain made of one of these labels and a country +// suffix (yahoo.fr, gmx.net, hotmail.co.uk, mail.ru) is free webmail. +var freeMailBrands = []string{ //nolint:gochecknoglobals + "gmail", "googlemail", "yahoo", "ymail", "rocketmail", "hotmail", "outlook", + "live", "msn", "gmx", "aol", "icloud", "protonmail", "proton", "yandex", + "mail", "tutanota", "zoho", +} + +// countrySecondLevels are the labels that sit between a name and a ccTLD in +// a two-level public suffix, such as co.uk or com.br. +var countrySecondLevels = []string{"co", "com", "net", "org", "ne", "or"} //nolint:gochecknoglobals + +// isFreeMailDomain reports whether domain belongs to a free webmail provider: +// an exact entry of freeMailDomains, or a freeMailBrands label followed by a +// single TLD (gmx.de) or a two-level country suffix (yahoo.co.uk). A brand +// label deeper in the name (mail.acme.com, live.team.acme.com) does not count. +func isFreeMailDomain(domain string) bool { + domain = strings.ToLower(strings.TrimSpace(domain)) + if slices.Contains(freeMailDomains, domain) { + return true + } + + labels := strings.Split(domain, ".") + if !slices.Contains(freeMailBrands, labels[0]) { + return false + } + + switch len(labels) { + case 2: + return labels[1] != "" + case 3: + return slices.Contains(countrySecondLevels, labels[1]) && labels[2] != "" + default: + return false + } } // permissivePostAtPatterns are post-@ substrings that effectively let the @@ -59,13 +120,11 @@ func validateAutoJoinRegex(pattern string) error { } postAtLower := strings.ToLower(unescapeRegexLiteral(postAtTrimmed)) - for _, free := range freeMailDomains { - if postAtLower == free { - return fmt.Errorf( - "%w: %q is a free webmail provider — claiming it is unsafe", - ErrInvalidAutoJoinRegex, free, - ) - } + if isFreeMailDomain(postAtLower) { + return fmt.Errorf( + "%w: %q is a free webmail provider — claiming it is unsafe", + ErrInvalidAutoJoinRegex, postAtLower, + ) } for _, probe := range permissivenessProbes { diff --git a/server/internal/handlers/auth/autojoin_regex_test.go b/server/internal/handlers/auth/autojoin_regex_test.go index 19ab9ea75..9c302ec28 100644 --- a/server/internal/handlers/auth/autojoin_regex_test.go +++ b/server/internal/handlers/auth/autojoin_regex_test.go @@ -20,6 +20,7 @@ func TestValidateAutoJoinRegex(t *testing.T) { {"any local at acme", `.+@acme\.com`, false}, {"regional acme", `.+@(eu|us)\.acme\.com`, false}, {"specific subdomain", `.+@team\.acme\.example`, false}, + {"brand label as a subdomain", `.+@mail\.acme\.com`, false}, // Refused — structural {"missing @", `.+example\.com`, true}, {"unparseable", `[broken`, true}, @@ -34,6 +35,9 @@ func TestValidateAutoJoinRegex(t *testing.T) { {"gmail", `.+@gmail\.com`, true}, {"outlook", `.+@outlook\.com`, true}, {"proton", `.+@proton\.me`, true}, + {"country variant gmx.de", `.+@gmx\.de`, true}, + {"country variant yahoo.co.uk", `.+@yahoo\.co\.uk`, true}, + {"national provider web.de", `.+@web\.de$`, true}, // Refused — probe match catches alternations that include free webmail {"alternation includes gmail", `.+@(gmail|acme)\.com`, true}, } @@ -53,3 +57,47 @@ func TestValidateAutoJoinRegex(t *testing.T) { }) } } + +func TestIsFreeMailDomain(t *testing.T) { + t.Parallel() + + cases := []struct { + domain string + want bool + }{ + // Exact entries + {"gmail.com", true}, + {"web.de", true}, + {"orange.fr", true}, + {"uol.com.br", true}, + // Brand + single TLD + {"gmx.de", true}, + {"gmx.net", true}, + {"yahoo.fr", true}, + {"hotmail.it", true}, + {"mail.ru", true}, + // Brand + two-level country suffix + {"yahoo.co.uk", true}, + {"hotmail.com.br", true}, + {"yahoo.co.jp", true}, + // Normalization + {"GMX.De", true}, + {" gmx.de ", true}, + // Not free webmail + {"acme.com", false}, + {"mail.acme.com", false}, + {"live.team.acme.com", false}, + {"gmx.acme.de", false}, + {"acmegmail.com", false}, + {"gmx", false}, + {"gmx.", false}, + {"", false}, + } + + for _, tc := range cases { + t.Run(tc.domain, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, isFreeMailDomain(tc.domain)) + }) + } +} diff --git a/web/dash0/src/lib/quick-target-suggestion.test.ts b/web/dash0/src/lib/quick-target-suggestion.test.ts index c427d5ca6..896f5ca13 100644 --- a/web/dash0/src/lib/quick-target-suggestion.test.ts +++ b/web/dash0/src/lib/quick-target-suggestion.test.ts @@ -72,8 +72,34 @@ describe("suggestQuickTarget", () => { expect(suggestQuickTarget(`alice@${domain}`)).toBeNull(); }); + // Country variants of the big providers, and national providers. A + // `@gmx.de` signup was offered `gmx.de` as its first check before these. + it.each([ + "gmx.de", + "gmx.net", + "yahoo.fr", + "hotmail.co.uk", + "outlook.de", + "mail.ru", + "web.de", + "t-online.de", + "orange.fr", + "free.fr", + "laposte.net", + "uol.com.br", + ])("returns null for free-webmail domain %s", (domain) => { + expect(suggestQuickTarget(`alice@${domain}`)).toBeNull(); + }); + + it("still suggests a work domain that starts with a webmail brand label", () => { + expect(suggestQuickTarget("alice@mail.acme.com")).toBe("mail.acme.com"); + expect(suggestQuickTarget("alice@live.team.acme.com")).toBe("live.team.acme.com"); + expect(suggestQuickTarget("alice@gmx.acme.de")).toBe("gmx.acme.de"); + }); + it("is case-insensitive for the denylist too", () => { expect(suggestQuickTarget("alice@Gmail.COM")).toBeNull(); + expect(suggestQuickTarget("alice@GMX.De")).toBeNull(); }); // Not a plausible host: "/", ":", whitespace, or a leading/trailing dot. diff --git a/web/dash0/src/lib/quick-target-suggestion.ts b/web/dash0/src/lib/quick-target-suggestion.ts index 240cbc022..04bc0f069 100644 --- a/web/dash0/src/lib/quick-target-suggestion.ts +++ b/web/dash0/src/lib/quick-target-suggestion.ts @@ -10,35 +10,63 @@ import { validateTarget } from "./quick-check-target"; /** * Free webmail domains where a signup's own email domain is not a useful - * monitoring target. Mirrors `freeMailDomains` in - * server/internal/handlers/auth/autojoin_regex.go verbatim — that file is the - * source of truth. Duplicating the list was chosen over an API change: this - * is a UX heuristic where a miss costs the user one select-all, not a - * security decision, so drift between the two lists is harmless. + * monitoring target. Mirrors `freeMailDomains`, `freeMailBrands` and + * `countrySecondLevels` in server/internal/handlers/auth/autojoin_regex.go + * verbatim — that file is the source of truth. Duplicating the list was chosen + * over an API change: this is a UX heuristic where a miss costs the user one + * select-all, not a security decision, so drift between the two lists is + * harmless. + * + * Country variants of the brands below (`yahoo.fr`, `gmx.de`, + * `hotmail.co.uk`) are matched by `isFreeMailDomain` and need no entry here. */ const FREE_MAIL_DOMAINS = new Set([ - "gmail.com", - "googlemail.com", - "yahoo.com", - "outlook.com", - "hotmail.com", - "live.com", - "msn.com", - "icloud.com", - "me.com", - "protonmail.com", - "proton.me", - "aol.com", - "gmx.com", - "mail.com", - "yandex.com", - "tutanota.com", - "zoho.com", - "qq.com", - "163.com", - "126.com", + "gmail.com", "googlemail.com", "yahoo.com", "outlook.com", "hotmail.com", + "live.com", "msn.com", "icloud.com", "me.com", "mac.com", "protonmail.com", + "proton.me", "pm.me", "aol.com", "gmx.com", "mail.com", "yandex.com", + "tutanota.com", "tuta.io", "zoho.com", "qq.com", "163.com", "126.com", + "yeah.net", "sina.com", "naver.com", "daum.net", "hanmail.net", "rediffmail.com", + "fastmail.com", "fastmail.fm", "hey.com", "mailbox.org", "posteo.de", "posteo.net", + // Germany, Austria, Switzerland + "web.de", "t-online.de", "freenet.de", "arcor.de", "bluewin.ch", + // France + "orange.fr", "wanadoo.fr", "free.fr", "laposte.net", "sfr.fr", "neuf.fr", "bbox.fr", + // Rest of Europe + "libero.it", "virgilio.it", "tiscali.it", "telenet.be", "skynet.be", "ziggo.nl", + "seznam.cz", "wp.pl", "o2.pl", "interia.pl", "onet.pl", "sapo.pt", + "inbox.ru", "list.ru", "bk.ru", "rambler.ru", "ukr.net", "abv.bg", + // UK, North America, Oceania, Brazil + "btinternet.com", "sky.com", "virginmedia.com", "comcast.net", "verizon.net", + "att.net", "sbcglobal.net", "shaw.ca", "rogers.com", "bigpond.com", + "uol.com.br", "bol.com.br", +]); + +/** Providers that run the same free service under many country domains. */ +const FREE_MAIL_BRANDS = new Set([ + "gmail", "googlemail", "yahoo", "ymail", "rocketmail", "hotmail", "outlook", + "live", "msn", "gmx", "aol", "icloud", "protonmail", "proton", "yandex", + "mail", "tutanota", "zoho", ]); +/** Labels between a name and a ccTLD in a two-level suffix (`co.uk`, `com.br`). */ +const COUNTRY_SECOND_LEVELS = new Set(["co", "com", "net", "org", "ne", "or"]); + +/** + * True for an exact `FREE_MAIL_DOMAINS` entry, or a `FREE_MAIL_BRANDS` label + * followed by a single TLD (`gmx.de`) or a two-level country suffix + * (`yahoo.co.uk`). A brand label deeper in the name (`mail.acme.com`) does not + * count. Expects a lowercased domain. + */ +function isFreeMailDomain(domain: string): boolean { + if (FREE_MAIL_DOMAINS.has(domain)) return true; + + const labels = domain.split("."); + if (!FREE_MAIL_BRANDS.has(labels[0])) return false; + if (labels.length === 2) return labels[1] !== ""; + if (labels.length === 3) return COUNTRY_SECOND_LEVELS.has(labels[1]) && labels[2] !== ""; + return false; +} + /** * The target to pre-fill the hero with for this user, or `null` for none. * @@ -65,7 +93,7 @@ export function suggestQuickTarget(email: string | null | undefined): string | n const domain = trimmedEmail.slice(at + 1).trim().toLowerCase(); if (!domain) return null; - if (FREE_MAIL_DOMAINS.has(domain)) return null; + if (isFreeMailDomain(domain)) return null; // Reuse the icmp host-shape check rather than writing a second parser: it // rejects a domain containing "/", ":", whitespace, or a leading/trailing From 4de4d1ac4b1d04e46b3f28f81fc9cecde573ad53 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Mon, 12 Oct 2026 01:24:17 +0200 Subject: [PATCH 2/2] docs(spec): first-time visitor must not see "session expired" Co-Authored-By: Claude Opus 5.5 --- ...1-first-visit-no-session-expired-banner.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 specs/todos/2026-10-12-01-first-visit-no-session-expired-banner.md diff --git a/specs/todos/2026-10-12-01-first-visit-no-session-expired-banner.md b/specs/todos/2026-10-12-01-first-visit-no-session-expired-banner.md new file mode 100644 index 000000000..afbab98b5 --- /dev/null +++ b/specs/todos/2026-10-12-01-first-visit-no-session-expired-banner.md @@ -0,0 +1,60 @@ +--- +model: sonnet +effort: medium +--- + +# A first-time visitor must not be told their session expired + +## Problem +Open `https://solidping.io/` in a fresh browser (no cookies, no localStorage). It lands on +`/d/orgs/default/login?session_expired=true&returnTo=%2Fd%2Fno-org`, and the sign-in card shows +"Your session has expired. Please log in again." (`auth.json` `sessionExpired`). The visitor never +had a session. Opening `/d/orgs/default/login` directly shows no banner. + +The bare domain is what people type and what directory listings store. Every new visitor arriving +that way is told something went wrong before they have done anything. PostHog shows real signups +starting from that URL (2026-10-11). + +The path: +1. `RootRedirect` (`web/dash0/src/routes/index.tsx:60`): no resolved org → `/no-org`. An + unauthenticated visitor has no org either, so they get the same redirect. +2. `/no-org` mounts and fires authenticated requests (`/auth/me`, `/auth/membership-requests`). + They answer 401. +3. `apiFetch` tries a refresh. `doRefresh` (`web/dash0/src/lib/token-refresh.ts:~80-100`) already + knows "no access token and no refresh token" means signed out and returns quietly (spec + 2026-09-25-14). +4. But the 401 then reaches `handleResponse` (`web/dash0/src/api/client.ts:478`), which always + calls `clearToken()` and `redirectToExpiredLogin()` (`:289`). That one always writes + `session_expired=true`. + +Secondary: the card title reads "Sign in to default" (`signInTo`, `routes/orgs/$org/login.tsx:637`). +For a stranger, `default` means nothing. + +## Proposal +1. `RootRedirect`: when the auth context has resolved and there is no session at all (no access + token, no refresh token), go straight to the login page with `session_expired: false` instead + of `/no-org`. Same stored-org-else-`default` fallback as the root `/login` route. This also + removes the 401 round trip and the flash of `/no-org`. +2. `handleResponse` 401 branch: read whether a token existed **before** `clearToken()`. If none + did, redirect to the login page with `session_expired=false` (keep `returnTo`). Only a + rejected token that existed earns the banner. Do this in `redirectToExpiredLogin` itself if + that is cleaner, e.g. a `{ hadSession }` argument, so every caller gets it. +3. Keep every existing expired-session behaviour: a real expired or revoked token still shows the + banner and keeps `returnTo`. +4. Optional, same PR if small: on `/orgs/default/login` reached without an explicit org choice, + show the generic "Sign in" title instead of "Sign in to default". Skip it if it needs more + than a title change. + +## Tests +- Playwright (`web/dash0/e2e/`, next to `zero-org-session.spec.ts` / `login.spec.ts`): a fresh + context opening `/d/` lands on a login URL with no `session_expired=true`, and + `login-title` is visible with no expired banner. +- Playwright: a signed-in session whose tokens are then invalidated server-side still lands on + the login page **with** the banner (regression control, compare `session-continuity.spec.ts`). +- Unit (`web/dash0/src/api/redirect-to-expired-login.test.ts`): no stored token → URL carries + `session_expired=false`. Stored token → `session_expired=true` as today. + +## Acceptance Criteria +- [ ] A fresh browser opening `/` or `/d/` never sees "Your session has expired". +- [ ] An actually expired or revoked session still sees it. +- [ ] `returnTo` keeps working in both cases.