@remit/drizzle-service 0.0.47 → 0.0.48
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,
|