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
14 changes: 9 additions & 5 deletions entrypoints/popup/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function mxtoolboxEmailHealthUrl(domain: string): string {
}

const DNS_TECHNIQUE_DISCLOSURE =
'DNS queries use DNS-over-HTTPS (Cloudflare / Google). Entra probe uses HTTPS only; no MTA-STS policy files or cert inspection. DKIM probes *._domainkey first, then _domainkey and common selectors.';
'DNS queries use DNS-over-HTTPS (Cloudflare / Google). Entra probe uses HTTPS only; no MTA-STS policy files or cert inspection. DKIM probes _domainkey for null DKIM, then provider/common selectors, then *._domainkey.';

const WALL_OF_SHAME_REPO = 'jkerai1/DMARC-WallOfShame';

Expand Down Expand Up @@ -148,10 +148,14 @@ function openUrlInNewTab(url: string): void {
a.remove();
}

function hasReportableDmarcIssue(result: CheckResult): boolean {
return result.full.dmarc.status !== 'pass';
}

function renderResultFooterActions(result: CheckResult): string {
const showCastShame = result.full.dmarc.status === 'fail';
const showCastShame = hasReportableDmarcIssue(result);
const castShameBtn = showCastShame
? `<button type="button" class="footer-action-btn footer-action-btn--shame" id="btn-cast-shame">Cast shame</button>`
? `<button type="button" class="footer-action-btn footer-action-btn--shame" id="btn-cast-shame">Report DMARC issue</button>`
: '';
return `
<div class="fab-row fab-row--footer fab-row--split">
Expand All @@ -169,7 +173,7 @@ function renderCastShameModal(result: CheckResult): string {
<div class="cast-shame-modal" id="cast-shame-modal" hidden aria-hidden="true">
<div class="cast-shame-modal__backdrop" id="cast-shame-backdrop" aria-hidden="true"></div>
<div class="cast-shame-modal__panel" role="dialog" aria-modal="true" aria-labelledby="cast-shame-heading">
<h2 class="cast-shame-modal__title" id="cast-shame-heading">Submit to DMARC wall of Shame</h2>
<h2 class="cast-shame-modal__title" id="cast-shame-heading">Report DMARC issue</h2>
<p class="cast-shame-modal__lede">Please submit the company name for the DMARC issue.</p>
<label class="cast-shame-modal__label" for="cast-shame-company">Company name</label>
<input type="text" class="cast-shame-modal__input" id="cast-shame-company" autocomplete="organization" maxlength="160" placeholder="e.g. Acme Ltd" />
Expand Down Expand Up @@ -463,7 +467,7 @@ function renderResult(result: CheckResult): void {
const tabDiffers = result.tabHostname !== result.queryHostname;
const detailedBreakdown = settings.detailedBreakdown;
const castShameModal =
full.dmarc.status === 'fail' ? renderCastShameModal(result) : '';
hasReportableDmarcIssue(result) ? renderCastShameModal(result) : '';

const spfSupplement =
result.spfMailProviderHint &&
Expand Down
138 changes: 138 additions & 0 deletions lib/checkDomain.dnsError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ beforeEach(() => {
vi.mocked(queryTxt.resolveTxtDetailed).mockReset();
});

function dkimLookupNames(): string[] {
return vi.mocked(queryTxt.resolveTxtDetailed)
.mock.calls
.map(([name]) => name)
.filter((name) => name.includes('_domainkey'));
}

describe('runDnsCheck DNS resolution errors', () => {
it('marks SPF as fail when TXT lookup is non-definitive and strict mode is on', async () => {
vi.mocked(queryTxt.resolveTxtDetailed).mockImplementation(
Expand Down Expand Up @@ -97,3 +104,134 @@ describe('runDnsCheck DNS resolution errors', () => {
);
});
});

describe('runDnsCheck DKIM probe order', () => {
it('checks fallback selectors when there is no wildcard record', async () => {
vi.mocked(queryTxt.resolveTxtDetailed).mockImplementation(
async (name: string) => {
if (name === 'example.com') {
return { strings: ['v=spf1 -all'], dnsState: 'ok' };
}
if (name === '_dmarc.example.com') {
return { strings: ['v=DMARC1; p=reject;'], dnsState: 'ok' };
}
if (name === 'default._domainkey.example.com') {
return { strings: ['v=DKIM1; k=rsa; p=MII'], dnsState: 'ok' };
}
if (name.includes('_domainkey')) {
return { strings: [], dnsState: 'nxdomain' };
}
return { strings: [], dnsState: 'ok' };
},
);

const r = await runDnsCheck('example.com');

expect(r.dkim.selector).toBe('default');
expect(r.full.dkim.status).toBe('pass');
expect(dkimLookupNames()).toEqual([
'_domainkey.example.com',
'google._domainkey.example.com',
'default._domainkey.example.com',
]);
});

it('chooses a valid selector before considering wildcard DKIM', async () => {
vi.mocked(queryTxt.resolveTxtDetailed).mockImplementation(
async (name: string) => {
if (name === 'example.com') {
return { strings: ['v=spf1 -all'], dnsState: 'ok' };
}
if (name === '_dmarc.example.com') {
return { strings: ['v=DMARC1; p=reject;'], dnsState: 'ok' };
}
if (name === 'google._domainkey.example.com') {
return { strings: ['v=DKIM1; k=rsa; p=MII'], dnsState: 'ok' };
}
if (name === '*._domainkey.example.com') {
return { strings: ['not a DKIM record'], dnsState: 'ok' };
}
if (name.includes('_domainkey')) {
return { strings: [], dnsState: 'nxdomain' };
}
return { strings: [], dnsState: 'ok' };
},
);

const r = await runDnsCheck('example.com');

expect(r.dkim.selector).toBe('google');
expect(dkimLookupNames()).toEqual([
'_domainkey.example.com',
'google._domainkey.example.com',
]);
});

it('stops at null DKIM on _domainkey', async () => {
vi.mocked(queryTxt.resolveTxtDetailed).mockImplementation(
async (name: string) => {
if (name === 'example.com') {
return { strings: ['v=spf1 -all'], dnsState: 'ok' };
}
if (name === '_dmarc.example.com') {
return { strings: ['v=DMARC1; p=reject;'], dnsState: 'ok' };
}
if (name === '_domainkey.example.com') {
return { strings: ['v=DKIM1; p='], dnsState: 'ok' };
}
if (name.includes('_domainkey')) {
return { strings: [], dnsState: 'nxdomain' };
}
return { strings: [], dnsState: 'ok' };
},
);

const r = await runDnsCheck('example.com');

expect(r.dkim.selector).toBe('_domainkey');
expect(r.full.dkim.status).toBe('pass');
expect(dkimLookupNames()).toEqual(['_domainkey.example.com']);
});

it('tries MX provider selectors before fallback selectors', async () => {
vi.mocked(mailInfra.runMailInfraChecks).mockResolvedValue([
{
id: 'mx',
title: 'MX',
status: 'pass',
summary: 'Microsoft 365',
lines: [],
providerProfile: {
name: 'Microsoft 365',
dkimSelectors: ['selector1', 'selector2'],
},
},
]);
vi.mocked(queryTxt.resolveTxtDetailed).mockImplementation(
async (name: string) => {
if (name === 'example.com') {
return { strings: ['v=spf1 -all'], dnsState: 'ok' };
}
if (name === '_dmarc.example.com') {
return { strings: ['v=DMARC1; p=reject;'], dnsState: 'ok' };
}
if (name === 'selector2._domainkey.example.com') {
return { strings: ['v=DKIM1; k=rsa; p=MII'], dnsState: 'ok' };
}
if (name.includes('_domainkey')) {
return { strings: [], dnsState: 'nxdomain' };
}
return { strings: [], dnsState: 'ok' };
},
);

const r = await runDnsCheck('example.com');

expect(r.dkim.selector).toBe('selector2');
expect(dkimLookupNames()).toEqual([
'_domainkey.example.com',
'selector1._domainkey.example.com',
'selector2._domainkey.example.com',
]);
});
});
106 changes: 54 additions & 52 deletions lib/checkDomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ const DNS_FAIL_DMARC = (org: string): GradeLine[] => [
function dnsFailDkimLines(mxProfileSelectors?: readonly string[]): GradeLine[] {
const probeHint =
mxProfileSelectors && mxProfileSelectors.length > 0
? `configured selectors (${mxProfileSelectors.join(', ')})`
? `configured selectors (${mxProfileSelectors.join(', ')}) or fallback selectors`
: 'any probed selector';
return [
{
status: 'fail',
text: `Could not resolve DKIM TXT at *._domainkey, _domainkey, or ${probeHint} (DNS error or non-definitive response).`,
text: `Could not resolve DKIM TXT at _domainkey, ${probeHint}, or *._domainkey (DNS error or non-definitive response).`,
},
];
}
Expand Down Expand Up @@ -165,64 +165,66 @@ export async function runDnsCheck(
let dkimBest: (DkimRecordAnalysis & { selector: string }) | null = null;
let hadDefinitiveDkimLookup = false;

const wildcardDkimDet = await resolveTxtDetailed(
dkimDnsWildcardFqdn(queryHost),
dnsTxt,
);
const apexDkimFqdn = `_domainkey.${queryHost}`;
const apexDkimDet = await resolveTxtDetailed(apexDkimFqdn, dnsTxt);
if (
wildcardDkimDet.dnsState === 'ok' ||
wildcardDkimDet.dnsState === 'nxdomain'
apexDkimDet.dnsState === 'ok' ||
apexDkimDet.dnsState === 'nxdomain'
) {
hadDefinitiveDkimLookup = true;
}
const wildcardTxts = analysisStrings(wildcardDkimDet, treatDnsAsFail);
const wildcardMerged = mergeTxtForDkim(wildcardTxts);
const wildcardRec = analyzeDkimRecord(wildcardMerged);
const wildcardOverrides =
isNullDkimDeclaration(wildcardRec) ||
wildcardRec.valid ||
Boolean(wildcardRec.raw);
const apexDkimTxts = analysisStrings(apexDkimDet, treatDnsAsFail);
const apexDkimMerged = mergeTxtForDkim(apexDkimTxts);
const apexDkimRec = analyzeDkimRecord(apexDkimMerged);

if (wildcardOverrides) {
dkimBest = { ...wildcardRec, selector: '*' };
if (isNullDkimDeclaration(apexDkimRec)) {
dkimBest = { ...apexDkimRec, selector: '_domainkey' };
} else {
const apexDkimFqdn = `_domainkey.${queryHost}`;
const apexDkimDet = await resolveTxtDetailed(apexDkimFqdn, dnsTxt);
if (
apexDkimDet.dnsState === 'ok' ||
apexDkimDet.dnsState === 'nxdomain'
) {
hadDefinitiveDkimLookup = true;
if (apexDkimRec.raw) {
dkimBest = { ...apexDkimRec, selector: '_domainkey' };
}
const apexDkimTxts = analysisStrings(apexDkimDet, treatDnsAsFail);
const apexDkimMerged = mergeTxtForDkim(apexDkimTxts);
const apexDkimRec = analyzeDkimRecord(apexDkimMerged);

if (isNullDkimDeclaration(apexDkimRec)) {
dkimBest = { ...apexDkimRec, selector: '_domainkey' };
} else if (apexDkimRec.valid) {
dkimBest = { ...apexDkimRec, selector: '_domainkey' };
} else {
for (const sel of dkimProbeSelectors) {
const name = `${sel}._domainkey.${queryHost}`;
const det = await resolveTxtDetailed(name, dnsTxt);
if (det.dnsState === 'ok' || det.dnsState === 'nxdomain') {
hadDefinitiveDkimLookup = true;
}
const txts = analysisStrings(det, treatDnsAsFail);
const merged = mergeTxtForDkim(txts);
const rec = analyzeDkimRecord(merged);
const tagged: DkimRecordAnalysis & { selector: string } = {
...rec,
selector: sel,
};
if (rec.valid) {
dkimBest = tagged;
break;
}
if (!dkimBest && rec.raw) {
dkimBest = tagged;
}
for (const sel of dkimProbeSelectors) {
const name = `${sel}._domainkey.${queryHost}`;
const det = await resolveTxtDetailed(name, dnsTxt);
if (det.dnsState === 'ok' || det.dnsState === 'nxdomain') {
hadDefinitiveDkimLookup = true;
}
const txts = analysisStrings(det, treatDnsAsFail);
const merged = mergeTxtForDkim(txts);
const rec = analyzeDkimRecord(merged);
const tagged: DkimRecordAnalysis & { selector: string } = {
...rec,
selector: sel,
};
if (rec.valid) {
dkimBest = tagged;
break;
}
if (!dkimBest && rec.raw) {
dkimBest = tagged;
}
}

if (!dkimBest || !dkimBest.valid) {
const wildcardDkimDet = await resolveTxtDetailed(
dkimDnsWildcardFqdn(queryHost),
dnsTxt,
);
if (
wildcardDkimDet.dnsState === 'ok' ||
wildcardDkimDet.dnsState === 'nxdomain'
) {
hadDefinitiveDkimLookup = true;
}
const wildcardTxts = analysisStrings(wildcardDkimDet, treatDnsAsFail);
const wildcardMerged = mergeTxtForDkim(wildcardTxts);
const wildcardRec = analyzeDkimRecord(wildcardMerged);

if (wildcardRec.valid || isNullDkimDeclaration(wildcardRec)) {
dkimBest = { ...wildcardRec, selector: '*' };
} else if (!dkimBest && wildcardRec.raw) {
dkimBest = { ...wildcardRec, selector: '*' };
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/checks/mailInfra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type MailInfraCheck = {
providerProfile?: {
name: string;
expectedSpfInclude?: string;
/** MX provider profile selectors; DKIM DNS probes use these exclusively when present. */
/** MX provider profile selectors; DKIM DNS probes prefer these before common fallbacks. */
dkimSelectors?: string[];
};
};
Expand Down
14 changes: 13 additions & 1 deletion lib/parse/dkim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,29 @@ describe('dkimSelectorsForDnsProbe', () => {
expect(dkimSelectorsForDnsProbe([])).toEqual(fallback);
});

it('uses only MX profile selectors when provided', () => {
it('uses MX profile selectors before fallback selectors when provided', () => {
expect(dkimSelectorsForDnsProbe(['selector1', 'selector2'])).toEqual([
'selector1',
'selector2',
'google',
'default',
'k1',
's1',
'dkim',
'mail',
]);
});

it('dedupes and trims profile selectors', () => {
expect(dkimSelectorsForDnsProbe(['selector1 ', ' Selector1', 'selector2'])).toEqual([
'selector1',
'selector2',
'google',
'default',
'k1',
's1',
'dkim',
'mail',
]);
});

Expand Down
Loading