Skip to content
Open
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
2 changes: 1 addition & 1 deletion app/controllers/api/v1/lookup.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async function lookup(ctx) {
// domain found must be that host or a parent of it)
domainName: isSANB(ctx.query.domain) ? ctx.query.domain : undefined,
username: isSANB(ctx.query.username)
? ctx.query.username.toLowerCase()
? ctx.query.username.normalize('NFC').toLowerCase()
: false,
ignoreBilling: ctx.query.ignore_billing,
client: ctx.client,
Expand Down
5 changes: 3 additions & 2 deletions app/models/aliases.js
Original file line number Diff line number Diff line change
Expand Up @@ -755,8 +755,9 @@ Aliases.pre('validate', function (next) {
});
}

// trim and convert to lowercase
this.name = this.name.trim().toLowerCase();
// trim, normalize Unicode (NFC) and convert to lowercase
// (e.g. so "blåtest" matches regardless of composed/decomposed "å")
this.name = this.name.trim().normalize('NFC').toLowerCase();

// require alias name
if (
Expand Down
2 changes: 1 addition & 1 deletion app/models/emails.js
Original file line number Diff line number Diff line change
Expand Up @@ -1553,7 +1553,7 @@ Emails.statics.queue = async function (
// rewrite from header to be without "+" symbol
// so that users can send with "+" address filtering
//
const name = parseUsername(addresses[0]); // converts to ASCII
const name = punycode.toASCII(parseUsername(addresses[0])); // converts to ASCII
const domain = parseHostFromDomainOrAddress(addresses[0]); // converts to ASCII
from = `${name}@${domain}`; // ASCII formatted From address header
}
Expand Down
29 changes: 17 additions & 12 deletions helpers/get-forwarding-addresses.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const i18n = require('#helpers/i18n');
const getForwardingConfiguration = require('#helpers/get-forwarding-configuration');
const getKeyInfo = require('#helpers/get-key-info');
const getMaxForwardedAddresses = require('#helpers/get-max-forwarded-addresses');
const getUsernameVariants = require('#helpers/get-username-variants');
const isEmail = require('#helpers/is-email');
const isExpiredOrNewlyCreated = require('#helpers/is-expired-or-newly-created');
const isRetryableError = require('#helpers/is-retryable-error');
Expand Down Expand Up @@ -469,6 +470,12 @@ async function getForwardingAddresses(
// (e.g. user@example.com => hello)
const username = isEmail(address) ? parseUsername(address) : false;

// Unicode and punycode forms of username to match alias names against
// (e.g. "fællestest" and legacy "xn--fllestest-g3a")
const usernameVariants = username ? getUsernameVariants(username) : [];
const isUsername = (name) =>
usernameVariants.includes(name.normalize('NFC').toLowerCase());

//
// store if the domain was bad and not on paid plan (required for bad domains)
//
Expand Down Expand Up @@ -987,7 +994,11 @@ async function getForwardingAddresses(
.catch((cacheErr) => logger.fatal(cacheErr));
}

if (username && regex && regex.test(username.toLowerCase())) {
const matchedUsername = regex
? usernameVariants.find((variant) => regex.test(variant))
: undefined;

if (matchedUsername) {
//
// opt-in subdomain-aware substitution:
// replace the literal tokens %SUBDOMAIN% and %HOST% in the target
Expand All @@ -1011,7 +1022,7 @@ async function getForwardingAddresses(
const hasDollarInterpolation = REGEX_INTERPOLATED_DOLLAR.test(target);

const substitutedAlias = hasDollarInterpolation
? username.toLowerCase().replace(regex, target)
? matchedUsername.replace(regex, target)
: target;

// RFC 5321 limits a mailbox address to 254 octets. Enforce the bound
Expand Down Expand Up @@ -1085,25 +1096,19 @@ async function getForwardingAddresses(
// check if we have a match (and if it is ignored)
if (_.isString(addr[0]) && addr[0].indexOf('!') === 0) {
// !!! -> 550
if (
addr[0].indexOf('!!!') === 0 &&
username === addr[0].toLowerCase().slice(3)
) {
if (addr[0].indexOf('!!!') === 0 && isUsername(addr[0].slice(3))) {
hardRejected = true;
break;
}

// !! -> 421
if (
addr[0].indexOf('!!') === 0 &&
username === addr[0].toLowerCase().slice(2)
) {
if (addr[0].indexOf('!!') === 0 && isUsername(addr[0].slice(2))) {
softRejected = true;
break;
}

// ! -> 250
if (username === addr[0].toLowerCase().slice(1)) {
if (isUsername(addr[0].slice(1))) {
ignored = true;
break;
}
Expand All @@ -1124,7 +1129,7 @@ async function getForwardingAddresses(
`${lowerCaseAddress} domain of ${domain} has an invalid "${config.recordPrefix}" TXT record due to an invalid email address of "${element}"`
);

if (_.isString(addr[0]) && username === addr[0].toLowerCase()) {
if (_.isString(addr[0]) && isUsername(addr[0])) {
if (isURL(addr[1], config.isURLOptions))
forwardingAddresses.push(addr[1]);
else forwardingAddresses.push(addr[1].toLowerCase());
Expand Down
32 changes: 14 additions & 18 deletions helpers/get-forwarding-configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const Domains = require('#models/domains');
const Users = require('#models/users');
const config = require('#config');
const email = require('#helpers/email');
const getUsernameVariants = require('#helpers/get-username-variants');
const i18n = require('#helpers/i18n');
const isEmail = require('#helpers/is-email');
const logger = require('#helpers/logger');
Expand Down Expand Up @@ -68,6 +69,11 @@ async function getForwardingConfiguration({
if (domainName !== undefined && !isVerifiedHost(domain.name, domainName))
return {};

// Unicode and punycode forms of username to match alias names against
// (e.g. "fællestest" and legacy "xn--fllestest-g3a")
const usernameVariants = username ? getUsernameVariants(username) : [];
if (username) username = usernameVariants[0];

let hasMultiplePGP = false;
let hasMultipleSMIME = false;

Expand Down Expand Up @@ -128,26 +134,21 @@ async function getForwardingConfiguration({

//
// NOTE: we are doing it this way for backwards compatibility for now
// (in the future we should probably just to `toUnicode` some users already converted so this supports legacy)
// (e.g. some users have aliases starting with "xn--" right now which means they already realized this bug)
// (e.g. some users have aliases starting with "xn--" right now
// since the local part was previously converted to punycode)
//
// if there were no aliases found but a `username` was passed
// then we can attempt to convert ASCII to Unicode and perform a lookup
if (
aliases.length === 0 &&
username &&
punycode.toUnicode(username) !== username
) {
// then we can attempt a lookup with its other Unicode/punycode forms
if (aliases.length === 0 && usernameVariants.length > 1) {
aliases = await Aliases.find({
domain: domain._id,
name: punycode.toUnicode(username)
name: { $in: usernameVariants.slice(1) }
})
.select(
'id user has_imap has_pgp public_key has_smime smime_certificate recipients name is_enabled error_code_if_disabled has_recipient_verification verified_recipients vacation_responder'
)
.lean()
.exec();
if (aliases.length > 0) username = punycode.toUnicode(username);
}

if (aliases.length === 0) return {};
Expand Down Expand Up @@ -444,8 +445,7 @@ async function getForwardingConfiguration({
for (const recipient of alias.recipients) {
if (!isEmail(recipient)) continue;
const [rcptName, rcptDomain] = recipient.split('@');
if (rcptName !== username && rcptName !== punycode.toUnicode(username))
continue;
if (!usernameVariants.includes(rcptName)) continue;
if (rcptDomain !== domain.name) continue;
const id = aliasIdsWithIMAPByName[rcptName];
if (!id) continue;
Expand Down Expand Up @@ -613,10 +613,7 @@ async function getForwardingConfiguration({
}

if (regex) {
if (
regex.test(username) ||
regex.test(punycode.toUnicode(username))
) {
if (usernameVariants.some((variant) => regex.test(variant))) {
pushToBody(alias);
}

Expand All @@ -625,8 +622,7 @@ async function getForwardingConfiguration({
}
}

if (username !== alias.name && punycode.toUnicode(username) !== alias.name)
continue;
if (!usernameVariants.includes(alias.name)) continue;

pushToBody(alias);
}
Expand Down
33 changes: 33 additions & 0 deletions helpers/get-username-variants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Copyright (c) Forward Email LLC
* SPDX-License-Identifier: BUSL-1.1
*/

const punycode = require('node:punycode');

//
// returns every form a username can be matched against in alias names
// and regular expressions (first entry is always the NFC Unicode form)
//
// e.g. "fællestest" -> [ "fællestest", "xn--fllestest-g3a" ]
// "xn--fllestest-g3a" -> [ "xn--fllestest-g3a", "fællestest" ]
//
// NOTE: aliases were previously matched against the punycode form of the
// local part, so some users created aliases starting with "xn--"
// which we still need to support for backwards compatibility
//
function getUsernameVariants(username) {
const variants = [username.normalize('NFC').toLowerCase()];
for (const fn of [punycode.toASCII, punycode.toUnicode]) {
try {
const variant = fn(variants[0]).normalize('NFC').toLowerCase();
if (!variants.includes(variant)) variants.push(variant);
} catch {
// ignore punycode conversion errors
}
}

return variants;
}

module.exports = getUsernameVariants;
13 changes: 8 additions & 5 deletions helpers/parse-username.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@
* SPDX-License-Identifier: BUSL-1.1
*/

const punycode = require('node:punycode');

const parseAddresses = require('#helpers/parse-addresses');

//
// NOTE: the local part is returned as Unicode (NFC normalized and lowercase)
// punycode is only defined for domain names, so a local part such as
// "fællestest" is not rewritten to "xn--fllestest-g3a" anymore
// (see `#helpers/get-username-variants` for matching legacy aliases)
//
function parseUsername(address, ignorePlus = false) {
address = parseAddresses(address)[0];
let username =
const username =
!ignorePlus && address.includes('+')
? address.split('+')[0]
: address.split('@')[0];

username = punycode.toASCII(username).toLowerCase();
return username;
return username.normalize('NFC').toLowerCase();
}

module.exports = parseUsername;
Loading