mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-15 16:19:20 +00:00
Merge pull request #4110 from HeyPuter/juancastro/icloud-dot-significance
Treat dots as significant on Apple's mail domains
This commit is contained in:
8 files changed
+216
-4
No files matched your search
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 86;
|
||||
const CURRENT_SCHEMA_VERSION = 87;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
|
||||
@@ -120,6 +120,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[83, ['0088_apps-index-url.sql']],
|
||||
[84, ['0089_team-require-2fa.sql']],
|
||||
[85, ['0090_user-app-permission-index.sql']],
|
||||
[86, ['0091_icloud-dot-significance.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Recompute `clean_email` for Apple's domains: dots are significant there, so
|
||||
-- rows written while they were stripped still collapse two mailboxes into one.
|
||||
-- Only rows still holding exactly what the old rule produced, so a NULL or a
|
||||
-- legacy un-lowercased value is left alone -- rewriting those would fold `+tag`
|
||||
-- or case into a key another row already owns. Restores dots and nothing else.
|
||||
|
||||
UPDATE `user`
|
||||
SET `clean_email` = CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(`email`), '@', -1))
|
||||
WHERE `email` IS NOT NULL
|
||||
AND LOCATE('@', `email`) > 1
|
||||
AND (
|
||||
LOWER(`email`) LIKE '%@icloud.com'
|
||||
OR LOWER(`email`) LIKE '%@me.com'
|
||||
OR LOWER(`email`) LIKE '%@mac.com'
|
||||
)
|
||||
AND `clean_email` = CONCAT(REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), '.', ''), '@', SUBSTRING_INDEX(LOWER(`email`), '@', -1))
|
||||
AND `clean_email` <> CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(`email`), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(`email`), '@', -1));
|
||||
|
||||
-- The same collapse decided who a pending invite belongs to, so the holder
|
||||
-- of the undotted address claims invites addressed to the dotted one. The
|
||||
-- typed address rides along as `invitedAddress` whenever it differed.
|
||||
UPDATE `share`
|
||||
SET `recipient_email` = CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', -1))
|
||||
WHERE JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress')) IS NOT NULL
|
||||
AND LOCATE('@', JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))) > 1
|
||||
AND (
|
||||
LOWER(`recipient_email`) LIKE '%@icloud.com'
|
||||
OR LOWER(`recipient_email`) LIKE '%@me.com'
|
||||
OR LOWER(`recipient_email`) LIKE '%@mac.com'
|
||||
)
|
||||
AND `recipient_email` = CONCAT(REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', 1), '+', 1), '.', ''), '@', SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', -1))
|
||||
AND `recipient_email` <> CONCAT(SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', 1), '+', 1), '@', SUBSTRING_INDEX(LOWER(JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.invitedAddress'))), '@', -1));
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Recompute `clean_email` for Apple's domains: dots are significant there, so
|
||||
-- rows written while they were stripped still collapse two mailboxes into one.
|
||||
-- Only rows still holding exactly what the old rule produced, so a NULL or a
|
||||
-- legacy un-lowercased value is left alone -- rewriting those would fold `+tag`
|
||||
-- or case into a key another row already owns. Restores dots and nothing else.
|
||||
|
||||
UPDATE "user"
|
||||
SET clean_email = (split_part(split_part(lower(email), '@', 1), '+', 1) || '@' || split_part(lower(email), '@', 2))
|
||||
WHERE email IS NOT NULL
|
||||
AND position('@' in email) > 1
|
||||
AND (
|
||||
lower(email) LIKE '%@icloud.com'
|
||||
OR lower(email) LIKE '%@me.com'
|
||||
OR lower(email) LIKE '%@mac.com'
|
||||
)
|
||||
AND clean_email = (replace(split_part(split_part(lower(email), '@', 1), '+', 1), '.', '') || '@' || split_part(lower(email), '@', 2))
|
||||
AND clean_email IS DISTINCT FROM (split_part(split_part(lower(email), '@', 1), '+', 1) || '@' || split_part(lower(email), '@', 2));
|
||||
|
||||
-- The same collapse decided who a pending invite belongs to, so the holder
|
||||
-- of the undotted address claims invites addressed to the dotted one. The
|
||||
-- typed address rides along as `invitedAddress` whenever it differed.
|
||||
UPDATE "share"
|
||||
SET recipient_email = (split_part(split_part(lower((data->>'invitedAddress')), '@', 1), '+', 1) || '@' || split_part(lower((data->>'invitedAddress')), '@', 2))
|
||||
WHERE (data->>'invitedAddress') IS NOT NULL
|
||||
AND position('@' in (data->>'invitedAddress')) > 1
|
||||
AND (
|
||||
lower(recipient_email) LIKE '%@icloud.com'
|
||||
OR lower(recipient_email) LIKE '%@me.com'
|
||||
OR lower(recipient_email) LIKE '%@mac.com'
|
||||
)
|
||||
AND recipient_email = (replace(split_part(split_part(lower((data->>'invitedAddress')), '@', 1), '+', 1), '.', '') || '@' || split_part(lower((data->>'invitedAddress')), '@', 2))
|
||||
AND recipient_email IS DISTINCT FROM (split_part(split_part(lower((data->>'invitedAddress')), '@', 1), '+', 1) || '@' || split_part(lower((data->>'invitedAddress')), '@', 2));
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Recompute `clean_email` for Apple's domains: dots are significant there, so
|
||||
-- rows written while they were stripped still collapse two mailboxes into one.
|
||||
-- Only rows still holding exactly what the old rule produced, so a NULL or a
|
||||
-- legacy un-lowercased value is left alone -- rewriting those would fold `+tag`
|
||||
-- or case into a key another row already owns. Restores dots and nothing else.
|
||||
|
||||
UPDATE `user`
|
||||
SET `clean_email` = (CASE WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(`email`), 1, instr(`email`, '@') - 1), 1,
|
||||
instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1)
|
||||
END || substr(lower(`email`), instr(`email`, '@')))
|
||||
WHERE `email` IS NOT NULL
|
||||
AND instr(`email`, '@') > 1
|
||||
AND (
|
||||
lower(`email`) LIKE '%@icloud.com'
|
||||
OR lower(`email`) LIKE '%@me.com'
|
||||
OR lower(`email`) LIKE '%@mac.com'
|
||||
)
|
||||
AND `clean_email` = (replace(CASE WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(`email`), 1, instr(`email`, '@') - 1), 1,
|
||||
instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1)
|
||||
END, '.', '') || substr(lower(`email`), instr(`email`, '@')))
|
||||
AND `clean_email` <> (CASE WHEN instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(`email`), 1, instr(`email`, '@') - 1), 1,
|
||||
instr(substr(lower(`email`), 1, instr(`email`, '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(`email`), 1, instr(`email`, '@') - 1)
|
||||
END || substr(lower(`email`), instr(`email`, '@')));
|
||||
|
||||
-- The same collapse decided who a pending invite belongs to, so the holder
|
||||
-- of the undotted address claims invites addressed to the dotted one. The
|
||||
-- typed address rides along as `invitedAddress` whenever it differed.
|
||||
UPDATE `share`
|
||||
SET `recipient_email` = (CASE WHEN instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), 1,
|
||||
instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1)
|
||||
END || substr(lower(json_extract(`data`, '$.invitedAddress')), instr(json_extract(`data`, '$.invitedAddress'), '@')))
|
||||
WHERE json_extract(`data`, '$.invitedAddress') IS NOT NULL
|
||||
AND instr(json_extract(`data`, '$.invitedAddress'), '@') > 1
|
||||
AND (
|
||||
lower(`recipient_email`) LIKE '%@icloud.com'
|
||||
OR lower(`recipient_email`) LIKE '%@me.com'
|
||||
OR lower(`recipient_email`) LIKE '%@mac.com'
|
||||
)
|
||||
AND `recipient_email` = (replace(CASE WHEN instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), 1,
|
||||
instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1)
|
||||
END, '.', '') || substr(lower(json_extract(`data`, '$.invitedAddress')), instr(json_extract(`data`, '$.invitedAddress'), '@')))
|
||||
AND `recipient_email` <> (CASE WHEN instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') > 0
|
||||
THEN substr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), 1,
|
||||
instr(substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1), '+') - 1)
|
||||
ELSE substr(lower(json_extract(`data`, '$.invitedAddress')), 1, instr(json_extract(`data`, '$.invitedAddress'), '@') - 1)
|
||||
END || substr(lower(json_extract(`data`, '$.invitedAddress')), instr(json_extract(`data`, '$.invitedAddress'), '@')));
|
||||
@@ -23,6 +23,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { cleanEmail } from '../../util/email.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import {
|
||||
@@ -390,6 +391,31 @@ describe('OIDCService.createUserFromOIDC', () => {
|
||||
expect(result.raced).toBe(true);
|
||||
});
|
||||
|
||||
it('does not resolve a dotted Apple address onto its undotted namesake', async () => {
|
||||
const stem = `js${crypto.randomBytes(4).toString('hex')}`;
|
||||
const victim = `${stem}@icloud.com`;
|
||||
const attacker = `${stem.slice(0, 2)}.${stem.slice(2)}@icloud.com`;
|
||||
|
||||
await server.stores.user.create({
|
||||
username: `v-${crypto.randomBytes(4).toString('hex')}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: 'hashed',
|
||||
email: victim,
|
||||
clean_email: cleanEmail(victim),
|
||||
email_confirmed: true,
|
||||
});
|
||||
|
||||
// Apple allocates the exact string, so these are two mailboxes.
|
||||
expect(cleanEmail(attacker)).not.toBe(cleanEmail(victim));
|
||||
await expect(
|
||||
oidc().findUserByEmail(attacker, { force: true }),
|
||||
).resolves.toBeNull();
|
||||
|
||||
// The undotted spelling is still the account it names.
|
||||
const found = await oidc().findUserByEmail(victim, { force: true });
|
||||
expect(found?.email).toBe(victim);
|
||||
});
|
||||
|
||||
it('leaves no orphan account behind when the identity was linked first', async () => {
|
||||
// The sub is already bound to another account, so `link()` throws after
|
||||
// this call has created its own user. That account can never be signed
|
||||
|
||||
@@ -61,8 +61,21 @@ describe('cleanEmail', () => {
|
||||
expect(cleanEmail('foo.bar@googlemail.com')).toBe('foobar@gmail.com');
|
||||
});
|
||||
|
||||
it('drops dots for icloud too', () => {
|
||||
expect(cleanEmail('foo.bar@icloud.com')).toBe('foobar@icloud.com');
|
||||
// Apple allocates the exact string: both of these can have owners.
|
||||
it('keeps dots for icloud, which treats them as significant', () => {
|
||||
expect(cleanEmail('foo.bar@icloud.com')).toBe('foo.bar@icloud.com');
|
||||
expect(cleanEmail('foobar@icloud.com')).toBe('foobar@icloud.com');
|
||||
for (const domain of ['icloud.com', 'me.com', 'mac.com']) {
|
||||
expect(cleanEmail(`a.b@${domain}`)).not.toBe(
|
||||
cleanEmail(`ab@${domain}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('still drops `+` for icloud, which does fold subaddressing', () => {
|
||||
expect(cleanEmail('foo.bar+tag@icloud.com')).toBe(
|
||||
'foo.bar@icloud.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps `+` for yahoo, which treats it as significant', () => {
|
||||
|
||||
@@ -46,7 +46,8 @@ const RULES: Record<RuleName, (p: Parts) => void> = {
|
||||
/** Rules each provider's semantics allow. Unlisted domains get none. */
|
||||
const PROVIDERS: Record<string, { rules: RuleName[] }> = {
|
||||
gmail: { rules: ['dots_dont_matter', 'remove_subaddressing'] },
|
||||
icloud: { rules: ['dots_dont_matter', 'remove_subaddressing'] },
|
||||
// Dots are significant at Apple: `j.smith@` and `jsmith@` are two mailboxes.
|
||||
icloud: { rules: ['remove_subaddressing'] },
|
||||
outlook: { rules: ['remove_subaddressing'] },
|
||||
proton: { rules: ['remove_subaddressing'] },
|
||||
fastmail: { rules: ['remove_subaddressing'] },
|
||||
|
||||
Reference in new issue
Block a user