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
18 changes: 18 additions & 0 deletions .changeset/block-free-email-brand-claim.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 6 additions & 15 deletions server/src/addie/mcp/admin-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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`;
Expand Down
9 changes: 2 additions & 7 deletions server/src/db/slack-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 3 additions & 13 deletions server/src/routes/admin/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions server/src/services/identifier-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -57,6 +82,10 @@ const SHARED_PLATFORM_DOMAINS = new Set<string>([
// 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,
]);

/**
Expand Down
54 changes: 53 additions & 1 deletion server/tests/unit/canonicalize-brand-domain.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
Loading