kryptheon-night 0.1.0
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/README.md +158 -0
- package/attack.js +555 -0
- package/bin/kryptheon-night.js +248 -0
- package/collision.js +309 -0
- package/connect.js +60 -0
- package/finding.js +552 -0
- package/intro.js +202 -0
- package/orphan.js +230 -0
- package/package.json +73 -0
- package/recheck.js +219 -0
- package/scan.js +569 -0
- package/schema.js +922 -0
- package/tamper.js +208 -0
- package/trouble.js +377 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// The front door.
|
|
4
|
+
//
|
|
5
|
+
// npx kryptheon-night
|
|
6
|
+
//
|
|
7
|
+
// No arguments, no environment variable, no flags. It asks for what it needs,
|
|
8
|
+
// says what it is about to do, waits to be told yes, and then runs.
|
|
9
|
+
//
|
|
10
|
+
// `scan.js` is the same program underneath and still takes its connection
|
|
11
|
+
// string and schema on the command line - that is the door for scripts and
|
|
12
|
+
// for the checks in this repo, and it has not changed. This file exists
|
|
13
|
+
// because the person this product is for does not have a command line habit.
|
|
14
|
+
// They have a Lovable app, a Supabase project somebody set up for them, and a
|
|
15
|
+
// worry that anyone can read their customers table. Everything between them
|
|
16
|
+
// and an answer is in this file.
|
|
17
|
+
//
|
|
18
|
+
// Exit codes are the same three as `scan.js`, because anything that automates
|
|
19
|
+
// this reads those and not the words:
|
|
20
|
+
//
|
|
21
|
+
// 0 every attack that ran, lost
|
|
22
|
+
// 1 something got through
|
|
23
|
+
// 2 it could not run at all
|
|
24
|
+
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const { Client } = require('pg');
|
|
27
|
+
|
|
28
|
+
const scanner = require('../scan.js');
|
|
29
|
+
const recheck = require('../recheck.js');
|
|
30
|
+
const intro = require('../intro.js');
|
|
31
|
+
const trouble = require('../trouble.js');
|
|
32
|
+
const { howToConnect } = require('../connect.js');
|
|
33
|
+
|
|
34
|
+
const line = (text) => process.stdout.write(text + '\n');
|
|
35
|
+
const fail = (text) => process.stderr.write(text + '\n');
|
|
36
|
+
|
|
37
|
+
function usage() {
|
|
38
|
+
return [
|
|
39
|
+
'',
|
|
40
|
+
' kryptheon-night - attacks a copy of your database and tells you what got in.',
|
|
41
|
+
'',
|
|
42
|
+
' Run it with nothing and it will ask you for what it needs:',
|
|
43
|
+
'',
|
|
44
|
+
' npx kryptheon-night',
|
|
45
|
+
'',
|
|
46
|
+
' Options, none of them necessary:',
|
|
47
|
+
'',
|
|
48
|
+
' --recheck run the same attacks again after a fix, and say',
|
|
49
|
+
' which problems are actually closed',
|
|
50
|
+
' --schema NAME the part of the database your app lives in.',
|
|
51
|
+
' Leave it out; it is "public" for almost everyone',
|
|
52
|
+
' --yes skip the "may I?" question. For scripts only',
|
|
53
|
+
' --help this',
|
|
54
|
+
'',
|
|
55
|
+
' The connection string can be put in KN_DATABASE_URL instead of being',
|
|
56
|
+
' typed in, which is how you would run this on a schedule.',
|
|
57
|
+
'',
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What was asked for, and what was left to the default. */
|
|
62
|
+
function readArgs(argv) {
|
|
63
|
+
const asked = { schema: null, recheck: false, yes: false, help: false, unknown: [] };
|
|
64
|
+
for (let i = 0; i < argv.length; i++) {
|
|
65
|
+
const arg = argv[i];
|
|
66
|
+
if (arg === '--recheck') asked.recheck = true;
|
|
67
|
+
else if (arg === '--yes' || arg === '-y') asked.yes = true;
|
|
68
|
+
else if (arg === '--help' || arg === '-h') asked.help = true;
|
|
69
|
+
else if (arg === '--schema') asked.schema = argv[++i] || null;
|
|
70
|
+
else if (arg.startsWith('--schema=')) asked.schema = arg.slice('--schema='.length);
|
|
71
|
+
else if (arg.startsWith('-')) asked.unknown.push(arg);
|
|
72
|
+
// A bare word is the schema, so that somebody who has read the old
|
|
73
|
+
// instructions and types `kryptheon-night public` is not told off.
|
|
74
|
+
else if (!asked.schema) asked.schema = arg;
|
|
75
|
+
else asked.unknown.push(arg);
|
|
76
|
+
}
|
|
77
|
+
return asked;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Prints a block of plain lines with the indent the rest of the report uses. */
|
|
81
|
+
function block(lines, write) {
|
|
82
|
+
(write || line)('');
|
|
83
|
+
for (const text of lines) (write || line)(text ? ' ' + text : '');
|
|
84
|
+
(write || line)('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const asked = readArgs(process.argv.slice(2));
|
|
89
|
+
|
|
90
|
+
if (asked.help) {
|
|
91
|
+
usage().forEach(line);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (asked.unknown.length) {
|
|
95
|
+
fail('');
|
|
96
|
+
fail(' I do not know the option ' + asked.unknown[0] + '.');
|
|
97
|
+
usage().forEach(fail);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
line('');
|
|
102
|
+
line(' Kryptheon Night Shift');
|
|
103
|
+
line(' I attack a copy of your database and tell you what got in.');
|
|
104
|
+
|
|
105
|
+
// Asking needs somewhere to ask. Piped into a script or run by a scheduler
|
|
106
|
+
// there is no keyboard, and a prompt written to nobody looks exactly like a
|
|
107
|
+
// program that has frozen.
|
|
108
|
+
const canAsk = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
109
|
+
|
|
110
|
+
let connection = process.env.KN_DATABASE_URL || '';
|
|
111
|
+
if (!connection) {
|
|
112
|
+
if (!canAsk) {
|
|
113
|
+
fail('');
|
|
114
|
+
fail(' I need a connection string and there is nobody here to ask.');
|
|
115
|
+
fail('');
|
|
116
|
+
fail(' Run me in a terminal window, or set KN_DATABASE_URL first.');
|
|
117
|
+
fail('');
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
connection = await intro.askForConnection(line);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Everything that can be told from the string alone is told now, before a
|
|
124
|
+
// single packet goes anywhere. These are the mistakes a person makes on
|
|
125
|
+
// their first try, and Postgres describes every one of them badly.
|
|
126
|
+
const unusable = trouble.readConnectionString(connection);
|
|
127
|
+
if (unusable) {
|
|
128
|
+
block(unusable, fail);
|
|
129
|
+
process.exit(2);
|
|
130
|
+
}
|
|
131
|
+
connection = connection.trim().replace(/^["']|["']$/g, '');
|
|
132
|
+
|
|
133
|
+
const warning = trouble.poolerWarning(connection);
|
|
134
|
+
if (warning) block(warning);
|
|
135
|
+
|
|
136
|
+
const target = asked.schema || 'public';
|
|
137
|
+
|
|
138
|
+
// The password is not shown back, but the address is - it is the part a
|
|
139
|
+
// person can check, and seeing their own project name appear is the first
|
|
140
|
+
// sign that any of this is working.
|
|
141
|
+
line('');
|
|
142
|
+
line(' Database: ' + trouble.withoutSecret(connection));
|
|
143
|
+
|
|
144
|
+
if (!asked.yes) {
|
|
145
|
+
if (!canAsk) {
|
|
146
|
+
fail('');
|
|
147
|
+
fail(' I will not connect to your database without being told yes, and');
|
|
148
|
+
fail(' there is nobody here to ask.');
|
|
149
|
+
fail('');
|
|
150
|
+
fail(' Add --yes if you meant to run this unattended.');
|
|
151
|
+
fail('');
|
|
152
|
+
process.exit(2);
|
|
153
|
+
}
|
|
154
|
+
block(intro.consentLines(target));
|
|
155
|
+
const yes = await intro.askYesNo(' Go ahead? (y/n) ');
|
|
156
|
+
if (!yes) {
|
|
157
|
+
line('');
|
|
158
|
+
line(' Stopped. Nothing was connected to and nothing was changed.');
|
|
159
|
+
line('');
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const file = path.resolve(scanner.LAST_RUN);
|
|
165
|
+
const before = asked.recheck ? scanner.loadLastRun(file) : null;
|
|
166
|
+
if (asked.recheck && !before) {
|
|
167
|
+
fail('');
|
|
168
|
+
fail(' There is no earlier run in this folder to compare against.');
|
|
169
|
+
fail('');
|
|
170
|
+
fail(' --recheck proves a fix worked by running the same attacks again and');
|
|
171
|
+
fail(' comparing. With nothing to compare against it would report every');
|
|
172
|
+
fail(' problem as new, which would look like an answer and would not be one.');
|
|
173
|
+
fail('');
|
|
174
|
+
fail(' Run it without --recheck first.');
|
|
175
|
+
fail('');
|
|
176
|
+
process.exit(2);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
line('');
|
|
180
|
+
line(' Connecting ...');
|
|
181
|
+
|
|
182
|
+
let client;
|
|
183
|
+
try {
|
|
184
|
+
client = new Client(howToConnect(connection));
|
|
185
|
+
await client.connect();
|
|
186
|
+
} catch (err) {
|
|
187
|
+
block(trouble.explain(err, connection), fail);
|
|
188
|
+
process.exit(2);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
line(' Connected.');
|
|
192
|
+
line('');
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const result = await scanner.scan(client, target, {
|
|
196
|
+
// Two requests arriving at the same instant cannot be faked down one
|
|
197
|
+
// connection, so the collision attack is handed a way to open its own.
|
|
198
|
+
// This is the one attack the nightly installer will never be able to
|
|
199
|
+
// run, and the report says so when it cannot.
|
|
200
|
+
openSession: async () => {
|
|
201
|
+
const extra = new Client(howToConnect(connection));
|
|
202
|
+
await extra.connect();
|
|
203
|
+
return extra;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (!before) {
|
|
208
|
+
scanner.report(result);
|
|
209
|
+
scanner.saveRun(file, result);
|
|
210
|
+
process.exitCode = result.stopped ? 2 : result.findings.length ? 1 : 0;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const verdict = recheck.compare(before, result);
|
|
215
|
+
recheck.describe(verdict).forEach(line);
|
|
216
|
+
recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
|
|
217
|
+
if (result.findings.length) scanner.report(result);
|
|
218
|
+
scanner.saveRun(file, result);
|
|
219
|
+
process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// Anything that went wrong mid-scan. The copy has already been dropped by
|
|
222
|
+
// the `finally` inside the scan itself, so there is nothing to clean up
|
|
223
|
+
// here - only something to say.
|
|
224
|
+
block(trouble.explain(err, connection), fail);
|
|
225
|
+
process.exitCode = 2;
|
|
226
|
+
} finally {
|
|
227
|
+
await client.end().catch(() => {});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Ctrl-C during the attack leaves a copy behind, because the process dies
|
|
232
|
+
// before the `finally` that drops it runs. The next run sweeps it up, but the
|
|
233
|
+
// person deserves to know it is there rather than finding it themselves.
|
|
234
|
+
process.on('SIGINT', () => {
|
|
235
|
+
line('');
|
|
236
|
+
line('');
|
|
237
|
+
line(' Stopped.');
|
|
238
|
+
line('');
|
|
239
|
+
line(' If I had already made the temporary copy, it is still in your');
|
|
240
|
+
line(' database. Run me again and I will delete it before I start.');
|
|
241
|
+
line('');
|
|
242
|
+
process.exit(130);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
main().catch((err) => {
|
|
246
|
+
block(trouble.explain(err, process.env.KN_DATABASE_URL || ''), fail);
|
|
247
|
+
process.exit(2);
|
|
248
|
+
});
|
package/collision.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// The collision attack.
|
|
2
|
+
//
|
|
3
|
+
// Two requests arriving at the same moment, where only one of them should be
|
|
4
|
+
// allowed to win. Impersonation asks "can the wrong person read this". This
|
|
5
|
+
// asks "can the same thing exist twice", which is the bug nobody finds by
|
|
6
|
+
// clicking through their own app, because one person clicking is never two
|
|
7
|
+
// requests at once.
|
|
8
|
+
//
|
|
9
|
+
// It is run for real: two separate database sessions, both inside open
|
|
10
|
+
// transactions, both inserting the same value while the other is still in
|
|
11
|
+
// flight. Reading the catalogue and noticing a missing unique index would have
|
|
12
|
+
// been easier and would have been a guess - the same shortcut that made the
|
|
13
|
+
// copy look right while the grants were missing. So the value is inserted
|
|
14
|
+
// twice and the rows are counted afterwards.
|
|
15
|
+
//
|
|
16
|
+
// WHAT THIS DELIBERATELY DOES NOT REPORT
|
|
17
|
+
//
|
|
18
|
+
// The lost update - two withdrawals of 100 from a balance of 100 that both
|
|
19
|
+
// succeed. It is real, it is common, and it cannot honestly be reported from
|
|
20
|
+
// here. Every Postgres database on default isolation behaves that way for a
|
|
21
|
+
// read-then-write, so whether an app is actually vulnerable depends on code
|
|
22
|
+
// this tool never sees: an atomic UPDATE or a SELECT ... FOR UPDATE makes it
|
|
23
|
+
// safe, and nothing in the schema says which was used. Reporting it would mean
|
|
24
|
+
// flagging every app that has a number in it. That is the definition of crying
|
|
25
|
+
// wolf, and the re-check at the end of the loop is worth exactly as much as
|
|
26
|
+
// the first report was believable.
|
|
27
|
+
//
|
|
28
|
+
// So only what was actually proven gets said: the same value went in twice,
|
|
29
|
+
// here is the count.
|
|
30
|
+
|
|
31
|
+
const { quote } = require('./schema.js');
|
|
32
|
+
const attack = require('./attack.js');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Columns where two rows holding one value is a security problem rather than
|
|
36
|
+
* an untidy spreadsheet.
|
|
37
|
+
*
|
|
38
|
+
* Anchored on the whole name on purpose. `token` is a credential; `token_id`,
|
|
39
|
+
* `token_expires_at` and `has_token` are not, and matching loosely would put
|
|
40
|
+
* three false alarms on screen for every real one.
|
|
41
|
+
*/
|
|
42
|
+
const MUST_BE_UNIQUE = [
|
|
43
|
+
{
|
|
44
|
+
// One secret matching two rows opens two different doors.
|
|
45
|
+
expectation: 'credential',
|
|
46
|
+
test: /^(token|auth_token|access_token|refresh_token|reset_token|session_token|session_id|api_key|apikey|access_key|secret_key)$/i,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
// A one-time code that can exist twice can be redeemed twice.
|
|
50
|
+
expectation: 'code',
|
|
51
|
+
test: /^(invite_code|invitation_code|coupon_code|promo_code|promotion_code|referral_code|voucher_code|redemption_code|activation_code|license_key|serial_key)$/i,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
// Two accounts answering to one login make "who is this" ambiguous, and
|
|
55
|
+
// password reset has to pick one of them.
|
|
56
|
+
expectation: 'identity',
|
|
57
|
+
test: /^(email|e_mail|username|user_name|handle|login)$/i,
|
|
58
|
+
accountTablesOnly: true,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Tables where a duplicate email really is a login problem.
|
|
64
|
+
*
|
|
65
|
+
* `customers` is deliberately absent. In half the apps it is the account
|
|
66
|
+
* table and in the other half it is a contact list, where two contacts sharing
|
|
67
|
+
* an office email is normal and correct. A finding that is wrong half the time
|
|
68
|
+
* is worse than no finding, so the ambiguous name is left alone.
|
|
69
|
+
*/
|
|
70
|
+
const ACCOUNT_TABLE = /^(users?|profiles?|accounts?|members?|auth_users|app_users|logins?)$/i;
|
|
71
|
+
|
|
72
|
+
/** Everything on a table that already promises "only one of these". */
|
|
73
|
+
function uniquenessTexts(table, indexes) {
|
|
74
|
+
const fromConstraints = (table.constraints || [])
|
|
75
|
+
.filter((c) => c.kind === 'u' || c.kind === 'p')
|
|
76
|
+
.map((c) => c.definition);
|
|
77
|
+
const fromIndexes = (indexes || [])
|
|
78
|
+
.filter((i) => i.table_name === table.name)
|
|
79
|
+
.map((i) => i.definition);
|
|
80
|
+
return fromConstraints.concat(fromIndexes);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Is this column already protected?
|
|
85
|
+
*
|
|
86
|
+
* Deliberately generous. A composite UNIQUE (org_id, email) counts, because
|
|
87
|
+
* the same email in two different organisations is how multi-tenant apps are
|
|
88
|
+
* supposed to work and calling that a bug would be wrong. A partial index
|
|
89
|
+
* counts, and so does an expression index on lower(email). The cost is that a
|
|
90
|
+
* column merely mentioned in some other index's WHERE clause also counts and
|
|
91
|
+
* gets skipped - an attack not run rather than a false alarm raised, which is
|
|
92
|
+
* the right way round.
|
|
93
|
+
*/
|
|
94
|
+
function coveredByUnique(table, columnName, indexes) {
|
|
95
|
+
const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
96
|
+
const word = new RegExp('(^|[^A-Za-z0-9_])' + escaped + '([^A-Za-z0-9_]|$)');
|
|
97
|
+
return uniquenessTexts(table, indexes).some((text) => word.test(String(text)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every column worth racing, with whether it is already protected.
|
|
102
|
+
*
|
|
103
|
+
* Protected columns are raced too, and that is deliberate. The first version
|
|
104
|
+
* skipped them, which meant that the moment somebody added the unique
|
|
105
|
+
* constraint the attack stopped running - so the re-check could no longer see
|
|
106
|
+
* it happen, and reported the fix it had asked for as "could not confirm".
|
|
107
|
+
* A fix has to be provable, and the only proof is running the same attack
|
|
108
|
+
* again and watching it be refused.
|
|
109
|
+
*
|
|
110
|
+
* `covered` decides what is reported, never what is attempted. It has to stay,
|
|
111
|
+
* because UNIQUE (org_id, email) genuinely allows the same email twice and
|
|
112
|
+
* calling that a bug would be wrong.
|
|
113
|
+
*/
|
|
114
|
+
function candidates(tables, indexes) {
|
|
115
|
+
const found = [];
|
|
116
|
+
for (const table of tables || []) {
|
|
117
|
+
for (const column of table.columns || []) {
|
|
118
|
+
const rule = MUST_BE_UNIQUE.find((r) => r.test.test(column.name));
|
|
119
|
+
if (!rule) continue;
|
|
120
|
+
if (rule.accountTablesOnly && !ACCOUNT_TABLE.test(table.name)) continue;
|
|
121
|
+
found.push({
|
|
122
|
+
table: table.name,
|
|
123
|
+
column: column.name,
|
|
124
|
+
expectation: rule.expectation,
|
|
125
|
+
type: column.type,
|
|
126
|
+
covered: coveredByUnique(table, column.name, indexes),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return found;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Keeps the colliding value inside a declared width like varchar(12). */
|
|
134
|
+
function fitToType(text, type) {
|
|
135
|
+
const match = /^[a-z ]*\((\d+)\)$/.exec(String(type || '').trim());
|
|
136
|
+
if (!match) return text;
|
|
137
|
+
const limit = Number(match[1]);
|
|
138
|
+
return text.length > limit ? text.slice(0, limit) : text;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The one value both sessions will fight over. */
|
|
142
|
+
function collidingValue(target) {
|
|
143
|
+
const type = String(target.type || '').toLowerCase();
|
|
144
|
+
if (type === 'uuid') return '33333333-3333-4333-8333-333333333333';
|
|
145
|
+
if (/^(integer|bigint|smallint|numeric|decimal|real|double)/.test(type)) return 424242;
|
|
146
|
+
return fitToType('kryptheon-collision', target.type);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Two inserts of the same value, genuinely at the same time.
|
|
151
|
+
*
|
|
152
|
+
* The sequencing is the delicate part, and it was measured rather than assumed
|
|
153
|
+
* (probe-collision.js). When a unique index IS present the second insert
|
|
154
|
+
* blocks until the first transaction ends, so waiting on both before
|
|
155
|
+
* committing either simply hangs for ever. So: run the first, commit it, and
|
|
156
|
+
* only then wait on the second - which has been in flight, inside its own open
|
|
157
|
+
* transaction, the whole time.
|
|
158
|
+
*/
|
|
159
|
+
async function race(one, two, run) {
|
|
160
|
+
await one.query('BEGIN');
|
|
161
|
+
await two.query('BEGIN');
|
|
162
|
+
// A pathological lock must not hang the night's run.
|
|
163
|
+
await one.query("SET LOCAL statement_timeout = '15s'");
|
|
164
|
+
await two.query("SET LOCAL statement_timeout = '15s'");
|
|
165
|
+
|
|
166
|
+
const first = await run(one).then(
|
|
167
|
+
() => ({ ok: true }),
|
|
168
|
+
(err) => ({ ok: false, why: err.message }),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Fired and deliberately not awaited: it has to be in flight while the first
|
|
172
|
+
// transaction is still open, or this is not a race at all.
|
|
173
|
+
const pending = run(two).then(
|
|
174
|
+
() => ({ ok: true }),
|
|
175
|
+
(err) => ({ ok: false, why: err.message }),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await one.query(first.ok ? 'COMMIT' : 'ROLLBACK');
|
|
179
|
+
const second = await pending;
|
|
180
|
+
await two.query(second.ok ? 'COMMIT' : 'ROLLBACK');
|
|
181
|
+
|
|
182
|
+
return { first: first, second: second };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Race every candidate, and say what got through.
|
|
187
|
+
*
|
|
188
|
+
* `notTried` is the half that keeps this honest. If the first insert fails -
|
|
189
|
+
* a constraint the seeding could not satisfy, a column that will not take the
|
|
190
|
+
* value - then nothing was learned about that column, and saying nothing at
|
|
191
|
+
* all would let it read as safe. A table nobody could test is not a table that
|
|
192
|
+
* held.
|
|
193
|
+
*/
|
|
194
|
+
async function collide(client, one, two, schema, tables, indexes) {
|
|
195
|
+
const findings = [];
|
|
196
|
+
const notTried = [];
|
|
197
|
+
const raced = [];
|
|
198
|
+
const byName = new Map((tables || []).map((t) => [t.name, t]));
|
|
199
|
+
|
|
200
|
+
for (const target of candidates(tables, indexes)) {
|
|
201
|
+
const table = byName.get(target.table);
|
|
202
|
+
const value = collidingValue(target);
|
|
203
|
+
const override = {};
|
|
204
|
+
override[target.column] = value;
|
|
205
|
+
|
|
206
|
+
let rows;
|
|
207
|
+
try {
|
|
208
|
+
// Two people the seeder never used, or on a table keyed by the person
|
|
209
|
+
// these rows clash with the seeded ones and the clash gets read as the
|
|
210
|
+
// app refusing a duplicate it never even saw.
|
|
211
|
+
// Numeric tags, so every other generated value differs between the two
|
|
212
|
+
// rows. They have to collide on the column under test and on nothing
|
|
213
|
+
// else, or a refusal elsewhere would be read as the app defending itself.
|
|
214
|
+
rows = [
|
|
215
|
+
await attack.rowFor(client, schema, table, attack.USER_C, 101, override),
|
|
216
|
+
await attack.rowFor(client, schema, table, attack.USER_D, 102, override),
|
|
217
|
+
];
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (!target.covered) notTried.push({ table: target.table, column: target.column, why: err.message });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let outcome;
|
|
224
|
+
try {
|
|
225
|
+
let nth = 0;
|
|
226
|
+
outcome = await race(one, two, (session) => {
|
|
227
|
+
const row = rows[nth];
|
|
228
|
+
nth += 1;
|
|
229
|
+
return attack.insertRow(session, schema, target.table, row);
|
|
230
|
+
});
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (!target.covered) notTried.push({ table: target.table, column: target.column, why: err.message });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!outcome.first.ok) {
|
|
237
|
+
// Not a pass. The attack never got off the ground - unless the column
|
|
238
|
+
// is already protected, where the constraint itself is the answer and
|
|
239
|
+
// the race was only ever there to demonstrate it.
|
|
240
|
+
if (!target.covered) notTried.push({
|
|
241
|
+
table: target.table,
|
|
242
|
+
column: target.column,
|
|
243
|
+
why: 'I could not get even one test row in: ' + outcome.first.why,
|
|
244
|
+
});
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Counted rather than inferred from the two answers, because that is the
|
|
249
|
+
// thing a person can be told without hedging: there are now two.
|
|
250
|
+
let copies = 0;
|
|
251
|
+
try {
|
|
252
|
+
const { rows: counted } = await client.query(
|
|
253
|
+
'SELECT count(*)::int AS n FROM ' + quote(schema) + '.' + quote(target.table) +
|
|
254
|
+
' WHERE ' + quote(target.column) + ' = $1',
|
|
255
|
+
[value],
|
|
256
|
+
);
|
|
257
|
+
copies = counted[0].n;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (!target.covered) notTried.push({ table: target.table, column: target.column, why: 'could not count the rows: ' + err.message });
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The race happened and its result is known. Recorded whichever way it
|
|
264
|
+
// went: a refusal is an answer, and the re-check has to be able to tell
|
|
265
|
+
// "attacked and held" from "never attacked".
|
|
266
|
+
raced.push({ table: target.table, column: target.column });
|
|
267
|
+
|
|
268
|
+
// A duplicate that landed on a column carrying UNIQUE (org_id, email) is
|
|
269
|
+
// the multi-tenant design working, not a hole.
|
|
270
|
+
if (copies > 1 && !target.covered) {
|
|
271
|
+
findings.push({
|
|
272
|
+
kind: 'duplicated',
|
|
273
|
+
table: target.table,
|
|
274
|
+
column: target.column,
|
|
275
|
+
expectation: target.expectation,
|
|
276
|
+
copies: copies,
|
|
277
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Put the copy back as it was found, so anything that runs after this
|
|
282
|
+
// reads the same database the earlier attacks did.
|
|
283
|
+
try {
|
|
284
|
+
await client.query(
|
|
285
|
+
'DELETE FROM ' + quote(schema) + '.' + quote(target.table) + ' WHERE ' + quote(target.column) + ' = $1',
|
|
286
|
+
[value],
|
|
287
|
+
);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (!target.covered) notTried.push({
|
|
290
|
+
table: target.table,
|
|
291
|
+
column: target.column,
|
|
292
|
+
why: 'the test rows could not be cleaned up: ' + err.message,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { findings: findings, notTried: notTried, raced: raced };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = {
|
|
301
|
+
MUST_BE_UNIQUE: MUST_BE_UNIQUE,
|
|
302
|
+
ACCOUNT_TABLE: ACCOUNT_TABLE,
|
|
303
|
+
uniquenessTexts: uniquenessTexts,
|
|
304
|
+
coveredByUnique: coveredByUnique,
|
|
305
|
+
candidates: candidates,
|
|
306
|
+
collidingValue: collidingValue,
|
|
307
|
+
race: race,
|
|
308
|
+
collide: collide,
|
|
309
|
+
};
|
package/connect.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// How to open a connection, which is not the same as the string a person
|
|
2
|
+
// pasted.
|
|
3
|
+
//
|
|
4
|
+
// In its own file because the decision is worth checking directly, and the
|
|
5
|
+
// command it is used from runs the moment it is required.
|
|
6
|
+
|
|
7
|
+
// Long enough for a Supabase project that has gone to sleep to wake up - free
|
|
8
|
+
// projects pause after a week and take the better part of half a minute to
|
|
9
|
+
// come back - and short enough that a blocked port does not look like a hang.
|
|
10
|
+
// Without this the connection simply never returns and the person is left
|
|
11
|
+
// watching a cursor with nothing to read.
|
|
12
|
+
const CONNECT_TIMEOUT = 30000;
|
|
13
|
+
|
|
14
|
+
// The two SSL modes the hosting companies put in the string themselves.
|
|
15
|
+
// Nobody chose these; they came with the copy button.
|
|
16
|
+
//
|
|
17
|
+
// Measured: `pg` 8.23 reads both of them as full certificate verification,
|
|
18
|
+
// and prints a nine-line upgrade notice to the screen while doing it. Neon
|
|
19
|
+
// hands out a string ending `?sslmode=require`, so a person following the
|
|
20
|
+
// instructions gets that notice in the middle of their security report - and
|
|
21
|
+
// on Supabase's own certificate authority, verify-full fails outright.
|
|
22
|
+
//
|
|
23
|
+
// So these two are dropped and replaced with what `sslmode=require` means
|
|
24
|
+
// everywhere else: encrypted, certificate not verified. Everything else -
|
|
25
|
+
// verify-full, verify-ca, no-verify, disable - is somebody who went and typed
|
|
26
|
+
// it, and is left exactly as they wrote it.
|
|
27
|
+
const HANDED_OUT = /^(require|prefer)$/i;
|
|
28
|
+
|
|
29
|
+
/** A database on this machine is not crossing a network. */
|
|
30
|
+
function isLocal(host) {
|
|
31
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function howToConnect(connectionString) {
|
|
35
|
+
let url;
|
|
36
|
+
try {
|
|
37
|
+
url = new URL(connectionString);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// Unparseable strings never get this far - readConnectionString stops
|
|
40
|
+
// them - but connecting and failing is better than throwing from here.
|
|
41
|
+
return { connectionString: connectionString, connectionTimeoutMillis: CONNECT_TIMEOUT };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mode = url.searchParams.get('sslmode');
|
|
45
|
+
if (mode && !HANDED_OUT.test(mode)) {
|
|
46
|
+
return { connectionString: connectionString, connectionTimeoutMillis: CONNECT_TIMEOUT };
|
|
47
|
+
}
|
|
48
|
+
if (mode) url.searchParams.delete('sslmode');
|
|
49
|
+
|
|
50
|
+
const config = { connectionString: url.toString(), connectionTimeoutMillis: CONNECT_TIMEOUT };
|
|
51
|
+
if (isLocal(url.hostname)) return config;
|
|
52
|
+
|
|
53
|
+
config.ssl = { rejectUnauthorized: false };
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
howToConnect: howToConnect,
|
|
59
|
+
CONNECT_TIMEOUT: CONNECT_TIMEOUT,
|
|
60
|
+
};
|