diff --git a/.changeset/block-free-email-brand-claim.md b/.changeset/block-free-email-brand-claim.md new file mode 100644 index 0000000000..b8bd2cbe53 --- /dev/null +++ b/.changeset/block-free-email-brand-claim.md @@ -0,0 +1,18 @@ +--- +--- + +fix(server): block free-email provider domains from brand identity claims, unify parallel lists + +Extends `SHARED_PLATFORM_DOMAINS` in `identifier-normalization.ts` with 30 high-volume +free-email provider domains (Gmail, Outlook, iCloud, Proton, Yahoo, AOL, Tutanota, etc.) +as defense-in-depth. WorkOS DNS verification makes admin-override exploitation implausible +today; this closes the gap for future trust paths and the new `primary_brand_domain` +auto-populate path added in #4157. + +Extracts the list into an exported `FREE_EMAIL_PROVIDER_DOMAINS` constant and replaces +four previously-diverged inline arrays across `admin-tools.ts`, `slack-db.ts`, and +`admin/domains.ts` with imports of the shared constant. + +Adds `assertClaimableBrandDomain` unit tests (the function was previously untested). + +Closes #4165. diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index 9c77fa8606..78677551eb 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -18,6 +18,7 @@ import { ToolError } from '../tool-error.js'; import type { AddieTool } from '../types.js'; import { COMMITTEE_TYPE_LABELS, VALID_MEMBER_OFFERINGS } from '../../types.js'; import type { MemberContext } from '../member-context.js'; +import { FREE_EMAIL_PROVIDER_DOMAINS } from '../../services/identifier-normalization.js'; // invalidateMemberContextCache is imported lazily in the two handlers that // call it (see below). Top-level import would pull member-context.ts, which // imports middleware/auth.ts, which constructs a WorkOS client at module load @@ -5802,12 +5803,7 @@ Use add_committee_leader to assign a leader.`; const limit = Math.min(Math.max((input.limit as number) || 20, 1), 100); const pool = getPool(); - // Common free email providers to exclude - const freeEmailDomains = [ - 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', 'hotmail.com', - 'outlook.com', 'live.com', 'msn.com', 'aol.com', 'icloud.com', 'me.com', - 'mac.com', 'protonmail.com', 'proton.me', 'mail.com', 'zoho.com', - ]; + const freeEmailDomains = FREE_EMAIL_PROVIDER_DOMAINS; let response = `## Domain Health Check\n\n`; let issueCount = 0; @@ -6124,6 +6120,7 @@ Use add_committee_leader to assign a leader.`; // 1. Find unmapped corporate domains (already engaged, high value) try { + const freePlaceholders = FREE_EMAIL_PROVIDER_DOMAINS.map((_, i) => `$${i + 1}`).join(', '); const unmappedResult = await pool.query(` WITH corporate_domains AS ( -- Extract domains from Slack users not in personal orgs @@ -6133,13 +6130,7 @@ Use add_committee_leader to assign a leader.`; FROM slack_user_mappings sm WHERE sm.slack_email IS NOT NULL AND sm.slack_is_bot IS NOT TRUE - -- Exclude common personal email domains - AND LOWER(sm.slack_email) NOT LIKE '%@gmail.com' - AND LOWER(sm.slack_email) NOT LIKE '%@yahoo.com' - AND LOWER(sm.slack_email) NOT LIKE '%@hotmail.com' - AND LOWER(sm.slack_email) NOT LIKE '%@outlook.com' - AND LOWER(sm.slack_email) NOT LIKE '%@icloud.com' - AND LOWER(sm.slack_email) NOT LIKE '%@aol.com' + AND LOWER(SUBSTRING(sm.slack_email FROM POSITION('@' IN sm.slack_email) + 1)) NOT IN (${freePlaceholders}) GROUP BY domain ) SELECT @@ -6157,8 +6148,8 @@ Use add_committee_leader to assign a leader.`; WHERE o.email_domain = cd.domain ) ORDER BY cd.user_count DESC - LIMIT $1 - `, [limit]); + LIMIT $${FREE_EMAIL_PROVIDER_DOMAINS.length + 1} + `, [...FREE_EMAIL_PROVIDER_DOMAINS, limit]); if (unmappedResult.rows.length > 0) { response += `### 🎯 Unmapped Domains (Already in Slack!)\n\n`; diff --git a/server/src/db/slack-db.ts b/server/src/db/slack-db.ts index 56ad2ee804..c8f307aab6 100644 --- a/server/src/db/slack-db.ts +++ b/server/src/db/slack-db.ts @@ -5,6 +5,7 @@ import type { SlackMappingSource, SlackMappingStats, } from '../slack/types.js'; +import { FREE_EMAIL_PROVIDER_DOMAINS } from '../services/identifier-normalization.js'; /** * Escape LIKE pattern wildcards to prevent SQL injection @@ -371,13 +372,7 @@ export class SlackDatabase { const excludeFree = options.excludeFreeEmailProviders !== false; const minUsers = options.minUsers ?? 1; - // Common free email providers to exclude - const freeEmailDomains = [ - 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', 'hotmail.com', - 'outlook.com', 'live.com', 'msn.com', 'aol.com', 'icloud.com', 'me.com', - 'mac.com', 'protonmail.com', 'proton.me', 'mail.com', 'zoho.com', - 'yandex.com', 'gmx.com', 'gmx.net', 'fastmail.com', 'tutanota.com', - ]; + const freeEmailDomains = FREE_EMAIL_PROVIDER_DOMAINS; let domainExcludeClause = ''; if (excludeFree) { diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index f916ff8be5..ab5ea42a7d 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -21,6 +21,7 @@ import { resolveOrgsByDomains } from "../../db/domain-resolution-db.js"; import { complete, isLLMConfigured } from "../../utils/llm.js"; import { BrandDatabase } from "../../db/brand-db.js"; import { verifyDomainChallenge } from "../../services/brand-claim.js"; +import { FREE_EMAIL_PROVIDER_DOMAINS } from "../../services/identifier-normalization.js"; const slackDb = new SlackDatabase(); const logger = createLogger("admin-domains"); @@ -577,13 +578,7 @@ export function setupDomainRoutes( const minUsers = min_users ? parseInt(min_users as string, 10) : 1; const resultLimit = limit ? parseInt(limit as string, 10) : 100; - // Common free email providers to exclude - const freeEmailDomains = [ - 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', 'hotmail.com', - 'outlook.com', 'live.com', 'msn.com', 'aol.com', 'icloud.com', 'me.com', - 'mac.com', 'protonmail.com', 'proton.me', 'mail.com', 'zoho.com', - 'yandex.com', 'gmx.com', 'gmx.net', 'fastmail.com', 'tutanota.com', - ]; + const freeEmailDomains = FREE_EMAIL_PROVIDER_DOMAINS; const pool = getPool(); @@ -1811,12 +1806,7 @@ Respond with ONLY a JSON array, one entry per cluster: return results; } - // Common free email providers to exclude from corporate domain checks - const freeEmailDomains = [ - 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', 'hotmail.com', - 'outlook.com', 'live.com', 'msn.com', 'aol.com', 'icloud.com', 'me.com', - 'mac.com', 'protonmail.com', 'proton.me', 'mail.com', 'zoho.com', - ]; + const freeEmailDomains = FREE_EMAIL_PROVIDER_DOMAINS; // GET /api/admin/domain-health - Get domain health summary and issues apiRouter.get( diff --git a/server/src/services/identifier-normalization.ts b/server/src/services/identifier-normalization.ts index 2242b418e7..16ff314a72 100644 --- a/server/src/services/identifier-normalization.ts +++ b/server/src/services/identifier-normalization.ts @@ -35,6 +35,31 @@ export function assertValidBrandDomain(canonical: string): void { } } +/** + * High-volume free-email provider domains. Exported so downstream consumers + * (admin health checks, etc.) can stay in sync without maintaining a + * parallel list. + */ +export const FREE_EMAIL_PROVIDER_DOMAINS: readonly string[] = [ + // Google + 'gmail.com', 'googlemail.com', + // Microsoft + 'outlook.com', 'hotmail.com', 'live.com', 'msn.com', + // Yahoo + 'yahoo.com', 'yahoo.co.uk', 'ymail.com', 'rocketmail.com', + // AOL + 'aol.com', 'aim.com', + // Apple + 'icloud.com', 'me.com', 'mac.com', + // Proton + 'proton.me', 'protonmail.com', 'pm.me', + // Other providers + 'zoho.com', 'fastmail.com', 'gmx.com', 'gmx.net', 'mail.com', + 'yandex.com', 'yandex.ru', 'qq.com', '163.com', '126.com', + // Forwarding alias / privacy-email services (multi-tenant; same blast radius as hosted mailbox providers) + 'duck.com', 'hey.com', 'tutanota.com', 'tutanota.de', +]; + /** * Shared-platform and public-suffix domains where any one tenant claiming * the apex would steal the brand identity for thousands of others. WorkOS @@ -57,6 +82,10 @@ const SHARED_PLATFORM_DOMAINS = new Set([ // Common eTLDs that pass the apex regex 'co.uk', 'co.jp', 'com.au', 'com.br', 'co.in', 'co.nz', 'co.za', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ne.jp', 'or.jp', + // Free email providers — claiming one would auto-assign brand identity for + // every user on that service. WorkOS DNS verification blocks this in practice + // but this is defense-in-depth for admin overrides and future trust paths. + ...FREE_EMAIL_PROVIDER_DOMAINS, ]); /** diff --git a/server/tests/unit/canonicalize-brand-domain.test.ts b/server/tests/unit/canonicalize-brand-domain.test.ts index 3488e03580..08df562f5a 100644 --- a/server/tests/unit/canonicalize-brand-domain.test.ts +++ b/server/tests/unit/canonicalize-brand-domain.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { canonicalizeBrandDomain, assertValidBrandDomain } from '../../src/services/identifier-normalization.js'; +import { canonicalizeBrandDomain, assertValidBrandDomain, assertClaimableBrandDomain } from '../../src/services/identifier-normalization.js'; describe('canonicalizeBrandDomain', () => { it('strips https:// protocol', () => { @@ -66,3 +66,55 @@ describe('assertValidBrandDomain', () => { expect(() => assertValidBrandDomain(long)).toThrow(); }); }); + +describe('assertClaimableBrandDomain', () => { + it('accepts a legitimate brand domain', () => { + expect(() => assertClaimableBrandDomain('kyber1.com')).not.toThrow(); + }); + + it('rejects shared hosting platforms', () => { + expect(() => assertClaimableBrandDomain('vercel.app')).toThrow(); + expect(() => assertClaimableBrandDomain('netlify.app')).toThrow(); + expect(() => assertClaimableBrandDomain('github.io')).toThrow(); + }); + + it('rejects Google free-email domains', () => { + expect(() => assertClaimableBrandDomain('gmail.com')).toThrow(); + expect(() => assertClaimableBrandDomain('googlemail.com')).toThrow(); + }); + + it('rejects Microsoft free-email domains', () => { + expect(() => assertClaimableBrandDomain('outlook.com')).toThrow(); + expect(() => assertClaimableBrandDomain('hotmail.com')).toThrow(); + expect(() => assertClaimableBrandDomain('live.com')).toThrow(); + }); + + it('rejects Apple free-email domains', () => { + expect(() => assertClaimableBrandDomain('icloud.com')).toThrow(); + expect(() => assertClaimableBrandDomain('me.com')).toThrow(); + expect(() => assertClaimableBrandDomain('mac.com')).toThrow(); + }); + + it('rejects Proton free-email domains', () => { + expect(() => assertClaimableBrandDomain('proton.me')).toThrow(); + expect(() => assertClaimableBrandDomain('protonmail.com')).toThrow(); + expect(() => assertClaimableBrandDomain('pm.me')).toThrow(); + }); + + it('rejects other high-volume free-email providers', () => { + expect(() => assertClaimableBrandDomain('yahoo.com')).toThrow(); + expect(() => assertClaimableBrandDomain('msn.com')).toThrow(); + expect(() => assertClaimableBrandDomain('aol.com')).toThrow(); + expect(() => assertClaimableBrandDomain('mail.com')).toThrow(); + expect(() => assertClaimableBrandDomain('tutanota.com')).toThrow(); + expect(() => assertClaimableBrandDomain('qq.com')).toThrow(); + expect(() => assertClaimableBrandDomain('163.com')).toThrow(); + expect(() => assertClaimableBrandDomain('126.com')).toThrow(); + expect(() => assertClaimableBrandDomain('tutanota.de')).toThrow(); + }); + + it('rejects common eTLDs', () => { + expect(() => assertClaimableBrandDomain('co.uk')).toThrow(); + expect(() => assertClaimableBrandDomain('com.au')).toThrow(); + }); +});