Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 69 additions & 10 deletions server/internal/handlers/auth/autojoin_regex.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,79 @@ package auth
import (
"fmt"
"regexp"
"slices"
"strings"
)

// freeMailDomains is the denylist of free webmail domains that must not be
// 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
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions server/internal/handlers/auth/autojoin_regex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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},
}
Expand All @@ -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))
})
}
}
60 changes: 60 additions & 0 deletions specs/todos/2026-10-12-01-first-visit-no-session-expired-banner.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions web/dash0/src/lib/quick-target-suggestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 54 additions & 26 deletions web/dash0/src/lib/quick-target-suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
Expand Down
Loading