@remit/drizzle-service 0.0.47 → 0.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The repair against the shape a deployment actually runs: the committed
|
|
3
|
-
* `CREATE TABLE` blocks, not a schema pushed from the drizzle table objects.
|
|
4
|
-
*
|
|
5
|
-
* What this has to hold, on a live database with no second copy of the data:
|
|
6
|
-
* every name the harvest guard would keep survives, a name that says something
|
|
7
|
-
* besides the address keeps that something, no row is removed, and a second run
|
|
8
|
-
* writes nothing.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
1
|
import assert from "node:assert/strict";
|
|
12
2
|
import { after, before, describe, test } from "node:test";
|
|
13
3
|
import { storedDisplayName } from "@remit/data-ports/display-name";
|
|
@@ -232,11 +222,6 @@ describe("rewriting display names that claim another address", () => {
|
|
|
232
222
|
});
|
|
233
223
|
});
|
|
234
224
|
|
|
235
|
-
/**
|
|
236
|
-
* The SQL in this repair narrows; it never decides. That only holds if no name
|
|
237
|
-
* the rule rewrites can slip past the narrowing — the case where a planted name
|
|
238
|
-
* would survive the sweep unseen.
|
|
239
|
-
*/
|
|
240
225
|
describe("the SQL narrowing is a superset of the rule", () => {
|
|
241
226
|
test("selects every name the rule rewrites", () => {
|
|
242
227
|
const sqlite = new Database(":memory:");
|
|
@@ -1,29 +1,5 @@
|
|
|
1
1
|
import { storedDisplayName } from "@remit/data-ports/display-name";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Rewriting the display names a spoofing sender already planted (issue #826).
|
|
5
|
-
*
|
|
6
|
-
* The harvest guard only decides what is stored from now on. Every name already
|
|
7
|
-
* written stays live, and none of the three write paths repairs one on a
|
|
8
|
-
* re-sync: `upsertAddress` refuses to overwrite a name with an empty one,
|
|
9
|
-
* `upsertEnvelopeAddress` is `onConflictDoNothing`, and a ThreadMessage is
|
|
10
|
-
* created once. On the instance that was hit there were 150 email-shaped
|
|
11
|
-
* display names in `address` alone, 30 of them naming a different address.
|
|
12
|
-
*
|
|
13
|
-
* The decision is `storedDisplayName` and nothing else. Expressing it a second
|
|
14
|
-
* time in SQL is what makes this dangerous: SQLite's `lower()` folds ASCII
|
|
15
|
-
* where JS folds all of Unicode, and its `trim()` and a literal space in a GLOB
|
|
16
|
-
* know only U+0020, so a SQL twin rewrites `Özcan@example.com` on
|
|
17
|
-
* `özcan@example.com` and `foo\tbar@baz.com` on anything — names the guard
|
|
18
|
-
* keeps, destroyed on a database holding the only copy. SQL narrows the scan
|
|
19
|
-
* and never decides.
|
|
20
|
-
*
|
|
21
|
-
* This is a repair rather than a migration because a migration is SQL, and SQL
|
|
22
|
-
* is exactly what must not hold the rule. It is convergent: a name the guard
|
|
23
|
-
* would keep is never rewritten twice, so re-running it, or resuming it after a
|
|
24
|
-
* crash part-way through, writes only what is left to write.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
3
|
export interface DisplayNameRepairClient {
|
|
28
4
|
all(sql: string, params: readonly unknown[]): Promise<unknown[]>;
|
|
29
5
|
run(sql: string, params: readonly unknown[]): Promise<number>;
|
|
@@ -31,53 +7,39 @@ export interface DisplayNameRepairClient {
|
|
|
31
7
|
|
|
32
8
|
export type DisplayNameRepairMode = "check" | "repair";
|
|
33
9
|
|
|
34
|
-
/**
|
|
35
|
-
* The scan narrows to names that could carry an address at all: everything
|
|
36
|
-
* `storedDisplayName` rewrites contains `x@y.zz`, so a row this misses cannot
|
|
37
|
-
* be claiming anything. It is a filter, never the decision — the pairing is
|
|
38
|
-
* pinned by a test that runs both halves over the same strings.
|
|
39
|
-
*/
|
|
40
10
|
export const EMBEDDED_ADDRESS_LIKE = "%_@_%.__%";
|
|
41
11
|
|
|
42
12
|
interface RepairSite {
|
|
43
13
|
readonly table: string;
|
|
44
|
-
readonly
|
|
45
|
-
readonly
|
|
46
|
-
readonly
|
|
47
|
-
|
|
48
|
-
readonly
|
|
49
|
-
/** What the write path stores for an absent name on this table. */
|
|
50
|
-
readonly absent: "" | null;
|
|
14
|
+
readonly keyColumn: string;
|
|
15
|
+
readonly nameColumn: string;
|
|
16
|
+
readonly emailColumn: string;
|
|
17
|
+
readonly searchCompoundColumn?: string;
|
|
18
|
+
readonly storedWhenNameIsAbsent: "" | null;
|
|
51
19
|
}
|
|
52
20
|
|
|
53
|
-
/**
|
|
54
|
-
* Every column an attacker-chosen name lands in. `address.display_name` is the
|
|
55
|
-
* one autocomplete reads; `envelope_address.display_name` is the From line the
|
|
56
|
-
* message header renders; `thread_message.from_name` is the sender label in the
|
|
57
|
-
* message list and the text the search index tokenizes.
|
|
58
|
-
*/
|
|
59
21
|
const SITES: readonly RepairSite[] = [
|
|
60
22
|
{
|
|
61
23
|
table: "address",
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
24
|
+
keyColumn: "address_id",
|
|
25
|
+
nameColumn: "display_name",
|
|
26
|
+
emailColumn: "normalized_email",
|
|
27
|
+
searchCompoundColumn: "normalized_compound",
|
|
28
|
+
storedWhenNameIsAbsent: "",
|
|
67
29
|
},
|
|
68
30
|
{
|
|
69
31
|
table: "envelope_address",
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
32
|
+
keyColumn: "envelope_address_id",
|
|
33
|
+
nameColumn: "display_name",
|
|
34
|
+
emailColumn: "normalized_email",
|
|
35
|
+
storedWhenNameIsAbsent: "",
|
|
74
36
|
},
|
|
75
37
|
{
|
|
76
38
|
table: "thread_message",
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
39
|
+
keyColumn: "thread_message_id",
|
|
40
|
+
nameColumn: "from_name",
|
|
41
|
+
emailColumn: "from_email",
|
|
42
|
+
storedWhenNameIsAbsent: null,
|
|
81
43
|
},
|
|
82
44
|
];
|
|
83
45
|
|
|
@@ -110,44 +72,37 @@ const candidates = async (
|
|
|
110
72
|
site: RepairSite,
|
|
111
73
|
): Promise<CandidateRow[]> => {
|
|
112
74
|
const rows = await client.all(
|
|
113
|
-
`SELECT ${site.
|
|
75
|
+
`SELECT ${site.keyColumn} AS id, ${site.nameColumn} AS name, ${site.emailColumn} AS email
|
|
114
76
|
FROM ${site.table}
|
|
115
|
-
WHERE ${site.
|
|
77
|
+
WHERE ${site.nameColumn} LIKE ?`,
|
|
116
78
|
[EMBEDDED_ADDRESS_LIKE],
|
|
117
79
|
);
|
|
118
80
|
return rows.filter(isCandidateRow);
|
|
119
81
|
};
|
|
120
82
|
|
|
121
|
-
/**
|
|
122
|
-
* One row at a time, because each row keeps a different remainder. The set is
|
|
123
|
-
* what a spoofing sender planted, not the table.
|
|
124
|
-
*/
|
|
125
83
|
const rewrite = async (
|
|
126
84
|
client: DisplayNameRepairClient,
|
|
127
85
|
site: RepairSite,
|
|
128
86
|
row: CandidateRow,
|
|
129
87
|
stored: string,
|
|
130
88
|
): Promise<number> => {
|
|
131
|
-
const columns = [`${site.
|
|
132
|
-
const params: unknown[] = [
|
|
89
|
+
const columns = [`${site.nameColumn} = ?`];
|
|
90
|
+
const params: unknown[] = [
|
|
91
|
+
stored === "" ? site.storedWhenNameIsAbsent : stored,
|
|
92
|
+
];
|
|
133
93
|
|
|
134
|
-
if (site.
|
|
135
|
-
columns.push(`${site.
|
|
94
|
+
if (site.searchCompoundColumn) {
|
|
95
|
+
columns.push(`${site.searchCompoundColumn} = ?`);
|
|
136
96
|
params.push(`${stored.toLowerCase()} ${row.email ?? ""}`.trim());
|
|
137
97
|
}
|
|
138
98
|
params.push(row.id);
|
|
139
99
|
|
|
140
100
|
return client.run(
|
|
141
|
-
`UPDATE ${site.table} SET ${columns.join(", ")} WHERE ${site.
|
|
101
|
+
`UPDATE ${site.table} SET ${columns.join(", ")} WHERE ${site.keyColumn} = ?`,
|
|
142
102
|
params,
|
|
143
103
|
);
|
|
144
104
|
};
|
|
145
105
|
|
|
146
|
-
/**
|
|
147
|
-
* `check` writes nothing, so it can be pointed at a live instance; `repair`
|
|
148
|
-
* runs the same scan and rewrites what it finds. One code path, so the report
|
|
149
|
-
* can never describe a decision the repair does not make.
|
|
150
|
-
*/
|
|
151
106
|
export const sweepDisplayNames = async (
|
|
152
107
|
client: DisplayNameRepairClient,
|
|
153
108
|
mode: DisplayNameRepairMode,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { after, before, describe, test } from "node:test";
|
|
4
|
+
import { mailboxSpecialUseTable, mailboxTable } from "../schema.js";
|
|
5
|
+
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
6
|
+
import { MailboxRepo } from "./i4-mailbox.js";
|
|
7
|
+
import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
|
|
8
|
+
|
|
9
|
+
const makeMailboxInput = (accountId: string, fullPath: string) => ({
|
|
10
|
+
accountId,
|
|
11
|
+
namespaceType: "personal" as const,
|
|
12
|
+
namespacePrefix: "",
|
|
13
|
+
hierarchyDelimiter: "/",
|
|
14
|
+
fullPath,
|
|
15
|
+
uidValidity: 1,
|
|
16
|
+
uidNext: 1,
|
|
17
|
+
highestModseq: "0",
|
|
18
|
+
messageCount: 0,
|
|
19
|
+
unseenCount: 0,
|
|
20
|
+
deletedCount: 0,
|
|
21
|
+
totalSize: 0,
|
|
22
|
+
lastSyncUid: 0,
|
|
23
|
+
highWaterMarkUid: 0,
|
|
24
|
+
lastMessageSyncAt: Date.now(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("MailboxSpecialUseRepo.findJunkMailbox (sqlite)", () => {
|
|
28
|
+
let close: () => Promise<void>;
|
|
29
|
+
let repo: MailboxSpecialUseRepo;
|
|
30
|
+
let mailboxes: MailboxRepo;
|
|
31
|
+
|
|
32
|
+
before(async () => {
|
|
33
|
+
const testDb = await createSqliteTestDb({
|
|
34
|
+
mailbox: mailboxTable,
|
|
35
|
+
mailboxSpecialUse: mailboxSpecialUseTable,
|
|
36
|
+
});
|
|
37
|
+
close = testDb.close;
|
|
38
|
+
repo = new MailboxSpecialUseRepo(testDb.db as never);
|
|
39
|
+
mailboxes = new MailboxRepo(testDb.db as never);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
after(async () => {
|
|
43
|
+
await close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("resolves an INBOX-nested Junk folder that advertises \\Junk", async () => {
|
|
47
|
+
const accountId = randomUUID();
|
|
48
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
|
|
49
|
+
const spam = await mailboxes.create(
|
|
50
|
+
makeMailboxInput(accountId, "INBOX/Spam"),
|
|
51
|
+
);
|
|
52
|
+
await repo.create(spam.mailboxId, "Junk");
|
|
53
|
+
|
|
54
|
+
const found = await repo.findJunkMailbox(accountId);
|
|
55
|
+
assert.equal(found?.mailboxId, spam.mailboxId);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("resolves an INBOX-nested Junk folder that advertises no special use", async () => {
|
|
59
|
+
// The name fallback used to compare the whole path against a list of
|
|
60
|
+
// bare names, so `INBOX/Spam` resolved to nothing on a server that
|
|
61
|
+
// advertises no \Junk.
|
|
62
|
+
const accountId = randomUUID();
|
|
63
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
|
|
64
|
+
const spam = await mailboxes.create(
|
|
65
|
+
makeMailboxInput(accountId, "INBOX/Spam"),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const found = await repo.findJunkMailbox(accountId);
|
|
69
|
+
assert.equal(found?.mailboxId, spam.mailboxId);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("answers null when the account has no Junk folder at all", async () => {
|
|
73
|
+
const accountId = randomUUID();
|
|
74
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
|
|
75
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Work"));
|
|
76
|
+
|
|
77
|
+
assert.equal(await repo.findJunkMailbox(accountId), null);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
MailboxSpecialUseItem,
|
|
4
4
|
MailboxSpecialUseValue,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
|
+
import { resolveMailboxByLeafName } from "@remit/data-ports/mailbox-name";
|
|
6
7
|
import { eq } from "drizzle-orm";
|
|
7
8
|
import type { Db } from "../db.js";
|
|
8
9
|
import { randomId } from "../id.js";
|
|
@@ -10,6 +11,14 @@ import { mailboxSpecialUseTable, mailboxTable } from "../schema/i4-mailbox.js";
|
|
|
10
11
|
|
|
11
12
|
type DB = Db<Record<string, unknown>>;
|
|
12
13
|
|
|
14
|
+
const JUNK_FOLDER_NAMES = [
|
|
15
|
+
"junk",
|
|
16
|
+
"spam",
|
|
17
|
+
"junk e-mail",
|
|
18
|
+
"junk email",
|
|
19
|
+
"bulk mail",
|
|
20
|
+
];
|
|
21
|
+
|
|
13
22
|
function rowToSpecialUse(
|
|
14
23
|
row: typeof mailboxSpecialUseTable.$inferSelect,
|
|
15
24
|
): MailboxSpecialUseItem {
|
|
@@ -155,9 +164,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
155
164
|
.select()
|
|
156
165
|
.from(mailboxTable)
|
|
157
166
|
.where(eq(mailboxTable.accountId, accountId));
|
|
158
|
-
|
|
159
|
-
const names = ["junk", "spam", "bulk mail", "junk e-mail", "[gmail]/spam"];
|
|
160
|
-
const found = rows.find((r) => names.includes(r.fullPath.toLowerCase()));
|
|
167
|
+
const found = resolveMailboxByLeafName(rows, JUNK_FOLDER_NAMES);
|
|
161
168
|
return found
|
|
162
169
|
? { mailboxId: found.mailboxId, fullPath: found.fullPath }
|
|
163
170
|
: null;
|