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
5 changes: 5 additions & 0 deletions libs/shared/guards/src/lib/admin-panel-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export enum AdminPanelFeature {
UnsubscribeFromMailingLists = 'UnsubscribeFromMailingLists',
RateLimiting = 'RateLimiting',
DeleteRecoveryPhone = 'DeleteRecoveryPhone',
EmailBlocklist = 'EmailBlocklist',
}

/** Enum of known user groups */
Expand Down Expand Up @@ -209,6 +210,10 @@ const defaultAdminPanelPermissions: Permissions = {
name: 'Delete Recovery Phone',
level: PermissionLevel.Admin,
},
[AdminPanelFeature.EmailBlocklist]: {
name: 'Manage Email Blocklist',
level: PermissionLevel.Admin,
},
};

/**
Expand Down
11 changes: 11 additions & 0 deletions packages/db-migrations/databases/fxa/patches/patch-189-190.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SET NAMES utf8mb4 COLLATE utf8mb4_bin;

CALL assertPatchLevel('189');

CREATE TABLE IF NOT EXISTS emailBlocklist (
regex VARCHAR(768) NOT NULL,
createdAt BIGINT UNSIGNED NOT NULL,
UNIQUE KEY uq_emailBlocklist_regex (regex)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

UPDATE dbMetadata SET value = '190' WHERE name = 'schema-patch-level';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
SET NAMES utf8mb4 COLLATE utf8mb4_bin;

CALL assertPatchLevel('190');

DROP TABLE IF EXISTS emailBlocklist;

UPDATE dbMetadata SET value = '189' WHERE name = 'schema-patch-level';
2 changes: 1 addition & 1 deletion packages/db-migrations/databases/fxa/target-patch.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"level": 189
"level": 190
}
7 changes: 7 additions & 0 deletions packages/fxa-admin-panel/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import PageRelyingParties from './components/PageRelyingParties';
import PageAccountDelete from './components/PageAccountDelete';
import PageRateLimiting from './components/PageRateLimiting';
import PageAccountReset from './components/PageAccountReset';
import PageEmailBlocklist from './components/PageEmailBlocklist';

const App = ({ config }: { config: IClientConfig }) => {
const [guard, setGuard] = useState<AdminPanelGuard>(config.guard);
Expand Down Expand Up @@ -46,6 +47,12 @@ const App = ({ config }: { config: IClientConfig }) => {
{guard.allow(AdminPanelFeature.AccountReset, user.group) && (
<Route path="/account-reset" element={<PageAccountReset />} />
)}
{guard.allow(AdminPanelFeature.EmailBlocklist, user.group) && (
<Route
path="/email-blocklist"
element={<PageEmailBlocklist />}
/>
)}
<Route path="/permissions" element={<PagePermissions />} />
</Routes>
</AppLayout>
Expand Down
16 changes: 16 additions & 0 deletions packages/fxa-admin-panel/src/components/Nav/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { NavLink } from 'react-router-dom';
import accountIcon from '../../images/icon-account.svg';
import keyIcon from '../../images/icon-key.svg';
import logsIcon from '../../images/icon-logs.svg';
import emailBlocklistIcon from '../../images/icon-email-blocklist.svg';
import { AdminPanelFeature } from '@fxa/shared/guards';
import Guard from '../Guard';

Expand Down Expand Up @@ -67,6 +68,21 @@ export const Nav = () => (
</NavLink>
</li>
</Guard>
<Guard features={[AdminPanelFeature.EmailBlocklist]}>
<li>
<NavLink
to="/email-blocklist"
className={({ isActive }) => getNavLinkClassName(isActive)}
>
<img
className="inline-flex mr-2 w-4"
src={emailBlocklistIcon}
alt="blocklist icon"
/>
Email Blocklist
</NavLink>
</li>
</Guard>
<Guard features={[AdminPanelFeature.RelyingParties]}>
<li>
<NavLink
Expand Down
229 changes: 229 additions & 0 deletions packages/fxa-admin-panel/src/components/PageEmailBlocklist/index.tsx
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar here, there are no unit tests for this new page and we should probably have some

Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { adminApi } from '../../lib/api';
import type { EmailBlocklistEntry } from 'fxa-admin-server/src/types';

const btnClass =
'bg-grey-10 border-2 p-1 border-grey-100 font-small leading-6 rounded';

const PageEmailBlocklist = () => {
const [entries, setEntries] = useState<EmailBlocklistEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

const loadEntries = useCallback(async () => {
setError(null);
try {
const data = await adminApi.getEmailBlocklist();
setEntries(
[...data].sort(
(a, b) => b.createdAt - a.createdAt || a.regex.localeCompare(b.regex)
)
);
} catch (e) {
setError('Failed to load blocklist.');
}
}, []);

useEffect(() => {
setLoading(true);
loadEntries().finally(() => setLoading(false));
}, [loadEntries]);
Comment thread
toufali marked this conversation as resolved.

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const raw = textareaRef.current?.value ?? '';
const regexes = raw
.split('\n')
.map((x) => x.trim())
.filter((x) => x.length > 0);

if (regexes.length === 0) return;

setSubmitting(true);
try {
await adminApi.addEmailBlocklistEntries(regexes);
if (textareaRef.current) textareaRef.current.value = '';
await loadEntries();
} catch (e) {
window.alert(
`Error: ${e instanceof Error ? e.message : 'Unknown error'}`
);
} finally {
setSubmitting(false);
}
};

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const lines = (ev.target?.result as string)
.split(/\r?\n/)
.map((l) => l.trim().replace(/^"|"$/g, ''))
.filter((l) => l.length > 0);
if (textareaRef.current) textareaRef.current.value = lines.join('\n');
Comment thread
toufali marked this conversation as resolved.
};
reader.readAsText(file);
e.target.value = '';
};

const handleDelete = async (regex: string) => {
try {
await adminApi.removeEmailBlocklistEntry(regex);
setEntries((prev) => prev.filter((e) => e.regex !== regex));
} catch {
window.alert('Failed to remove entry.');
}
};

const handleDeleteAll = async () => {
if (
!window.confirm(
`Delete all ${entries.length} blocklist entries? This cannot be undone.`
)
)
return;
try {
await adminApi.deleteAllEmailBlocklistEntries();
await loadEntries();
} catch {
window.alert('Failed to delete all entries.');
}
};

return (
<>
<h2 className="header-page">Email Blocklist</h2>
<ul className="list-disc list-inside mb-4">
<li>
Blocks registration for emails matching any pattern. Does not affect
existing accounts.
</li>
<li>
Patterns are regexes matched against the full address. Mostly used for
domains (e.g. <code>@evildoge\.example\.com$</code>).
</li>
<li>
Use <code>$</code> to anchor to the end of the address — without it,{' '}
<code>@mozilla\.com</code> would also match{' '}
<code>@mozilla\.com.haxor.net</code>.
</li>
<li>
Enter one pattern per line, or upload a CSV/TXT file (one entry per
line). Duplicates are silently ignored.
</li>
<li>Blocked attempts are logged and counted in statsd.</li>
<li>
Changes propagate to auth-server within 5 minutes. Keep the list small
(hundreds of entries, not thousands) to avoid slowing registration.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be worth calling out in here too if we don't include a code fix to block it - but there's a risk of ReDoS with current setup. Obviously we have regex matching we do in other places, but those are config driven and require code review. Since this is an admin panel it is restricted but it's manual entry so there's a chance to accidentally enter a regex pattern with nested quantifiers and cause exponentially long matching when checking emails

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, GitHub security flagged this above as well. The rationale is that this is admin-only and the risk was low.

Regardless, I'll add a new fast-follow ticket to restrict arbitrary regex power -- limiting only to the case of adding domain names.

</li>
</ul>
<p className="mb-4">
<strong>
⚠️ Avoid complex patterns with nested quantifiers (e.g.{' '}
<code>{'(a+)+'}</code>).
</strong>{' '}
These can cause slow matching on every registration attempt. Stick to
simple domain patterns like <code>@domain\.com$</code>.
</p>

<form method="post" onSubmit={handleSubmit}>
<textarea
ref={textareaRef}
data-testid="blocklist-input"
name="regexList"
rows={8}
cols={60}
className="border-2 block"
placeholder={'@evildoge\\.example\\.com$\n@haxor\\.net$'}
/>
<br />
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt"
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
className={btnClass}
onClick={() => fileInputRef.current?.click()}
>
📂 Load from file…
</button>
&nbsp;
<button
type="submit"
data-testid="blocklist-add-btn"
className="bg-green-50 border-2 p-1 border-green-300 font-small leading-6 rounded"
disabled={submitting}
>
➕ Add Entries
</button>
</form>

<hr className="my-4" />

<div className="flex items-center justify-between mb-2">
<h2 className="header-page">Current Blocklist</h2>
{entries.length > 0 && (
<button
className="bg-red-50 border-2 p-1 border-red-300 font-small leading-6 rounded"
onClick={handleDeleteAll}
>
🗑️ Delete All
</button>
)}
</div>

{loading && <p>Loading…</p>}
{error && <p className="text-red-600">{error}</p>}
{!loading && !error && entries.length === 0 && (
<p className="result-grey">No entries yet.</p>
)}
{entries.length > 0 && (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-grey-50">
<th className="text-left p-2 border border-grey-100">Pattern</th>
<th className="text-left p-2 border border-grey-100">Added</th>
<th className="p-2 border border-grey-100"></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.regex} className="hover:bg-grey-10">
<td className="p-2 border border-grey-100 font-mono">
{entry.regex}
</td>
<td className="p-2 border border-grey-100 whitespace-nowrap">
{new Date(entry.createdAt).toISOString()}
</td>
<td className="p-2 border border-grey-100 text-center">
<button
data-testid={`delete-${entry.regex}`}
Comment thread
toufali marked this conversation as resolved.
className={btnClass}
onClick={() => handleDelete(entry.regex)}
>
🗑️ Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
);
};

export default PageEmailBlocklist;
10 changes: 10 additions & 0 deletions packages/fxa-admin-panel/src/images/icon-email-blocklist.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions packages/fxa-admin-panel/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
AccountDeleteResponse,
AccountDeleteTaskStatus,
AccountResetResponse,
EmailBlocklistEntry,
} from 'fxa-admin-server/src/types';

function baseUrl() {
Expand Down Expand Up @@ -249,4 +250,28 @@ export const adminApi = {
{ method: 'DELETE' }
);
},

// ---- Email blocklist ----

getEmailBlocklist(): Promise<EmailBlocklistEntry[]> {
return apiFetch('/api/email-blocklist');
},

addEmailBlocklistEntries(regexes: string[]): Promise<{ ok: boolean }> {
return apiFetch('/api/email-blocklist/add', {
method: 'POST',
body: JSON.stringify({ regexes }),
});
},

removeEmailBlocklistEntry(regex: string): Promise<{ removed: boolean }> {
return apiFetch('/api/email-blocklist', {
method: 'DELETE',
body: JSON.stringify({ regex }),
});
},

deleteAllEmailBlocklistEntries(): Promise<{ ok: boolean }> {
return apiFetch('/api/email-blocklist/all', { method: 'DELETE' });
},
};
Loading
Loading