kryptheon-night 0.1.38 → 0.1.40
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/attack.js +1028 -1025
- package/bin/kryptheon-night.js +736 -725
- package/engine.sql +4657 -4366
- package/finding.js +1715 -1571
- package/intro.js +311 -303
- package/package.json +98 -98
- package/scan.js +1330 -1311
- package/schema.js +1588 -1531
- package/tamper.js +949 -726
package/attack.js
CHANGED
|
@@ -1,1025 +1,1028 @@
|
|
|
1
|
-
// The impersonation attack.
|
|
2
|
-
//
|
|
3
|
-
// Sign in as one person, ask for another person's data, and see what comes
|
|
4
|
-
// back. This is the attack a recorded browser flow can never find, because the
|
|
5
|
-
// frontend never even tries to ask for somebody else's rows - it only ever
|
|
6
|
-
// requests what the signed-in person is supposed to have.
|
|
7
|
-
//
|
|
8
|
-
// It runs the way a real request runs: become the role PostgREST becomes, put
|
|
9
|
-
// the caller's identity where PostgREST puts it, then read. Verified against a
|
|
10
|
-
// real database in probe-rls.js before any of this was written.
|
|
11
|
-
//
|
|
12
|
-
// Two fake people are seeded and nothing else is ever inserted, so a finding is
|
|
13
|
-
// always about rows this tool created. No real customer is involved at any
|
|
14
|
-
// point, which is the promise the whole product is sold on.
|
|
15
|
-
|
|
16
|
-
const { quote } = require('./schema.js');
|
|
17
|
-
|
|
18
|
-
const USER_A = '11111111-1111-4111-8111-111111111111';
|
|
19
|
-
const USER_B = '22222222-2222-4222-8222-222222222222';
|
|
20
|
-
|
|
21
|
-
// Two more who own nothing at all. The seeded pair already hold a row each,
|
|
22
|
-
// so an attack that inserts under their name collides with the row the seeder
|
|
23
|
-
// put there - on a table keyed by the person, that is a primary key clash, and
|
|
24
|
-
// it was being read as the app refusing the attack. These are for any attack
|
|
25
|
-
// that has to add a row of its own.
|
|
26
|
-
const USER_C = '55555555-5555-4555-8555-555555555555';
|
|
27
|
-
const USER_D = '66666666-6666-4666-8666-666666666666';
|
|
28
|
-
|
|
29
|
-
// The names an owner column is actually given, in the order they are worth
|
|
30
|
-
// believing. `id` is last and only counts on a table that looks like a profile,
|
|
31
|
-
// where the row id IS the person.
|
|
32
|
-
const OWNER_NAMES = ['user_id', 'owner_id', 'owner', 'profile_id', 'account_id', 'created_by', 'author_id'];
|
|
33
|
-
|
|
34
|
-
// A key to Supabase's auth.users as the app wrote it. In the copy it points
|
|
35
|
-
// at a stand-in named kn_ext__auth__users, which the table-of-people rule
|
|
36
|
-
// below already recognises; this one is for a table itself called users.
|
|
37
|
-
const TO_AUTH_USERS = /REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(/i;
|
|
38
|
-
// A table that looks like it holds people, keyed by the person.
|
|
39
|
-
const PERSON_TABLE = /(profile|user|account|member)/i;
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The single-column keys from a uuid column, with what they point at. Read
|
|
43
|
-
* from the constraint wording itself, because a key to auth.users and a key
|
|
44
|
-
* to public.users are different things and the schema is part of the words.
|
|
45
|
-
*/
|
|
46
|
-
function personKeys(table, uuids) {
|
|
47
|
-
const byName = new Set(uuids.map((c) => c.name));
|
|
48
|
-
const keys = [];
|
|
49
|
-
for (const constraint of table.constraints || []) {
|
|
50
|
-
if (constraint.kind !== 'f') continue;
|
|
51
|
-
const definition = String(constraint.definition || '');
|
|
52
|
-
const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(definition);
|
|
53
|
-
if (!match) continue;
|
|
54
|
-
const columns = match[1].split(',').map((t) => t.trim().split('"').join(''));
|
|
55
|
-
const refColumns = match[3].split(',').map((t) => t.trim().split('"').join(''));
|
|
56
|
-
if (columns.length !== 1 || !byName.has(columns[0])) continue;
|
|
57
|
-
keys.push({
|
|
58
|
-
column: columns[0],
|
|
59
|
-
toAuth: TO_AUTH_USERS.test(definition),
|
|
60
|
-
refTable: match[2].split('.').pop().trim().split('"').join(''),
|
|
61
|
-
refColumn: refColumns[0],
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
return keys;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** The column that says who a row belongs to, or null if nothing does. */
|
|
68
|
-
function ownerColumn(table) {
|
|
69
|
-
const uuids = table.columns.filter((c) => c.type === 'uuid');
|
|
70
|
-
for (const name of OWNER_NAMES) {
|
|
71
|
-
const found = uuids.find((c) => c.name === name);
|
|
72
|
-
if (found) return found.name;
|
|
73
|
-
}
|
|
74
|
-
// Not named like an owner, but pointing at a person. Found on a blind test:
|
|
75
|
-
// appointments.provider_id -> profiles.id -> auth.users. Judged by name
|
|
76
|
-
// alone it had no owner, so "one provider reads another's patients" - a
|
|
77
|
-
// rule written USING (true) - was never tried, and the worst hole in the
|
|
78
|
-
// app went unreported. `id` first: a table keyed by the person is theirs.
|
|
79
|
-
// Two people on one row - sender_id and recipient_id - and neither is
|
|
80
|
-
// named: whose row it is cannot be told, and guessing would report one
|
|
81
|
-
// person reading a message sent to them as a break. So only one, or none.
|
|
82
|
-
const people = personKeys(table, uuids).filter((k) => k.toAuth ||
|
|
83
|
-
(k.refColumn === 'id' && k.refTable !== table.name && PERSON_TABLE.test(k.refTable)));
|
|
84
|
-
const columns = Array.from(new Set(people.map((k) => k.column)));
|
|
85
|
-
if (columns.includes('id')) return 'id';
|
|
86
|
-
if (columns.length === 1) return columns[0];
|
|
87
|
-
// A profiles table keyed by the person themselves.
|
|
88
|
-
if (/profile|user|account|member/i.test(table.name)) {
|
|
89
|
-
const id = uuids.find((c) => c.name === 'id');
|
|
90
|
-
if (id) return id.name;
|
|
91
|
-
}
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Keeps a generated value inside a declared width like varchar(20). */
|
|
96
|
-
function fitTo(text, type) {
|
|
97
|
-
const match = /^[a-z ]*\((\d+)\)$/.exec(String(type).trim());
|
|
98
|
-
if (!match) return text;
|
|
99
|
-
const limit = Number(match[1]);
|
|
100
|
-
return text.length > limit ? text.slice(0, limit) : text;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Something valid to put in a column, so a row can exist at all.
|
|
105
|
-
*
|
|
106
|
-
* `distinct` is what keeps two seeded rows from being identical. Without it
|
|
107
|
-
* every row carried the same text, so a table with a unique email column
|
|
108
|
-
* refused the second insert and the whole table was reported as "not checked"
|
|
109
|
-
* - a well-built app treated as an unknown one. The tag goes at the front
|
|
110
|
-
* because a narrow varchar truncates the end, and two rows truncated to the
|
|
111
|
-
* same string is that bug all over again.
|
|
112
|
-
*/
|
|
113
|
-
function valueFor(column, owner, distinct, attempt) {
|
|
114
|
-
// A domain is somebody's own type with a rule bolted on. The rule cannot be
|
|
115
|
-
// guessed at from here, but the type underneath it can be filled in.
|
|
116
|
-
const type = String(column.base_type || column.type).toLowerCase();
|
|
117
|
-
const tag = distinct === undefined || distinct === null ? '' : String(distinct);
|
|
118
|
-
const step = Number(tag) || 0;
|
|
119
|
-
|
|
120
|
-
// An enum accepts one of a fixed list and nothing else. Every generated
|
|
121
|
-
// string was rejected, and the table went down as "not checked".
|
|
122
|
-
//
|
|
123
|
-
// Insisting on a real array rather than accepting anything with a length:
|
|
124
|
-
// the labels arrived once as the string "{new,paid,shipped}", whose first
|
|
125
|
-
// element is the character "{", and that is a value the database rejects
|
|
126
|
-
// just as firmly while looking like the code is working.
|
|
127
|
-
if (Array.isArray(column.enum_labels) && column.enum_labels.length) return column.enum_labels[0];
|
|
128
|
-
// Anything at all is allowed in an empty array, whatever the element type.
|
|
129
|
-
if (column.is_array || /\[\]$/.test(type)) return '{}';
|
|
130
|
-
|
|
131
|
-
if (type === 'uuid') return owner;
|
|
132
|
-
if (/^(integer|bigint|smallint|numeric|decimal|real|double|money)/.test(type)) return 1 + step;
|
|
133
|
-
if (/^bool/.test(type)) return true;
|
|
134
|
-
if (/^(timestamp|date)/.test(type)) return new Date().toISOString();
|
|
135
|
-
if (/^time/.test(type)) return '12:00:00';
|
|
136
|
-
if (/^interval/.test(type)) return '1 day';
|
|
137
|
-
if (/^json/.test(type)) return '{}';
|
|
138
|
-
if (/^(inet|cidr)/.test(type)) return '192.0.2.' + (1 + step);
|
|
139
|
-
if (/^macaddr8/.test(type)) return '08:00:2b:01:02:03:04:0' + (5 + step);
|
|
140
|
-
if (/^macaddr/.test(type)) return '08:00:2b:01:02:0' + (3 + step);
|
|
141
|
-
if (/^(tsvector|tsquery)/.test(type)) return 'kryptheon';
|
|
142
|
-
if (/^bytea/.test(type)) return Buffer.from('kryptheon');
|
|
143
|
-
if (/^xml/.test(type)) return '<kryptheon/>';
|
|
144
|
-
if (/^bit/.test(type)) return '0';
|
|
145
|
-
if (/^(point|line|lseg|box|path|polygon|circle)/.test(type)) return '(0,0)';
|
|
146
|
-
|
|
147
|
-
// Text is where the rules live that cannot be read: a domain that insists on
|
|
148
|
-
// an @, a CHECK on a length, a regex for a product code. Rather than pretend
|
|
149
|
-
// to understand them, the seeder works down a short ladder of shapes and
|
|
150
|
-
// keeps whichever one the database accepts.
|
|
151
|
-
const shapes = [
|
|
152
|
-
tag ? tag + ' kryptheon test' : 'kryptheon test',
|
|
153
|
-
'kryptheon' + (tag || '') + '@example.com',
|
|
154
|
-
'KN' + (tag || '1'),
|
|
155
|
-
String(step + 1),
|
|
156
|
-
'https://example.com/kryptheon',
|
|
157
|
-
];
|
|
158
|
-
return fitTo(shapes[Math.min(Math.max(attempt || 0, 0), shapes.length - 1)], type);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Values a CHECK constraint will actually accept for one column.
|
|
163
|
-
*
|
|
164
|
-
* `status text CHECK (status IN ('open','closed'))` is one of the most common
|
|
165
|
-
* things anyone writes, and Postgres stores it as
|
|
166
|
-
* `CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text])))`. Reading the
|
|
167
|
-
* literals back out turns a table that could never be seeded into one that can.
|
|
168
|
-
*
|
|
169
|
-
* Only this one shape is understood, deliberately. A CHECK can contain
|
|
170
|
-
* anything, and pretending to satisfy an arbitrary one would mean inventing
|
|
171
|
-
* rows that the app itself would reject.
|
|
172
|
-
*/
|
|
173
|
-
function allowedByCheck(table, columnName) {
|
|
174
|
-
const found = [];
|
|
175
|
-
const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
176
|
-
// Both spellings of the same rule. On a text column Postgres writes
|
|
177
|
-
// state = ANY (ARRAY['open'::text, ...])
|
|
178
|
-
// and on a varchar column it writes
|
|
179
|
-
// (state)::text = ANY ((ARRAY['open'::character varying, ...])::text[])
|
|
180
|
-
// which the first pattern could not cross: it stopped at the bracket that
|
|
181
|
-
// closes the cast. A varchar column with an IN list is an ordinary thing to
|
|
182
|
-
// write, and every table with one was seeded with an invented value, refused
|
|
183
|
-
// by its own constraint, and reported as a table that could not be checked.
|
|
184
|
-
//
|
|
185
|
-
// What may sit between the column and the = is spelled out rather than left
|
|
186
|
-
// to a negated class: anything looser reaches across the next AND and hands
|
|
187
|
-
// one column's allowed values to another.
|
|
188
|
-
const anyOf = new RegExp(
|
|
189
|
-
'\\b' + escaped + '\\b\\)?(?:::[a-z ]+)?\\s*=\\s*ANY\\s*\\(+\\s*ARRAY\\[(.*?)\\]',
|
|
190
|
-
'i',
|
|
191
|
-
);
|
|
192
|
-
for (const constraint of table.constraints || []) {
|
|
193
|
-
if (constraint.kind !== 'c') continue;
|
|
194
|
-
const match = anyOf.exec(String(constraint.definition));
|
|
195
|
-
if (!match) continue;
|
|
196
|
-
// Picked out one at a time rather than split on commas, which came apart
|
|
197
|
-
// in the middle of any value that had a comma in it.
|
|
198
|
-
const literals = /'((?:[^']|'')*)'/g;
|
|
199
|
-
let literal;
|
|
200
|
-
while ((literal = literals.exec(match[1])) !== null) {
|
|
201
|
-
found.push(literal[1].split("''").join("'"));
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return found;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* The CHECK constraints on a table that name this column, as the expression
|
|
209
|
-
* inside CHECK (...). A NOT VALID one still applies to every new row.
|
|
210
|
-
*/
|
|
211
|
-
function checksOn(table, columnName) {
|
|
212
|
-
const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
213
|
-
const named = new RegExp('(^|[^A-Za-z0-9_$"])("' + escaped.split('"').join('""') + '"|' + escaped + ')($|[^A-Za-z0-9_$"])');
|
|
214
|
-
const found = [];
|
|
215
|
-
for (const constraint of table.constraints || []) {
|
|
216
|
-
if (constraint.kind !== 'c') continue;
|
|
217
|
-
const definition = String(constraint.definition || '');
|
|
218
|
-
if (!named.test(definition)) continue;
|
|
219
|
-
found.push(definition.replace(/^CHECK\s*/i, '').replace(/\s+NOT VALID\s*$/i, ''));
|
|
220
|
-
}
|
|
221
|
-
return found;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const NUMERIC_KIND = /^(integer|bigint|smallint|numeric|decimal|real|double)/;
|
|
225
|
-
const TEXT_KIND = /^(text|character|varchar|citext)/;
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Values to offer a column whose CHECK could not be read, best first.
|
|
229
|
-
*
|
|
230
|
-
* Found on a real app (a clinic's, 2026-10-11): patient_waitlist and
|
|
231
|
-
* whatsapp_contacts each had a phone number column with a CHECK on its
|
|
232
|
-
* format, compliance_requests a type column with a CHECK no reading here
|
|
233
|
-
* understood - and none of the made-up values fitted, so three tables, and a
|
|
234
|
-
* fourth that pointed at one of them, were never tested. Nothing here reads
|
|
235
|
-
* the rule. Each value is put to the rule itself, in the database
|
|
236
|
-
* (fittingValue), and the first it accepts is used.
|
|
237
|
-
*
|
|
238
|
-
* Mirrored by __KN__.check_candidates in engine.sql, value for value and in
|
|
239
|
-
* the same order: both engines must seed the same rows.
|
|
240
|
-
*/
|
|
241
|
-
function checkCandidates(column, definitions, distinct) {
|
|
242
|
-
const kind = String(column.base_type || column.type).toLowerCase();
|
|
243
|
-
const tag = distinct === undefined || distinct === null ? '' : String(distinct);
|
|
244
|
-
const step = Number(tag) || 0;
|
|
245
|
-
const out = [];
|
|
246
|
-
const add = (value) => { if (out.indexOf(value) === -1) out.push(value); };
|
|
247
|
-
if (NUMERIC_KIND.test(kind)) {
|
|
248
|
-
[1 + step, 18 + step, 100 + step, 1000 + step, 0, 5].forEach((n) => add(String(n)));
|
|
249
|
-
for (const definition of definitions) {
|
|
250
|
-
const numbers = definition.match(/-?\d+(?:\.\d+)?/g) || [];
|
|
251
|
-
numbers.forEach(add);
|
|
252
|
-
}
|
|
253
|
-
return out;
|
|
254
|
-
}
|
|
255
|
-
if (!TEXT_KIND.test(kind)) return out;
|
|
256
|
-
const d = String(step % 10);
|
|
257
|
-
const phones = ['+1415555012' + d, '+919876543' + (210 + step), '9876543' + (210 + step), '415555012' + d,
|
|
258
|
-
'+1 415-555-012' + d, '(415) 555-012' + d, '415-555-012' + d, '+44 7700 90012' + d, '07700 90012' + d];
|
|
259
|
-
const emails = ['kryptheon' + tag + '@example.com'];
|
|
260
|
-
const links = ['https://example.com/kryptheon' + tag];
|
|
261
|
-
// The shapes valueFor offers, in its order, so a rule they already passed
|
|
262
|
-
// still gets the value it always got.
|
|
263
|
-
const words = [tag ? tag + ' kryptheon test' : 'kryptheon test', 'kryptheon' + tag + '@example.com', 'KN' + (tag || '1'),
|
|
264
|
-
String(step + 1), 'https://example.com/kryptheon', 'kryptheon' + tag, 'Kryptheon' + tag, 'kn-' + (tag || '1')];
|
|
265
|
-
const literals = [];
|
|
266
|
-
for (const definition of definitions) {
|
|
267
|
-
const quoted = /'((?:[^']|'')*)'/g;
|
|
268
|
-
let one;
|
|
269
|
-
while ((one = quoted.exec(definition)) !== null) literals.push(one[1].split("''").join("'"));
|
|
270
|
-
}
|
|
271
|
-
const codes = ['US', 'IN', 'USD', 'INR', 'EUR', 'en', 'en-US', '#00000' + d, '2026-01-0' + (1 + (step % 9)), '1234' + d, '56000' + d,
|
|
272
|
-
'kryptheon test value long enough for a rule on a minimum length ' + (tag || '0')];
|
|
273
|
-
const name = String(column.name).toLowerCase();
|
|
274
|
-
const first = /phone|mobile|whatsapp|tel|msisdn/.test(name) ? phones
|
|
275
|
-
: /mail/.test(name) ? emails
|
|
276
|
-
: /url|link|website|site|href/.test(name) ? links : [];
|
|
277
|
-
for (const value of first.concat(words, literals, phones, emails, links, codes)) add(fitTo(value, kind));
|
|
278
|
-
return out;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* The first values a column's CHECK constraints accept, asked of the
|
|
283
|
-
* database itself: each rule is run on the value alone, the column given as
|
|
284
|
-
* the value by a one-row subquery. A rule that also names another column
|
|
285
|
-
* cannot be run alone and is left out; when no rule can be, nothing is
|
|
286
|
-
* offered and the seeder goes on as before. Remembered per table, column and
|
|
287
|
-
* row, so the attacks that insert later ask nothing again.
|
|
288
|
-
*
|
|
289
|
-
* Returns the accepted values, best first - possibly none.
|
|
290
|
-
*/
|
|
291
|
-
const FITTED = new WeakMap();
|
|
292
|
-
async function fittingValues(client, table, column, distinct) {
|
|
293
|
-
const definitions = checksOn(table, column.name);
|
|
294
|
-
if (!definitions.length) return [];
|
|
295
|
-
const candidates = checkCandidates(column, definitions, distinct);
|
|
296
|
-
if (!candidates.length) return [];
|
|
297
|
-
let byTable = FITTED.get(table);
|
|
298
|
-
if (!byTable) FITTED.set(table, (byTable = new Map()));
|
|
299
|
-
const key = column.name + '\u0000' + String(distinct);
|
|
300
|
-
if (byTable.has(key)) return byTable.get(key);
|
|
301
|
-
|
|
302
|
-
const type = String(column.type);
|
|
303
|
-
const alone = (expression, value) =>
|
|
304
|
-
'SELECT (' + expression + ') IS NOT FALSE AS ok FROM (SELECT ' + value + '::' + type + ' AS ' + quote(column.name) + ') AS kn_row';
|
|
305
|
-
const runnable = [];
|
|
306
|
-
for (const expression of definitions) {
|
|
307
|
-
try {
|
|
308
|
-
await client.query(alone(expression, 'NULL'));
|
|
309
|
-
runnable.push(expression);
|
|
310
|
-
} catch (err) {
|
|
311
|
-
// Names another column, or something only the table can supply.
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
let accepted = [];
|
|
315
|
-
if (runnable.length) {
|
|
316
|
-
const all = runnable.map((e) => '(' + e + ') IS NOT FALSE').join(' AND ');
|
|
317
|
-
for (const value of candidates) {
|
|
318
|
-
try {
|
|
319
|
-
const { rows } = await client.query(
|
|
320
|
-
'SELECT ' + all + ' AS ok FROM (SELECT $1::' + type + ' AS ' + quote(column.name) + ') AS kn_row', [value]);
|
|
321
|
-
if (rows[0] && rows[0].ok === true) accepted.push(value);
|
|
322
|
-
// As many as the seeder will ever try; asking further changes nothing.
|
|
323
|
-
if (accepted.length >= SHAPES_TO_TRY) break;
|
|
324
|
-
} catch (err) {
|
|
325
|
-
// A value its own type, or the rule, refuses outright.
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
byTable.set(key, accepted);
|
|
330
|
-
return accepted;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Sets of columns where exactly one may be filled in.
|
|
335
|
-
*
|
|
336
|
-
* Found on a blind test (HelixOps): documents belonged to a work order or to
|
|
337
|
-
* an inspection, never both, written
|
|
338
|
-
* CHECK ((work_order_id IS NOT NULL)::int + (inspection_id IS NOT NULL)::int = 1)
|
|
339
|
-
* Both parents were there, so the seeder pointed the row at both, the CHECK
|
|
340
|
-
* refused every attempt, and the table was never tested at all.
|
|
341
|
-
*
|
|
342
|
-
* Two spellings are read - that sum, and num_nonnulls(a, b, ...) = 1 - and
|
|
343
|
-
* only when the constraint says nothing else. A CHECK with anything more in it
|
|
344
|
-
* is left alone rather than half understood.
|
|
345
|
-
*/
|
|
346
|
-
function exactlyOne(table) {
|
|
347
|
-
const groups = [];
|
|
348
|
-
const name = '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
|
|
349
|
-
const unquote = (text) => (text.charAt(0) === '"' ? text.slice(1, -1).split('""').join('"') : text);
|
|
350
|
-
for (const constraint of table.constraints || []) {
|
|
351
|
-
if (constraint.kind !== 'c') continue;
|
|
352
|
-
const definition = String(constraint.definition || '');
|
|
353
|
-
let columns = [];
|
|
354
|
-
let rest = definition;
|
|
355
|
-
const summed = new RegExp('\\(\\(' + name + ' IS NOT NULL\\)\\)::integer', 'gi');
|
|
356
|
-
let match;
|
|
357
|
-
while ((match = summed.exec(definition)) !== null) columns.push(unquote(match[1]));
|
|
358
|
-
rest = rest.replace(summed, '').replace(/\+/g, '');
|
|
359
|
-
if (!columns.length) {
|
|
360
|
-
const counted = /num_nonnulls\(([^()]*)\)/i.exec(definition);
|
|
361
|
-
if (counted) {
|
|
362
|
-
const listed = new RegExp(name, 'g');
|
|
363
|
-
let one;
|
|
364
|
-
while ((one = listed.exec(counted[1])) !== null) columns.push(unquote(one[1]));
|
|
365
|
-
rest = rest.replace(counted[0], '');
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
// Nothing may be left but the CHECK itself, brackets and "= 1".
|
|
369
|
-
if (columns.length < 2 || rest.replace(/[()\s]/g, '').toUpperCase() !== 'CHECK=1') continue;
|
|
370
|
-
groups.push(columns);
|
|
371
|
-
}
|
|
372
|
-
return groups;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* The foreign keys on a table, read out of Postgres's own wording.
|
|
377
|
-
*
|
|
378
|
-
* Every column of the key, not just the first. A key over (org_id, cart_id)
|
|
379
|
-
* used to be read as though it were only org_id: the second column got an
|
|
380
|
-
* invented value, the pair pointed at no row that existed, and the table was
|
|
381
|
-
* reported as one that could not be checked.
|
|
382
|
-
*/
|
|
383
|
-
function foreignKeys(table) {
|
|
384
|
-
const keys = [];
|
|
385
|
-
const unquote = (text) => text.trim().split('"').join('');
|
|
386
|
-
for (const constraint of table.constraints || []) {
|
|
387
|
-
if (constraint.kind !== 'f') continue;
|
|
388
|
-
const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(constraint.definition);
|
|
389
|
-
if (!match) continue;
|
|
390
|
-
keys.push({
|
|
391
|
-
columns: match[1].split(',').map(unquote),
|
|
392
|
-
refTable: unquote(match[2].split('.').pop()),
|
|
393
|
-
refColumns: match[3].split(',').map(unquote),
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
return keys;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/** The single-column primary key of a table, or null if it has none. */
|
|
400
|
-
function primaryKeyOf(table) {
|
|
401
|
-
for (const constraint of table.constraints || []) {
|
|
402
|
-
if (constraint.kind !== 'p') continue;
|
|
403
|
-
const match = /PRIMARY KEY \(([^)]+)\)/i.exec(constraint.definition);
|
|
404
|
-
if (!match) continue;
|
|
405
|
-
const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
|
|
406
|
-
// A composite key is not something a single `<thing>_id` column points at.
|
|
407
|
-
return columns.length === 1 ? columns[0] : null;
|
|
408
|
-
}
|
|
409
|
-
return null;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* The table a column named `<thing>_id` is pointing at, if there is one.
|
|
414
|
-
*
|
|
415
|
-
* The name has to match a table that is really here, and the types have to
|
|
416
|
-
* agree. Both, because `stripe_id` matches nothing and `org_id integer` does
|
|
417
|
-
* not point at an `orgs.id` that is a uuid.
|
|
418
|
-
*/
|
|
419
|
-
function parentFor(column, tables) {
|
|
420
|
-
const stem = /^(.+)_id$/i.exec(column.name);
|
|
421
|
-
if (!stem) return null;
|
|
422
|
-
const wanted = stem[1].toLowerCase();
|
|
423
|
-
const parent = (tables || []).find((table) => {
|
|
424
|
-
const name = table.name.toLowerCase();
|
|
425
|
-
return name === wanted || name === wanted + 's' || name === wanted + 'es';
|
|
426
|
-
});
|
|
427
|
-
if (!parent) return null;
|
|
428
|
-
|
|
429
|
-
const key = primaryKeyOf(parent);
|
|
430
|
-
if (!key) return null;
|
|
431
|
-
const keyColumn = (parent.columns || []).find((c) => c.name === key);
|
|
432
|
-
if (!keyColumn || keyColumn.type !== column.type) return null;
|
|
433
|
-
|
|
434
|
-
return { table: parent, keyColumn: key, type: keyColumn.type };
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* The keys a table would have if it had been given them: a `<thing>_id`
|
|
439
|
-
* column naming a table that is here, with no foreign key on it.
|
|
440
|
-
*
|
|
441
|
-
* Found on a blind test. A production snapshot had no foreign keys at all, so
|
|
442
|
-
* workspace_members.workspace_id was seeded with an invented value that named
|
|
443
|
-
* no workspace. A view joining members to their workspaces then showed
|
|
444
|
-
* nothing, and "anyone can list every workspace and who is in it" - a view
|
|
445
|
-
* granted to logged-out visitors - read as an empty room.
|
|
446
|
-
*
|
|
447
|
-
* Only ever used to pick a value. Nothing is refused for lack of one: the
|
|
448
|
-
* database never promised the parent would be there.
|
|
449
|
-
*/
|
|
450
|
-
function impliedKeys(table, tables) {
|
|
451
|
-
const tied = new Set();
|
|
452
|
-
for (const key of foreignKeys(table)) key.columns.forEach((name) => tied.add(name));
|
|
453
|
-
const keys = [];
|
|
454
|
-
for (const column of table.columns || []) {
|
|
455
|
-
if (tied.has(column.name)) continue;
|
|
456
|
-
const parent = parentFor(column, tables);
|
|
457
|
-
if (!parent || parent.table.name === table.name) continue;
|
|
458
|
-
keys.push({ columns: [column.name], refTable: parent.table.name, refColumns: [parent.keyColumn] });
|
|
459
|
-
}
|
|
460
|
-
return keys;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Parents before children.
|
|
465
|
-
*
|
|
466
|
-
* Tables come back in alphabetical order, which put `orders` before `profiles`
|
|
467
|
-
* and made every insert fail on the foreign key - on the first real app it was
|
|
468
|
-
* pointed at, because every app has one of these. A cycle is left in whatever
|
|
469
|
-
* order it arrived: it cannot be satisfied anyway, and the table that fails is
|
|
470
|
-
* reported rather than dropped.
|
|
471
|
-
*
|
|
472
|
-
* A key the app never declared counts too. A member row seeded before the
|
|
473
|
-
* workspace it names has nothing real to point at.
|
|
474
|
-
*/
|
|
475
|
-
function dependencyOrder(tables) {
|
|
476
|
-
const byName = new Map(tables.map((t) => [t.name, t]));
|
|
477
|
-
const ordered = [];
|
|
478
|
-
const done = new Set();
|
|
479
|
-
const visiting = new Set();
|
|
480
|
-
|
|
481
|
-
const visit = (table) => {
|
|
482
|
-
if (done.has(table.name) || visiting.has(table.name)) return;
|
|
483
|
-
visiting.add(table.name);
|
|
484
|
-
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
485
|
-
const parent = byName.get(key.refTable);
|
|
486
|
-
if (parent && parent.name !== table.name) visit(parent);
|
|
487
|
-
}
|
|
488
|
-
visiting.delete(table.name);
|
|
489
|
-
done.add(table.name);
|
|
490
|
-
ordered.push(table);
|
|
491
|
-
};
|
|
492
|
-
|
|
493
|
-
for (const table of tables) visit(table);
|
|
494
|
-
return ordered;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* One row that is really in the parent table, so a foreign key is satisfied.
|
|
499
|
-
*
|
|
500
|
-
* The whole row, not one column of it. A composite key has to point at a pair
|
|
501
|
-
* that exists together: borrowing each column from a separate row would build
|
|
502
|
-
* a combination the parent never had.
|
|
503
|
-
*
|
|
504
|
-
* `mine` is the row seeded for the same person, when there is one. Without it
|
|
505
|
-
* both fake people were put in the first workspace - the same team, both as
|
|
506
|
-
* its owner - and every rule written "members of this workspace may read it"
|
|
507
|
-
* let each read the other's rows. Six tables of one blind test were reported
|
|
508
|
-
* as one customer reading another's data, when they were teammates.
|
|
509
|
-
*/
|
|
510
|
-
async function existingRow(client, schema, key, mine) {
|
|
511
|
-
if (mine) return key.refColumns.map((name) => mine[name]);
|
|
512
|
-
try {
|
|
513
|
-
const columns = key.refColumns.map((name, i) => quote(name) + ' AS v' + i).join(', ');
|
|
514
|
-
const { rows } = await client.query(
|
|
515
|
-
'SELECT ' + columns + ' FROM ' + quote(schema) + '.' + quote(key.refTable) + ' LIMIT 1',
|
|
516
|
-
);
|
|
517
|
-
if (!rows.length) return null;
|
|
518
|
-
return key.refColumns.map((name, i) => rows[0]['v' + i]);
|
|
519
|
-
} catch (err) {
|
|
520
|
-
return null;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* A row that will actually go in.
|
|
526
|
-
*
|
|
527
|
-
* Owner column set to the person, foreign keys pointing at rows that really
|
|
528
|
-
* exist, every NOT NULL column filled, and anything with a default left to
|
|
529
|
-
* supply its own value.
|
|
530
|
-
*
|
|
531
|
-
* `overrides` is what the Collision attack needs: it forces one column to a
|
|
532
|
-
* chosen value while everything else stays distinct, so when two inserts race
|
|
533
|
-
* they collide on that column and on nothing else. Without it a second unique
|
|
534
|
-
* column elsewhere in the table would refuse the insert, and the refusal would
|
|
535
|
-
* be read as the app defending itself.
|
|
536
|
-
*
|
|
537
|
-
* `seeding` is only passed by the seeder: `rows`, the rows already seeded for
|
|
538
|
-
* each person, table by table, so a row points at its own person's parent;
|
|
539
|
-
* and `implied`, the keys the app never declared, so a `workspace_id` with no
|
|
540
|
-
* foreign key still names a workspace that is there.
|
|
541
|
-
*/
|
|
542
|
-
async function rowFor(client, schema, table, person, distinct, overrides, attempt, seeding) {
|
|
543
|
-
const forced = overrides || {};
|
|
544
|
-
const owner = ownerColumn(table);
|
|
545
|
-
const columns = [];
|
|
546
|
-
const values = [];
|
|
547
|
-
const sown = (seeding && seeding.rows) || new Map();
|
|
548
|
-
const implied = (seeding && seeding.implied) || [];
|
|
549
|
-
|
|
550
|
-
// Every column that takes part in a foreign key, and the value it has to
|
|
551
|
-
// hold. Resolved one key at a time so that all of a composite key's columns
|
|
552
|
-
// come from the same parent row. Declared keys first, so a key the app wrote
|
|
553
|
-
// always wins over one guessed from a name.
|
|
554
|
-
const borrowed = new Map();
|
|
555
|
-
for (const key of foreignKeys(table).concat(implied)) {
|
|
556
|
-
const theirs = sown.get(key.refTable);
|
|
557
|
-
const row = await existingRow(client, schema, key, theirs && theirs.get(person));
|
|
558
|
-
if (!row) continue;
|
|
559
|
-
key.columns.forEach((name, i) => {
|
|
560
|
-
if (!borrowed.has(name)) borrowed.set(name, row[i]);
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
const partOfKey = new Set();
|
|
564
|
-
for (const key of foreignKeys(table)) key.columns.forEach((name) => partOfKey.add(name));
|
|
565
|
-
|
|
566
|
-
// Where exactly one of a set may be filled in: the one already chosen, or
|
|
567
|
-
// else the first that can be - a key needs a parent row to point at. The
|
|
568
|
-
// rest are written as nothing, whatever their default.
|
|
569
|
-
const chosen = (name) => Object.prototype.hasOwnProperty.call(forced, name);
|
|
570
|
-
const onlyOne = new Map();
|
|
571
|
-
for (const group of exactlyOne(table)) {
|
|
572
|
-
const kept = group.find(chosen) || group.find((name) => !partOfKey.has(name) || borrowed.has(name));
|
|
573
|
-
for (const name of group) onlyOne.set(name, name === kept);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
for (const column of table.columns) {
|
|
577
|
-
if (Object.prototype.hasOwnProperty.call(forced, column.name)) {
|
|
578
|
-
columns.push(column.name);
|
|
579
|
-
values.push(forced[column.name]);
|
|
580
|
-
continue;
|
|
581
|
-
}
|
|
582
|
-
if (onlyOne.get(column.name) === false) {
|
|
583
|
-
columns.push(column.name);
|
|
584
|
-
values.push(null);
|
|
585
|
-
continue;
|
|
586
|
-
}
|
|
587
|
-
const mustFill = onlyOne.get(column.name) === true;
|
|
588
|
-
if (column.name === owner) {
|
|
589
|
-
columns.push(column.name);
|
|
590
|
-
values.push(person);
|
|
591
|
-
continue;
|
|
592
|
-
}
|
|
593
|
-
// A column pointing at another table has to hold something that is
|
|
594
|
-
// actually there, whatever its type would otherwise suggest.
|
|
595
|
-
if (partOfKey.has(column.name)) {
|
|
596
|
-
if (borrowed.has(column.name)) {
|
|
597
|
-
columns.push(column.name);
|
|
598
|
-
values.push(borrowed.get(column.name));
|
|
599
|
-
continue;
|
|
600
|
-
}
|
|
601
|
-
if (column.not_null) throw new Error('nothing to point ' + column.name + ' at');
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
// A generated column computes itself and refuses to be written to at all.
|
|
605
|
-
//
|
|
606
|
-
// Two halves, and only one of them can be observed. An identity column
|
|
607
|
-
// carries no default_expr, so without the identity half the seeder writes
|
|
608
|
-
// to it and Postgres refuses the row - both engines are caught doing it.
|
|
609
|
-
// A stored generated column keeps its expression IN default_expr, so the
|
|
610
|
-
// line below skips it whether or not this one does: removing the generated
|
|
611
|
-
// half changes nothing any fixture could see. It stays because that is a
|
|
612
|
-
// fact about how readColumns fills the shape, not about Postgres, and the
|
|
613
|
-
// day it changes this is the only thing standing in the way.
|
|
614
|
-
if (column.generated || column.identity) continue;
|
|
615
|
-
// A key nobody declared, pointing at a parent that is there. Filled even
|
|
616
|
-
// where a default or a null would do, because a row that names no real
|
|
617
|
-
// parent is invisible to every rule and view that joins through it.
|
|
618
|
-
if (borrowed.has(column.name)) {
|
|
619
|
-
columns.push(column.name);
|
|
620
|
-
values.push(borrowed.get(column.name));
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
// Anything with a default can supply its own value. A column that may be
|
|
624
|
-
// empty is left empty - unless it is the one of a set that has to be
|
|
625
|
-
// filled in.
|
|
626
|
-
if (column.default_expr) continue;
|
|
627
|
-
if (!column.not_null && !mustFill) continue;
|
|
628
|
-
columns.push(column.name);
|
|
629
|
-
// A CHECK that lists what it will accept beats anything invented here;
|
|
630
|
-
// after that, a value one it could not read was asked about and accepts.
|
|
631
|
-
const allowed = allowedByCheck(table, column.name);
|
|
632
|
-
const fitting = allowed.length ? [] : await fittingValues(client, table, column, distinct);
|
|
633
|
-
const pick = (list) => list[Math.min(Math.max(attempt || 0, 0), list.length - 1)];
|
|
634
|
-
values.push(
|
|
635
|
-
allowed.length ? pick(allowed)
|
|
636
|
-
: fitting.length ? pick(fitting)
|
|
637
|
-
: valueFor(column, person, distinct, attempt),
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
return { columns: columns, values: values };
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Puts a built row in. Separate so the same row can be raced against itself.
|
|
646
|
-
* `back` asks for the row as it landed, defaults and all.
|
|
647
|
-
*/
|
|
648
|
-
function insertRow(client, schema, table, row, back) {
|
|
649
|
-
const where = 'INSERT INTO ' + quote(schema) + '.' + quote(table);
|
|
650
|
-
const tail = back ? ' RETURNING *' : '';
|
|
651
|
-
// A table of nothing but an id and its defaults leaves no columns to name,
|
|
652
|
-
// and "INSERT INTO t () VALUES ()" is a syntax error. Postgres has a spelling
|
|
653
|
-
// for exactly this, and without it every settings and flags table in the
|
|
654
|
-
// world came back as one that could not be checked.
|
|
655
|
-
if (!row.columns.length) return client.query(where + ' DEFAULT VALUES' + tail);
|
|
656
|
-
const placeholders = row.values.map((_, i) => '$' + (i + 1));
|
|
657
|
-
return client.query(
|
|
658
|
-
where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')' + tail,
|
|
659
|
-
row.values,
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* The column that says which organization's a row is, on a table with no
|
|
665
|
-
* person's name on it - or null. Its first key to a table that others point
|
|
666
|
-
* at (an organization, a workspace, a team), where the table itself is not one
|
|
667
|
-
* of those: seeding gives each person their own organization, and a table like
|
|
668
|
-
* this one row, in the first person's. Found on a blind test (HarborLine):
|
|
669
|
-
* integration_secrets(organization_id, access_token) was readable by every
|
|
670
|
-
* signed-in user, and with no person column it was never tried at all.
|
|
671
|
-
*/
|
|
672
|
-
function tenantKey(table, tables, parents) {
|
|
673
|
-
// A view is not seeded: it shows whatever its tables hold, the second
|
|
674
|
-
// person's own organization included, so a row of it read by them proves
|
|
675
|
-
// nothing. Found on the same test: workspace_health, filtered to the
|
|
676
|
-
// organizations the reader belongs to, came back as a leak.
|
|
677
|
-
if (table.isView || parents.has(table.name)) return null;
|
|
678
|
-
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
679
|
-
if (key.refTable !== table.name && parents.has(key.refTable) && key.columns.length === 1) return key.columns[0];
|
|
680
|
-
}
|
|
681
|
-
return null;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/** Every table some other table points at, by a key it declared or one it implies. */
|
|
685
|
-
function parentTables(tables) {
|
|
686
|
-
const parents = new Set();
|
|
687
|
-
for (const table of tables) {
|
|
688
|
-
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
689
|
-
if (key.refTable !== table.name) parents.add(key.refTable);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
return parents;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// How many differently-shaped values to try before giving up on a table. The
|
|
696
|
-
// rules that reject the first attempt - a domain insisting on an @, a CHECK on
|
|
697
|
-
// a length - cannot be read out of the catalogue, so the only honest way to
|
|
698
|
-
// satisfy them is to offer something else and see.
|
|
699
|
-
const SHAPES_TO_TRY = 5;
|
|
700
|
-
|
|
701
|
-
/**
|
|
702
|
-
* Two rows per table: one belonging to each fake person.
|
|
703
|
-
*
|
|
704
|
-
* Inserted as the owner of the schema, deliberately - seeding is not the
|
|
705
|
-
* attack, and a policy that blocked the seed would leave nothing to attack.
|
|
706
|
-
*
|
|
707
|
-
* A table that cannot be seeded is recorded rather than skipped quietly. An
|
|
708
|
-
* empty table reads as a safe table, so silently failing here would report an
|
|
709
|
-
* app as secure because the tool could not get a row into it.
|
|
710
|
-
*/
|
|
711
|
-
async function seed(client, schema, tables) {
|
|
712
|
-
const seeded = [];
|
|
713
|
-
const skipped = [];
|
|
714
|
-
// Each person's own rows, table by table, so that what they own points at
|
|
715
|
-
// their own workspace rather than the first one that went in.
|
|
716
|
-
const rows = new Map();
|
|
717
|
-
const parents = parentTables(tables);
|
|
718
|
-
for (const table of dependencyOrder(tables)) {
|
|
719
|
-
const owner = ownerColumn(table);
|
|
720
|
-
const seeding = { rows: rows, implied: impliedKeys(table, tables) };
|
|
721
|
-
|
|
722
|
-
// A table with nobody's name on it still gets a row. Without one, an open
|
|
723
|
-
// door cannot be told from an empty room: a logged-out stranger reads zero
|
|
724
|
-
// rows either way, and the tool reports the app as safe. Settings tables,
|
|
725
|
-
// waitlists and contact forms are exactly this shape, and exactly the ones
|
|
726
|
-
// that get left open.
|
|
727
|
-
//
|
|
728
|
-
// One that other tables point at - a workspace, a team, an organisation -
|
|
729
|
-
// gets one for each person, so the two of them are in different teams
|
|
730
|
-
// and a team rule is tested across teams rather than inside one. Some
|
|
731
|
-
// tables only ever hold one row, so a pair that will not go in falls back
|
|
732
|
-
// to the single row it always had.
|
|
733
|
-
const casts = owner ? [[USER_A, USER_B]]
|
|
734
|
-
: parents.has(table.name) ? [[USER_A, USER_B], [USER_A]] : [[USER_A]];
|
|
735
|
-
|
|
736
|
-
// Each attempt offers a differently-shaped set of values. A table whose
|
|
737
|
-
// rules reject all of them is reported, never quietly passed over.
|
|
738
|
-
let refused = null;
|
|
739
|
-
let landed = false;
|
|
740
|
-
let worked = 0;
|
|
741
|
-
for (const people of casts) {
|
|
742
|
-
for (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
|
|
743
|
-
const theirs = new Map();
|
|
744
|
-
try {
|
|
745
|
-
let nth = 0;
|
|
746
|
-
for (const person of people) {
|
|
747
|
-
nth += 1;
|
|
748
|
-
const row = await rowFor(client, schema, table, person, nth, null, attempt, seeding);
|
|
749
|
-
const result = await insertRow(client, schema, table.name, row, true);
|
|
750
|
-
theirs.set(person, result.rows[0]);
|
|
751
|
-
}
|
|
752
|
-
landed = true;
|
|
753
|
-
worked = attempt;
|
|
754
|
-
rows.set(table.name, theirs);
|
|
755
|
-
} catch (err) {
|
|
756
|
-
refused = err.message;
|
|
757
|
-
// A half-seeded table would make the next attempt collide with its own
|
|
758
|
-
// first row, so anything that did go in is taken back out.
|
|
759
|
-
await client
|
|
760
|
-
.query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
|
|
761
|
-
.catch(() => {});
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
if (landed) break;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
if (landed) {
|
|
768
|
-
// The shape that worked is kept: any later attack that has to insert into
|
|
769
|
-
// this table can use the same one instead of rediscovering it, and be
|
|
770
|
-
// sure a refusal is the app defending itself rather than a CHECK it
|
|
771
|
-
// never satisfied.
|
|
772
|
-
const entry = { table: table.name, owner: owner, attempt: worked };
|
|
773
|
-
// A table with nobody's name on it that got a row for each person - an
|
|
774
|
-
// organization's invoices, say - holds the second person's own row too.
|
|
775
|
-
// Which row is the first person's is kept, so a write meant for
|
|
776
|
-
// "somebody else's row" is aimed at it. Found on a blind test
|
|
777
|
-
// (HarborLine): an unscoped UPDATE changed the second person's own
|
|
778
|
-
// invoice, and was reported as any customer changing another's.
|
|
779
|
-
const firstRow = !owner && rows.get(table.name) && rows.get(table.name).get(USER_A);
|
|
780
|
-
const pk = primaryKeyOf(table);
|
|
781
|
-
if (firstRow && rows.get(table.name).size === 2 && pk && firstRow[pk] !== null && firstRow[pk] !== undefined) {
|
|
782
|
-
entry.rowOfA = { column: pk, value: String(firstRow[pk]) };
|
|
783
|
-
}
|
|
784
|
-
seeded.push(entry);
|
|
785
|
-
} else {
|
|
786
|
-
// Recorded, never swallowed. The report has to say this table was not
|
|
787
|
-
// checked rather than let an empty table pass for a safe one.
|
|
788
|
-
skipped.push({ table: table.name, why: refused });
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
return { seeded: seeded, skipped: skipped };
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/** Reads a table the way a request would, as whoever is asking. */
|
|
795
|
-
async function readAs(client, schema, table, role, userId) {
|
|
796
|
-
await client.query('BEGIN');
|
|
797
|
-
try {
|
|
798
|
-
await client.query('SET LOCAL role TO ' + role);
|
|
799
|
-
// A logged-out visitor is not "no claims". Supabase hands PostgREST the
|
|
800
|
-
// anon key, which is itself a JWT, so request.jwt.claims arrives as a real
|
|
801
|
-
// JSON object that simply has no `sub` in it.
|
|
802
|
-
//
|
|
803
|
-
// Sending an empty string instead made auth.uid() throw on the cast, and a
|
|
804
|
-
// read that throws returns no rows - which is exactly what a properly
|
|
805
|
-
// secured table returns. Every table whose policy calls auth.uid(), which
|
|
806
|
-
// is nearly every table anyone writes, came back looking safe without the
|
|
807
|
-
// rule ever being evaluated.
|
|
808
|
-
await client.query('SELECT set_config($1, $2, true)', [
|
|
809
|
-
'request.jwt.claims',
|
|
810
|
-
JSON.stringify(userId ? { sub: userId, role: role } : { role: role }),
|
|
811
|
-
]);
|
|
812
|
-
const result = await client.query('SELECT * FROM ' + quote(schema) + '.' + quote(table));
|
|
813
|
-
return result.rows;
|
|
814
|
-
} catch (err) {
|
|
815
|
-
// A refusal is an answer: the table is not reachable by this caller at all.
|
|
816
|
-
return { blocked: err.message };
|
|
817
|
-
} finally {
|
|
818
|
-
await client.query('ROLLBACK');
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
function rowsOwnedBy(rows, owner, person) {
|
|
823
|
-
if (!Array.isArray(rows)) return 0;
|
|
824
|
-
return rows.filter((r) => String(r[owner]) === person).length;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
/**
|
|
828
|
-
* A refusal is only an answer when it is the right refusal.
|
|
829
|
-
*
|
|
830
|
-
* "permission denied for table orders" means this caller cannot reach the
|
|
831
|
-
* table at all. That is the attack being defeated, and it is good news worth
|
|
832
|
-
* recording as a pass.
|
|
833
|
-
*
|
|
834
|
-
* Everything else - a schema the policy needs and the role cannot use, a
|
|
835
|
-
* function the policy calls that is not there, a timeout - means the rule was
|
|
836
|
-
* never evaluated. No verdict exists. Both come back as an error and return
|
|
837
|
-
* zero rows, and zero rows is exactly what a perfectly secured table returns,
|
|
838
|
-
* so telling them apart is the difference between "you are safe" and "I could
|
|
839
|
-
* not tell", which is the difference the whole product rests on.
|
|
840
|
-
*/
|
|
841
|
-
function refusalMeans(message) {
|
|
842
|
-
// The multi-word kinds come first. Postgres says "permission denied for
|
|
843
|
-
// materialized view hits", and an alternation that tried `view` first would
|
|
844
|
-
// never reach it - so a matview nobody had granted was filed as untested
|
|
845
|
-
// rather than as the attack being beaten, and a correct app collected a
|
|
846
|
-
// warning it had not earned.
|
|
847
|
-
const denied = /permission denied for (materialized view|foreign table|partitioned table|table|relation|view|sequence)/i;
|
|
848
|
-
return denied.test(String(message)) ? 'unreachable' : 'untested';
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
/**
|
|
852
|
-
* The table whose rule looks itself up, or null.
|
|
853
|
-
*
|
|
854
|
-
* A rule on organization_members that asks organization_members who is a
|
|
855
|
-
* member has to be checked to be checked, and Postgres stops the request
|
|
856
|
-
* rather than loop. Found on a blind test: every signed-in read touching that
|
|
857
|
-
* table failed, the app was broken for everyone with an account, and the
|
|
858
|
-
* report listed it only as a reason something was not tested. It is a finding
|
|
859
|
-
* in its own right - and it names the table at fault, which is often not the
|
|
860
|
-
* one that was being read.
|
|
861
|
-
*/
|
|
862
|
-
/**
|
|
863
|
-
* The other shape the same loop takes. When the rule reaches its own table
|
|
864
|
-
* through a helper function rather than directly, Postgres does not see a
|
|
865
|
-
* policy recursing - it runs out of stack - and the message names no table.
|
|
866
|
-
* Found on a blind test: every signed-in read failed with "stack depth limit
|
|
867
|
-
* exceeded", and the report filed each one only as untested.
|
|
868
|
-
*/
|
|
869
|
-
function tooDeep(message) {
|
|
870
|
-
return /stack depth limit exceeded/i.test(String(message || ''));
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
function recursionIn(message) {
|
|
874
|
-
const found = /infinite recursion detected in policy for relation "([^"]+)"/i.exec(String(message || ''));
|
|
875
|
-
return found ? found[1] : null;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
/**
|
|
879
|
-
* What each table gives away, and to whom.
|
|
880
|
-
*
|
|
881
|
-
* Two separate findings, because they are two different conversations with the
|
|
882
|
-
* person who has to fix it:
|
|
883
|
-
*
|
|
884
|
-
* exposed - a logged-out stranger can read the table. This is the one that
|
|
885
|
-
* ends up on a news site.
|
|
886
|
-
* crossed - a signed-in customer can read another customer's rows. Quieter,
|
|
887
|
-
* and the one that breaks trust with the people already paying.
|
|
888
|
-
*/
|
|
889
|
-
async function impersonate(client, schema, tables) {
|
|
890
|
-
const findings = [];
|
|
891
|
-
const completed = [];
|
|
892
|
-
const blocked = [];
|
|
893
|
-
// Rules that looked themselves up, and which read ran into each one.
|
|
894
|
-
const looped = [];
|
|
895
|
-
|
|
896
|
-
/** Did this read produce a verdict, and if not, why not? */
|
|
897
|
-
const settle = (key, table, answer, as) => {
|
|
898
|
-
if (Array.isArray(answer)) {
|
|
899
|
-
completed.push(key);
|
|
900
|
-
return true;
|
|
901
|
-
}
|
|
902
|
-
if (refusalMeans(answer.blocked) === 'unreachable') {
|
|
903
|
-
// Refused outright. The attack ran and lost, which is the result we want
|
|
904
|
-
// for a table that is properly closed.
|
|
905
|
-
completed.push(key);
|
|
906
|
-
return false;
|
|
907
|
-
}
|
|
908
|
-
blocked.push({ table: table, key: key, why: as + ': ' + answer.blocked });
|
|
909
|
-
return false;
|
|
910
|
-
};
|
|
911
|
-
|
|
912
|
-
const parents = parentTables(tables);
|
|
913
|
-
for (const table of tables) {
|
|
914
|
-
const owner = ownerColumn(table);
|
|
915
|
-
const tenant = owner ? null : tenantKey(table, tables, parents);
|
|
916
|
-
const anon = await readAs(client, schema, table.name, 'anon', null);
|
|
917
|
-
const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
|
|
918
|
-
|
|
919
|
-
// Looked for on every table, owner or not: a signed-in read of a table
|
|
920
|
-
// with nobody's name on it fails just the same, and it is the same
|
|
921
|
-
// broken page.
|
|
922
|
-
for (const [answer, who] of [[anon, 'anyone'], [asA, 'signed-in']]) {
|
|
923
|
-
if (Array.isArray(answer)) continue;
|
|
924
|
-
const relation = recursionIn(answer.blocked);
|
|
925
|
-
// A loop with no table named is recorded with none; the scan works out
|
|
926
|
-
// which table's rule it is from the rules and helpers it has read.
|
|
927
|
-
if (relation || tooDeep(answer.blocked)) looped.push({ relation: relation || null, table: table.name, who: who });
|
|
928
|
-
}
|
|
929
|
-
// Only a read of the table itself that came back with a verdict, for both
|
|
930
|
-
// callers, shows its rules no longer loop. A read that failed for some
|
|
931
|
-
// other reason shows nothing either way.
|
|
932
|
-
const answered = (answer) => Array.isArray(answer) || refusalMeans(answer.blocked) === 'unreachable';
|
|
933
|
-
if (answered(anon) && answered(asA)) completed.push('recursive:' + table.name);
|
|
934
|
-
|
|
935
|
-
if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
|
|
936
|
-
findings.push({
|
|
937
|
-
kind: 'exposed',
|
|
938
|
-
table: table.name,
|
|
939
|
-
readable: anon.length,
|
|
940
|
-
columns: Object.keys(anon[0] || {}),
|
|
941
|
-
rlsEnabled: table.rlsEnabled,
|
|
942
|
-
isView: Boolean(table.isView),
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
//
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1
|
+
// The impersonation attack.
|
|
2
|
+
//
|
|
3
|
+
// Sign in as one person, ask for another person's data, and see what comes
|
|
4
|
+
// back. This is the attack a recorded browser flow can never find, because the
|
|
5
|
+
// frontend never even tries to ask for somebody else's rows - it only ever
|
|
6
|
+
// requests what the signed-in person is supposed to have.
|
|
7
|
+
//
|
|
8
|
+
// It runs the way a real request runs: become the role PostgREST becomes, put
|
|
9
|
+
// the caller's identity where PostgREST puts it, then read. Verified against a
|
|
10
|
+
// real database in probe-rls.js before any of this was written.
|
|
11
|
+
//
|
|
12
|
+
// Two fake people are seeded and nothing else is ever inserted, so a finding is
|
|
13
|
+
// always about rows this tool created. No real customer is involved at any
|
|
14
|
+
// point, which is the promise the whole product is sold on.
|
|
15
|
+
|
|
16
|
+
const { quote } = require('./schema.js');
|
|
17
|
+
|
|
18
|
+
const USER_A = '11111111-1111-4111-8111-111111111111';
|
|
19
|
+
const USER_B = '22222222-2222-4222-8222-222222222222';
|
|
20
|
+
|
|
21
|
+
// Two more who own nothing at all. The seeded pair already hold a row each,
|
|
22
|
+
// so an attack that inserts under their name collides with the row the seeder
|
|
23
|
+
// put there - on a table keyed by the person, that is a primary key clash, and
|
|
24
|
+
// it was being read as the app refusing the attack. These are for any attack
|
|
25
|
+
// that has to add a row of its own.
|
|
26
|
+
const USER_C = '55555555-5555-4555-8555-555555555555';
|
|
27
|
+
const USER_D = '66666666-6666-4666-8666-666666666666';
|
|
28
|
+
|
|
29
|
+
// The names an owner column is actually given, in the order they are worth
|
|
30
|
+
// believing. `id` is last and only counts on a table that looks like a profile,
|
|
31
|
+
// where the row id IS the person.
|
|
32
|
+
const OWNER_NAMES = ['user_id', 'owner_id', 'owner', 'profile_id', 'account_id', 'created_by', 'author_id'];
|
|
33
|
+
|
|
34
|
+
// A key to Supabase's auth.users as the app wrote it. In the copy it points
|
|
35
|
+
// at a stand-in named kn_ext__auth__users, which the table-of-people rule
|
|
36
|
+
// below already recognises; this one is for a table itself called users.
|
|
37
|
+
const TO_AUTH_USERS = /REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(/i;
|
|
38
|
+
// A table that looks like it holds people, keyed by the person.
|
|
39
|
+
const PERSON_TABLE = /(profile|user|account|member)/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The single-column keys from a uuid column, with what they point at. Read
|
|
43
|
+
* from the constraint wording itself, because a key to auth.users and a key
|
|
44
|
+
* to public.users are different things and the schema is part of the words.
|
|
45
|
+
*/
|
|
46
|
+
function personKeys(table, uuids) {
|
|
47
|
+
const byName = new Set(uuids.map((c) => c.name));
|
|
48
|
+
const keys = [];
|
|
49
|
+
for (const constraint of table.constraints || []) {
|
|
50
|
+
if (constraint.kind !== 'f') continue;
|
|
51
|
+
const definition = String(constraint.definition || '');
|
|
52
|
+
const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(definition);
|
|
53
|
+
if (!match) continue;
|
|
54
|
+
const columns = match[1].split(',').map((t) => t.trim().split('"').join(''));
|
|
55
|
+
const refColumns = match[3].split(',').map((t) => t.trim().split('"').join(''));
|
|
56
|
+
if (columns.length !== 1 || !byName.has(columns[0])) continue;
|
|
57
|
+
keys.push({
|
|
58
|
+
column: columns[0],
|
|
59
|
+
toAuth: TO_AUTH_USERS.test(definition),
|
|
60
|
+
refTable: match[2].split('.').pop().trim().split('"').join(''),
|
|
61
|
+
refColumn: refColumns[0],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return keys;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The column that says who a row belongs to, or null if nothing does. */
|
|
68
|
+
function ownerColumn(table) {
|
|
69
|
+
const uuids = table.columns.filter((c) => c.type === 'uuid');
|
|
70
|
+
for (const name of OWNER_NAMES) {
|
|
71
|
+
const found = uuids.find((c) => c.name === name);
|
|
72
|
+
if (found) return found.name;
|
|
73
|
+
}
|
|
74
|
+
// Not named like an owner, but pointing at a person. Found on a blind test:
|
|
75
|
+
// appointments.provider_id -> profiles.id -> auth.users. Judged by name
|
|
76
|
+
// alone it had no owner, so "one provider reads another's patients" - a
|
|
77
|
+
// rule written USING (true) - was never tried, and the worst hole in the
|
|
78
|
+
// app went unreported. `id` first: a table keyed by the person is theirs.
|
|
79
|
+
// Two people on one row - sender_id and recipient_id - and neither is
|
|
80
|
+
// named: whose row it is cannot be told, and guessing would report one
|
|
81
|
+
// person reading a message sent to them as a break. So only one, or none.
|
|
82
|
+
const people = personKeys(table, uuids).filter((k) => k.toAuth ||
|
|
83
|
+
(k.refColumn === 'id' && k.refTable !== table.name && PERSON_TABLE.test(k.refTable)));
|
|
84
|
+
const columns = Array.from(new Set(people.map((k) => k.column)));
|
|
85
|
+
if (columns.includes('id')) return 'id';
|
|
86
|
+
if (columns.length === 1) return columns[0];
|
|
87
|
+
// A profiles table keyed by the person themselves.
|
|
88
|
+
if (/profile|user|account|member/i.test(table.name)) {
|
|
89
|
+
const id = uuids.find((c) => c.name === 'id');
|
|
90
|
+
if (id) return id.name;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Keeps a generated value inside a declared width like varchar(20). */
|
|
96
|
+
function fitTo(text, type) {
|
|
97
|
+
const match = /^[a-z ]*\((\d+)\)$/.exec(String(type).trim());
|
|
98
|
+
if (!match) return text;
|
|
99
|
+
const limit = Number(match[1]);
|
|
100
|
+
return text.length > limit ? text.slice(0, limit) : text;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Something valid to put in a column, so a row can exist at all.
|
|
105
|
+
*
|
|
106
|
+
* `distinct` is what keeps two seeded rows from being identical. Without it
|
|
107
|
+
* every row carried the same text, so a table with a unique email column
|
|
108
|
+
* refused the second insert and the whole table was reported as "not checked"
|
|
109
|
+
* - a well-built app treated as an unknown one. The tag goes at the front
|
|
110
|
+
* because a narrow varchar truncates the end, and two rows truncated to the
|
|
111
|
+
* same string is that bug all over again.
|
|
112
|
+
*/
|
|
113
|
+
function valueFor(column, owner, distinct, attempt) {
|
|
114
|
+
// A domain is somebody's own type with a rule bolted on. The rule cannot be
|
|
115
|
+
// guessed at from here, but the type underneath it can be filled in.
|
|
116
|
+
const type = String(column.base_type || column.type).toLowerCase();
|
|
117
|
+
const tag = distinct === undefined || distinct === null ? '' : String(distinct);
|
|
118
|
+
const step = Number(tag) || 0;
|
|
119
|
+
|
|
120
|
+
// An enum accepts one of a fixed list and nothing else. Every generated
|
|
121
|
+
// string was rejected, and the table went down as "not checked".
|
|
122
|
+
//
|
|
123
|
+
// Insisting on a real array rather than accepting anything with a length:
|
|
124
|
+
// the labels arrived once as the string "{new,paid,shipped}", whose first
|
|
125
|
+
// element is the character "{", and that is a value the database rejects
|
|
126
|
+
// just as firmly while looking like the code is working.
|
|
127
|
+
if (Array.isArray(column.enum_labels) && column.enum_labels.length) return column.enum_labels[0];
|
|
128
|
+
// Anything at all is allowed in an empty array, whatever the element type.
|
|
129
|
+
if (column.is_array || /\[\]$/.test(type)) return '{}';
|
|
130
|
+
|
|
131
|
+
if (type === 'uuid') return owner;
|
|
132
|
+
if (/^(integer|bigint|smallint|numeric|decimal|real|double|money)/.test(type)) return 1 + step;
|
|
133
|
+
if (/^bool/.test(type)) return true;
|
|
134
|
+
if (/^(timestamp|date)/.test(type)) return new Date().toISOString();
|
|
135
|
+
if (/^time/.test(type)) return '12:00:00';
|
|
136
|
+
if (/^interval/.test(type)) return '1 day';
|
|
137
|
+
if (/^json/.test(type)) return '{}';
|
|
138
|
+
if (/^(inet|cidr)/.test(type)) return '192.0.2.' + (1 + step);
|
|
139
|
+
if (/^macaddr8/.test(type)) return '08:00:2b:01:02:03:04:0' + (5 + step);
|
|
140
|
+
if (/^macaddr/.test(type)) return '08:00:2b:01:02:0' + (3 + step);
|
|
141
|
+
if (/^(tsvector|tsquery)/.test(type)) return 'kryptheon';
|
|
142
|
+
if (/^bytea/.test(type)) return Buffer.from('kryptheon');
|
|
143
|
+
if (/^xml/.test(type)) return '<kryptheon/>';
|
|
144
|
+
if (/^bit/.test(type)) return '0';
|
|
145
|
+
if (/^(point|line|lseg|box|path|polygon|circle)/.test(type)) return '(0,0)';
|
|
146
|
+
|
|
147
|
+
// Text is where the rules live that cannot be read: a domain that insists on
|
|
148
|
+
// an @, a CHECK on a length, a regex for a product code. Rather than pretend
|
|
149
|
+
// to understand them, the seeder works down a short ladder of shapes and
|
|
150
|
+
// keeps whichever one the database accepts.
|
|
151
|
+
const shapes = [
|
|
152
|
+
tag ? tag + ' kryptheon test' : 'kryptheon test',
|
|
153
|
+
'kryptheon' + (tag || '') + '@example.com',
|
|
154
|
+
'KN' + (tag || '1'),
|
|
155
|
+
String(step + 1),
|
|
156
|
+
'https://example.com/kryptheon',
|
|
157
|
+
];
|
|
158
|
+
return fitTo(shapes[Math.min(Math.max(attempt || 0, 0), shapes.length - 1)], type);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Values a CHECK constraint will actually accept for one column.
|
|
163
|
+
*
|
|
164
|
+
* `status text CHECK (status IN ('open','closed'))` is one of the most common
|
|
165
|
+
* things anyone writes, and Postgres stores it as
|
|
166
|
+
* `CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text])))`. Reading the
|
|
167
|
+
* literals back out turns a table that could never be seeded into one that can.
|
|
168
|
+
*
|
|
169
|
+
* Only this one shape is understood, deliberately. A CHECK can contain
|
|
170
|
+
* anything, and pretending to satisfy an arbitrary one would mean inventing
|
|
171
|
+
* rows that the app itself would reject.
|
|
172
|
+
*/
|
|
173
|
+
function allowedByCheck(table, columnName) {
|
|
174
|
+
const found = [];
|
|
175
|
+
const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
176
|
+
// Both spellings of the same rule. On a text column Postgres writes
|
|
177
|
+
// state = ANY (ARRAY['open'::text, ...])
|
|
178
|
+
// and on a varchar column it writes
|
|
179
|
+
// (state)::text = ANY ((ARRAY['open'::character varying, ...])::text[])
|
|
180
|
+
// which the first pattern could not cross: it stopped at the bracket that
|
|
181
|
+
// closes the cast. A varchar column with an IN list is an ordinary thing to
|
|
182
|
+
// write, and every table with one was seeded with an invented value, refused
|
|
183
|
+
// by its own constraint, and reported as a table that could not be checked.
|
|
184
|
+
//
|
|
185
|
+
// What may sit between the column and the = is spelled out rather than left
|
|
186
|
+
// to a negated class: anything looser reaches across the next AND and hands
|
|
187
|
+
// one column's allowed values to another.
|
|
188
|
+
const anyOf = new RegExp(
|
|
189
|
+
'\\b' + escaped + '\\b\\)?(?:::[a-z ]+)?\\s*=\\s*ANY\\s*\\(+\\s*ARRAY\\[(.*?)\\]',
|
|
190
|
+
'i',
|
|
191
|
+
);
|
|
192
|
+
for (const constraint of table.constraints || []) {
|
|
193
|
+
if (constraint.kind !== 'c') continue;
|
|
194
|
+
const match = anyOf.exec(String(constraint.definition));
|
|
195
|
+
if (!match) continue;
|
|
196
|
+
// Picked out one at a time rather than split on commas, which came apart
|
|
197
|
+
// in the middle of any value that had a comma in it.
|
|
198
|
+
const literals = /'((?:[^']|'')*)'/g;
|
|
199
|
+
let literal;
|
|
200
|
+
while ((literal = literals.exec(match[1])) !== null) {
|
|
201
|
+
found.push(literal[1].split("''").join("'"));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return found;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The CHECK constraints on a table that name this column, as the expression
|
|
209
|
+
* inside CHECK (...). A NOT VALID one still applies to every new row.
|
|
210
|
+
*/
|
|
211
|
+
function checksOn(table, columnName) {
|
|
212
|
+
const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
213
|
+
const named = new RegExp('(^|[^A-Za-z0-9_$"])("' + escaped.split('"').join('""') + '"|' + escaped + ')($|[^A-Za-z0-9_$"])');
|
|
214
|
+
const found = [];
|
|
215
|
+
for (const constraint of table.constraints || []) {
|
|
216
|
+
if (constraint.kind !== 'c') continue;
|
|
217
|
+
const definition = String(constraint.definition || '');
|
|
218
|
+
if (!named.test(definition)) continue;
|
|
219
|
+
found.push(definition.replace(/^CHECK\s*/i, '').replace(/\s+NOT VALID\s*$/i, ''));
|
|
220
|
+
}
|
|
221
|
+
return found;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const NUMERIC_KIND = /^(integer|bigint|smallint|numeric|decimal|real|double)/;
|
|
225
|
+
const TEXT_KIND = /^(text|character|varchar|citext)/;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Values to offer a column whose CHECK could not be read, best first.
|
|
229
|
+
*
|
|
230
|
+
* Found on a real app (a clinic's, 2026-10-11): patient_waitlist and
|
|
231
|
+
* whatsapp_contacts each had a phone number column with a CHECK on its
|
|
232
|
+
* format, compliance_requests a type column with a CHECK no reading here
|
|
233
|
+
* understood - and none of the made-up values fitted, so three tables, and a
|
|
234
|
+
* fourth that pointed at one of them, were never tested. Nothing here reads
|
|
235
|
+
* the rule. Each value is put to the rule itself, in the database
|
|
236
|
+
* (fittingValue), and the first it accepts is used.
|
|
237
|
+
*
|
|
238
|
+
* Mirrored by __KN__.check_candidates in engine.sql, value for value and in
|
|
239
|
+
* the same order: both engines must seed the same rows.
|
|
240
|
+
*/
|
|
241
|
+
function checkCandidates(column, definitions, distinct) {
|
|
242
|
+
const kind = String(column.base_type || column.type).toLowerCase();
|
|
243
|
+
const tag = distinct === undefined || distinct === null ? '' : String(distinct);
|
|
244
|
+
const step = Number(tag) || 0;
|
|
245
|
+
const out = [];
|
|
246
|
+
const add = (value) => { if (out.indexOf(value) === -1) out.push(value); };
|
|
247
|
+
if (NUMERIC_KIND.test(kind)) {
|
|
248
|
+
[1 + step, 18 + step, 100 + step, 1000 + step, 0, 5].forEach((n) => add(String(n)));
|
|
249
|
+
for (const definition of definitions) {
|
|
250
|
+
const numbers = definition.match(/-?\d+(?:\.\d+)?/g) || [];
|
|
251
|
+
numbers.forEach(add);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
if (!TEXT_KIND.test(kind)) return out;
|
|
256
|
+
const d = String(step % 10);
|
|
257
|
+
const phones = ['+1415555012' + d, '+919876543' + (210 + step), '9876543' + (210 + step), '415555012' + d,
|
|
258
|
+
'+1 415-555-012' + d, '(415) 555-012' + d, '415-555-012' + d, '+44 7700 90012' + d, '07700 90012' + d];
|
|
259
|
+
const emails = ['kryptheon' + tag + '@example.com'];
|
|
260
|
+
const links = ['https://example.com/kryptheon' + tag];
|
|
261
|
+
// The shapes valueFor offers, in its order, so a rule they already passed
|
|
262
|
+
// still gets the value it always got.
|
|
263
|
+
const words = [tag ? tag + ' kryptheon test' : 'kryptheon test', 'kryptheon' + tag + '@example.com', 'KN' + (tag || '1'),
|
|
264
|
+
String(step + 1), 'https://example.com/kryptheon', 'kryptheon' + tag, 'Kryptheon' + tag, 'kn-' + (tag || '1')];
|
|
265
|
+
const literals = [];
|
|
266
|
+
for (const definition of definitions) {
|
|
267
|
+
const quoted = /'((?:[^']|'')*)'/g;
|
|
268
|
+
let one;
|
|
269
|
+
while ((one = quoted.exec(definition)) !== null) literals.push(one[1].split("''").join("'"));
|
|
270
|
+
}
|
|
271
|
+
const codes = ['US', 'IN', 'USD', 'INR', 'EUR', 'en', 'en-US', '#00000' + d, '2026-01-0' + (1 + (step % 9)), '1234' + d, '56000' + d,
|
|
272
|
+
'kryptheon test value long enough for a rule on a minimum length ' + (tag || '0')];
|
|
273
|
+
const name = String(column.name).toLowerCase();
|
|
274
|
+
const first = /phone|mobile|whatsapp|tel|msisdn/.test(name) ? phones
|
|
275
|
+
: /mail/.test(name) ? emails
|
|
276
|
+
: /url|link|website|site|href/.test(name) ? links : [];
|
|
277
|
+
for (const value of first.concat(words, literals, phones, emails, links, codes)) add(fitTo(value, kind));
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The first values a column's CHECK constraints accept, asked of the
|
|
283
|
+
* database itself: each rule is run on the value alone, the column given as
|
|
284
|
+
* the value by a one-row subquery. A rule that also names another column
|
|
285
|
+
* cannot be run alone and is left out; when no rule can be, nothing is
|
|
286
|
+
* offered and the seeder goes on as before. Remembered per table, column and
|
|
287
|
+
* row, so the attacks that insert later ask nothing again.
|
|
288
|
+
*
|
|
289
|
+
* Returns the accepted values, best first - possibly none.
|
|
290
|
+
*/
|
|
291
|
+
const FITTED = new WeakMap();
|
|
292
|
+
async function fittingValues(client, table, column, distinct) {
|
|
293
|
+
const definitions = checksOn(table, column.name);
|
|
294
|
+
if (!definitions.length) return [];
|
|
295
|
+
const candidates = checkCandidates(column, definitions, distinct);
|
|
296
|
+
if (!candidates.length) return [];
|
|
297
|
+
let byTable = FITTED.get(table);
|
|
298
|
+
if (!byTable) FITTED.set(table, (byTable = new Map()));
|
|
299
|
+
const key = column.name + '\u0000' + String(distinct);
|
|
300
|
+
if (byTable.has(key)) return byTable.get(key);
|
|
301
|
+
|
|
302
|
+
const type = String(column.type);
|
|
303
|
+
const alone = (expression, value) =>
|
|
304
|
+
'SELECT (' + expression + ') IS NOT FALSE AS ok FROM (SELECT ' + value + '::' + type + ' AS ' + quote(column.name) + ') AS kn_row';
|
|
305
|
+
const runnable = [];
|
|
306
|
+
for (const expression of definitions) {
|
|
307
|
+
try {
|
|
308
|
+
await client.query(alone(expression, 'NULL'));
|
|
309
|
+
runnable.push(expression);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
// Names another column, or something only the table can supply.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
let accepted = [];
|
|
315
|
+
if (runnable.length) {
|
|
316
|
+
const all = runnable.map((e) => '(' + e + ') IS NOT FALSE').join(' AND ');
|
|
317
|
+
for (const value of candidates) {
|
|
318
|
+
try {
|
|
319
|
+
const { rows } = await client.query(
|
|
320
|
+
'SELECT ' + all + ' AS ok FROM (SELECT $1::' + type + ' AS ' + quote(column.name) + ') AS kn_row', [value]);
|
|
321
|
+
if (rows[0] && rows[0].ok === true) accepted.push(value);
|
|
322
|
+
// As many as the seeder will ever try; asking further changes nothing.
|
|
323
|
+
if (accepted.length >= SHAPES_TO_TRY) break;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
// A value its own type, or the rule, refuses outright.
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
byTable.set(key, accepted);
|
|
330
|
+
return accepted;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Sets of columns where exactly one may be filled in.
|
|
335
|
+
*
|
|
336
|
+
* Found on a blind test (HelixOps): documents belonged to a work order or to
|
|
337
|
+
* an inspection, never both, written
|
|
338
|
+
* CHECK ((work_order_id IS NOT NULL)::int + (inspection_id IS NOT NULL)::int = 1)
|
|
339
|
+
* Both parents were there, so the seeder pointed the row at both, the CHECK
|
|
340
|
+
* refused every attempt, and the table was never tested at all.
|
|
341
|
+
*
|
|
342
|
+
* Two spellings are read - that sum, and num_nonnulls(a, b, ...) = 1 - and
|
|
343
|
+
* only when the constraint says nothing else. A CHECK with anything more in it
|
|
344
|
+
* is left alone rather than half understood.
|
|
345
|
+
*/
|
|
346
|
+
function exactlyOne(table) {
|
|
347
|
+
const groups = [];
|
|
348
|
+
const name = '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
|
|
349
|
+
const unquote = (text) => (text.charAt(0) === '"' ? text.slice(1, -1).split('""').join('"') : text);
|
|
350
|
+
for (const constraint of table.constraints || []) {
|
|
351
|
+
if (constraint.kind !== 'c') continue;
|
|
352
|
+
const definition = String(constraint.definition || '');
|
|
353
|
+
let columns = [];
|
|
354
|
+
let rest = definition;
|
|
355
|
+
const summed = new RegExp('\\(\\(' + name + ' IS NOT NULL\\)\\)::integer', 'gi');
|
|
356
|
+
let match;
|
|
357
|
+
while ((match = summed.exec(definition)) !== null) columns.push(unquote(match[1]));
|
|
358
|
+
rest = rest.replace(summed, '').replace(/\+/g, '');
|
|
359
|
+
if (!columns.length) {
|
|
360
|
+
const counted = /num_nonnulls\(([^()]*)\)/i.exec(definition);
|
|
361
|
+
if (counted) {
|
|
362
|
+
const listed = new RegExp(name, 'g');
|
|
363
|
+
let one;
|
|
364
|
+
while ((one = listed.exec(counted[1])) !== null) columns.push(unquote(one[1]));
|
|
365
|
+
rest = rest.replace(counted[0], '');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// Nothing may be left but the CHECK itself, brackets and "= 1".
|
|
369
|
+
if (columns.length < 2 || rest.replace(/[()\s]/g, '').toUpperCase() !== 'CHECK=1') continue;
|
|
370
|
+
groups.push(columns);
|
|
371
|
+
}
|
|
372
|
+
return groups;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The foreign keys on a table, read out of Postgres's own wording.
|
|
377
|
+
*
|
|
378
|
+
* Every column of the key, not just the first. A key over (org_id, cart_id)
|
|
379
|
+
* used to be read as though it were only org_id: the second column got an
|
|
380
|
+
* invented value, the pair pointed at no row that existed, and the table was
|
|
381
|
+
* reported as one that could not be checked.
|
|
382
|
+
*/
|
|
383
|
+
function foreignKeys(table) {
|
|
384
|
+
const keys = [];
|
|
385
|
+
const unquote = (text) => text.trim().split('"').join('');
|
|
386
|
+
for (const constraint of table.constraints || []) {
|
|
387
|
+
if (constraint.kind !== 'f') continue;
|
|
388
|
+
const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(constraint.definition);
|
|
389
|
+
if (!match) continue;
|
|
390
|
+
keys.push({
|
|
391
|
+
columns: match[1].split(',').map(unquote),
|
|
392
|
+
refTable: unquote(match[2].split('.').pop()),
|
|
393
|
+
refColumns: match[3].split(',').map(unquote),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return keys;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The single-column primary key of a table, or null if it has none. */
|
|
400
|
+
function primaryKeyOf(table) {
|
|
401
|
+
for (const constraint of table.constraints || []) {
|
|
402
|
+
if (constraint.kind !== 'p') continue;
|
|
403
|
+
const match = /PRIMARY KEY \(([^)]+)\)/i.exec(constraint.definition);
|
|
404
|
+
if (!match) continue;
|
|
405
|
+
const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
|
|
406
|
+
// A composite key is not something a single `<thing>_id` column points at.
|
|
407
|
+
return columns.length === 1 ? columns[0] : null;
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The table a column named `<thing>_id` is pointing at, if there is one.
|
|
414
|
+
*
|
|
415
|
+
* The name has to match a table that is really here, and the types have to
|
|
416
|
+
* agree. Both, because `stripe_id` matches nothing and `org_id integer` does
|
|
417
|
+
* not point at an `orgs.id` that is a uuid.
|
|
418
|
+
*/
|
|
419
|
+
function parentFor(column, tables) {
|
|
420
|
+
const stem = /^(.+)_id$/i.exec(column.name);
|
|
421
|
+
if (!stem) return null;
|
|
422
|
+
const wanted = stem[1].toLowerCase();
|
|
423
|
+
const parent = (tables || []).find((table) => {
|
|
424
|
+
const name = table.name.toLowerCase();
|
|
425
|
+
return name === wanted || name === wanted + 's' || name === wanted + 'es';
|
|
426
|
+
});
|
|
427
|
+
if (!parent) return null;
|
|
428
|
+
|
|
429
|
+
const key = primaryKeyOf(parent);
|
|
430
|
+
if (!key) return null;
|
|
431
|
+
const keyColumn = (parent.columns || []).find((c) => c.name === key);
|
|
432
|
+
if (!keyColumn || keyColumn.type !== column.type) return null;
|
|
433
|
+
|
|
434
|
+
return { table: parent, keyColumn: key, type: keyColumn.type };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The keys a table would have if it had been given them: a `<thing>_id`
|
|
439
|
+
* column naming a table that is here, with no foreign key on it.
|
|
440
|
+
*
|
|
441
|
+
* Found on a blind test. A production snapshot had no foreign keys at all, so
|
|
442
|
+
* workspace_members.workspace_id was seeded with an invented value that named
|
|
443
|
+
* no workspace. A view joining members to their workspaces then showed
|
|
444
|
+
* nothing, and "anyone can list every workspace and who is in it" - a view
|
|
445
|
+
* granted to logged-out visitors - read as an empty room.
|
|
446
|
+
*
|
|
447
|
+
* Only ever used to pick a value. Nothing is refused for lack of one: the
|
|
448
|
+
* database never promised the parent would be there.
|
|
449
|
+
*/
|
|
450
|
+
function impliedKeys(table, tables) {
|
|
451
|
+
const tied = new Set();
|
|
452
|
+
for (const key of foreignKeys(table)) key.columns.forEach((name) => tied.add(name));
|
|
453
|
+
const keys = [];
|
|
454
|
+
for (const column of table.columns || []) {
|
|
455
|
+
if (tied.has(column.name)) continue;
|
|
456
|
+
const parent = parentFor(column, tables);
|
|
457
|
+
if (!parent || parent.table.name === table.name) continue;
|
|
458
|
+
keys.push({ columns: [column.name], refTable: parent.table.name, refColumns: [parent.keyColumn] });
|
|
459
|
+
}
|
|
460
|
+
return keys;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Parents before children.
|
|
465
|
+
*
|
|
466
|
+
* Tables come back in alphabetical order, which put `orders` before `profiles`
|
|
467
|
+
* and made every insert fail on the foreign key - on the first real app it was
|
|
468
|
+
* pointed at, because every app has one of these. A cycle is left in whatever
|
|
469
|
+
* order it arrived: it cannot be satisfied anyway, and the table that fails is
|
|
470
|
+
* reported rather than dropped.
|
|
471
|
+
*
|
|
472
|
+
* A key the app never declared counts too. A member row seeded before the
|
|
473
|
+
* workspace it names has nothing real to point at.
|
|
474
|
+
*/
|
|
475
|
+
function dependencyOrder(tables) {
|
|
476
|
+
const byName = new Map(tables.map((t) => [t.name, t]));
|
|
477
|
+
const ordered = [];
|
|
478
|
+
const done = new Set();
|
|
479
|
+
const visiting = new Set();
|
|
480
|
+
|
|
481
|
+
const visit = (table) => {
|
|
482
|
+
if (done.has(table.name) || visiting.has(table.name)) return;
|
|
483
|
+
visiting.add(table.name);
|
|
484
|
+
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
485
|
+
const parent = byName.get(key.refTable);
|
|
486
|
+
if (parent && parent.name !== table.name) visit(parent);
|
|
487
|
+
}
|
|
488
|
+
visiting.delete(table.name);
|
|
489
|
+
done.add(table.name);
|
|
490
|
+
ordered.push(table);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
for (const table of tables) visit(table);
|
|
494
|
+
return ordered;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* One row that is really in the parent table, so a foreign key is satisfied.
|
|
499
|
+
*
|
|
500
|
+
* The whole row, not one column of it. A composite key has to point at a pair
|
|
501
|
+
* that exists together: borrowing each column from a separate row would build
|
|
502
|
+
* a combination the parent never had.
|
|
503
|
+
*
|
|
504
|
+
* `mine` is the row seeded for the same person, when there is one. Without it
|
|
505
|
+
* both fake people were put in the first workspace - the same team, both as
|
|
506
|
+
* its owner - and every rule written "members of this workspace may read it"
|
|
507
|
+
* let each read the other's rows. Six tables of one blind test were reported
|
|
508
|
+
* as one customer reading another's data, when they were teammates.
|
|
509
|
+
*/
|
|
510
|
+
async function existingRow(client, schema, key, mine) {
|
|
511
|
+
if (mine) return key.refColumns.map((name) => mine[name]);
|
|
512
|
+
try {
|
|
513
|
+
const columns = key.refColumns.map((name, i) => quote(name) + ' AS v' + i).join(', ');
|
|
514
|
+
const { rows } = await client.query(
|
|
515
|
+
'SELECT ' + columns + ' FROM ' + quote(schema) + '.' + quote(key.refTable) + ' LIMIT 1',
|
|
516
|
+
);
|
|
517
|
+
if (!rows.length) return null;
|
|
518
|
+
return key.refColumns.map((name, i) => rows[0]['v' + i]);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* A row that will actually go in.
|
|
526
|
+
*
|
|
527
|
+
* Owner column set to the person, foreign keys pointing at rows that really
|
|
528
|
+
* exist, every NOT NULL column filled, and anything with a default left to
|
|
529
|
+
* supply its own value.
|
|
530
|
+
*
|
|
531
|
+
* `overrides` is what the Collision attack needs: it forces one column to a
|
|
532
|
+
* chosen value while everything else stays distinct, so when two inserts race
|
|
533
|
+
* they collide on that column and on nothing else. Without it a second unique
|
|
534
|
+
* column elsewhere in the table would refuse the insert, and the refusal would
|
|
535
|
+
* be read as the app defending itself.
|
|
536
|
+
*
|
|
537
|
+
* `seeding` is only passed by the seeder: `rows`, the rows already seeded for
|
|
538
|
+
* each person, table by table, so a row points at its own person's parent;
|
|
539
|
+
* and `implied`, the keys the app never declared, so a `workspace_id` with no
|
|
540
|
+
* foreign key still names a workspace that is there.
|
|
541
|
+
*/
|
|
542
|
+
async function rowFor(client, schema, table, person, distinct, overrides, attempt, seeding) {
|
|
543
|
+
const forced = overrides || {};
|
|
544
|
+
const owner = ownerColumn(table);
|
|
545
|
+
const columns = [];
|
|
546
|
+
const values = [];
|
|
547
|
+
const sown = (seeding && seeding.rows) || new Map();
|
|
548
|
+
const implied = (seeding && seeding.implied) || [];
|
|
549
|
+
|
|
550
|
+
// Every column that takes part in a foreign key, and the value it has to
|
|
551
|
+
// hold. Resolved one key at a time so that all of a composite key's columns
|
|
552
|
+
// come from the same parent row. Declared keys first, so a key the app wrote
|
|
553
|
+
// always wins over one guessed from a name.
|
|
554
|
+
const borrowed = new Map();
|
|
555
|
+
for (const key of foreignKeys(table).concat(implied)) {
|
|
556
|
+
const theirs = sown.get(key.refTable);
|
|
557
|
+
const row = await existingRow(client, schema, key, theirs && theirs.get(person));
|
|
558
|
+
if (!row) continue;
|
|
559
|
+
key.columns.forEach((name, i) => {
|
|
560
|
+
if (!borrowed.has(name)) borrowed.set(name, row[i]);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const partOfKey = new Set();
|
|
564
|
+
for (const key of foreignKeys(table)) key.columns.forEach((name) => partOfKey.add(name));
|
|
565
|
+
|
|
566
|
+
// Where exactly one of a set may be filled in: the one already chosen, or
|
|
567
|
+
// else the first that can be - a key needs a parent row to point at. The
|
|
568
|
+
// rest are written as nothing, whatever their default.
|
|
569
|
+
const chosen = (name) => Object.prototype.hasOwnProperty.call(forced, name);
|
|
570
|
+
const onlyOne = new Map();
|
|
571
|
+
for (const group of exactlyOne(table)) {
|
|
572
|
+
const kept = group.find(chosen) || group.find((name) => !partOfKey.has(name) || borrowed.has(name));
|
|
573
|
+
for (const name of group) onlyOne.set(name, name === kept);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
for (const column of table.columns) {
|
|
577
|
+
if (Object.prototype.hasOwnProperty.call(forced, column.name)) {
|
|
578
|
+
columns.push(column.name);
|
|
579
|
+
values.push(forced[column.name]);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (onlyOne.get(column.name) === false) {
|
|
583
|
+
columns.push(column.name);
|
|
584
|
+
values.push(null);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const mustFill = onlyOne.get(column.name) === true;
|
|
588
|
+
if (column.name === owner) {
|
|
589
|
+
columns.push(column.name);
|
|
590
|
+
values.push(person);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
// A column pointing at another table has to hold something that is
|
|
594
|
+
// actually there, whatever its type would otherwise suggest.
|
|
595
|
+
if (partOfKey.has(column.name)) {
|
|
596
|
+
if (borrowed.has(column.name)) {
|
|
597
|
+
columns.push(column.name);
|
|
598
|
+
values.push(borrowed.get(column.name));
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (column.not_null) throw new Error('nothing to point ' + column.name + ' at');
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
// A generated column computes itself and refuses to be written to at all.
|
|
605
|
+
//
|
|
606
|
+
// Two halves, and only one of them can be observed. An identity column
|
|
607
|
+
// carries no default_expr, so without the identity half the seeder writes
|
|
608
|
+
// to it and Postgres refuses the row - both engines are caught doing it.
|
|
609
|
+
// A stored generated column keeps its expression IN default_expr, so the
|
|
610
|
+
// line below skips it whether or not this one does: removing the generated
|
|
611
|
+
// half changes nothing any fixture could see. It stays because that is a
|
|
612
|
+
// fact about how readColumns fills the shape, not about Postgres, and the
|
|
613
|
+
// day it changes this is the only thing standing in the way.
|
|
614
|
+
if (column.generated || column.identity) continue;
|
|
615
|
+
// A key nobody declared, pointing at a parent that is there. Filled even
|
|
616
|
+
// where a default or a null would do, because a row that names no real
|
|
617
|
+
// parent is invisible to every rule and view that joins through it.
|
|
618
|
+
if (borrowed.has(column.name)) {
|
|
619
|
+
columns.push(column.name);
|
|
620
|
+
values.push(borrowed.get(column.name));
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
// Anything with a default can supply its own value. A column that may be
|
|
624
|
+
// empty is left empty - unless it is the one of a set that has to be
|
|
625
|
+
// filled in.
|
|
626
|
+
if (column.default_expr) continue;
|
|
627
|
+
if (!column.not_null && !mustFill) continue;
|
|
628
|
+
columns.push(column.name);
|
|
629
|
+
// A CHECK that lists what it will accept beats anything invented here;
|
|
630
|
+
// after that, a value one it could not read was asked about and accepts.
|
|
631
|
+
const allowed = allowedByCheck(table, column.name);
|
|
632
|
+
const fitting = allowed.length ? [] : await fittingValues(client, table, column, distinct);
|
|
633
|
+
const pick = (list) => list[Math.min(Math.max(attempt || 0, 0), list.length - 1)];
|
|
634
|
+
values.push(
|
|
635
|
+
allowed.length ? pick(allowed)
|
|
636
|
+
: fitting.length ? pick(fitting)
|
|
637
|
+
: valueFor(column, person, distinct, attempt),
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return { columns: columns, values: values };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Puts a built row in. Separate so the same row can be raced against itself.
|
|
646
|
+
* `back` asks for the row as it landed, defaults and all.
|
|
647
|
+
*/
|
|
648
|
+
function insertRow(client, schema, table, row, back) {
|
|
649
|
+
const where = 'INSERT INTO ' + quote(schema) + '.' + quote(table);
|
|
650
|
+
const tail = back ? ' RETURNING *' : '';
|
|
651
|
+
// A table of nothing but an id and its defaults leaves no columns to name,
|
|
652
|
+
// and "INSERT INTO t () VALUES ()" is a syntax error. Postgres has a spelling
|
|
653
|
+
// for exactly this, and without it every settings and flags table in the
|
|
654
|
+
// world came back as one that could not be checked.
|
|
655
|
+
if (!row.columns.length) return client.query(where + ' DEFAULT VALUES' + tail);
|
|
656
|
+
const placeholders = row.values.map((_, i) => '$' + (i + 1));
|
|
657
|
+
return client.query(
|
|
658
|
+
where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')' + tail,
|
|
659
|
+
row.values,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The column that says which organization's a row is, on a table with no
|
|
665
|
+
* person's name on it - or null. Its first key to a table that others point
|
|
666
|
+
* at (an organization, a workspace, a team), where the table itself is not one
|
|
667
|
+
* of those: seeding gives each person their own organization, and a table like
|
|
668
|
+
* this one row, in the first person's. Found on a blind test (HarborLine):
|
|
669
|
+
* integration_secrets(organization_id, access_token) was readable by every
|
|
670
|
+
* signed-in user, and with no person column it was never tried at all.
|
|
671
|
+
*/
|
|
672
|
+
function tenantKey(table, tables, parents) {
|
|
673
|
+
// A view is not seeded: it shows whatever its tables hold, the second
|
|
674
|
+
// person's own organization included, so a row of it read by them proves
|
|
675
|
+
// nothing. Found on the same test: workspace_health, filtered to the
|
|
676
|
+
// organizations the reader belongs to, came back as a leak.
|
|
677
|
+
if (table.isView || parents.has(table.name)) return null;
|
|
678
|
+
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
679
|
+
if (key.refTable !== table.name && parents.has(key.refTable) && key.columns.length === 1) return key.columns[0];
|
|
680
|
+
}
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Every table some other table points at, by a key it declared or one it implies. */
|
|
685
|
+
function parentTables(tables) {
|
|
686
|
+
const parents = new Set();
|
|
687
|
+
for (const table of tables) {
|
|
688
|
+
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
689
|
+
if (key.refTable !== table.name) parents.add(key.refTable);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return parents;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// How many differently-shaped values to try before giving up on a table. The
|
|
696
|
+
// rules that reject the first attempt - a domain insisting on an @, a CHECK on
|
|
697
|
+
// a length - cannot be read out of the catalogue, so the only honest way to
|
|
698
|
+
// satisfy them is to offer something else and see.
|
|
699
|
+
const SHAPES_TO_TRY = 5;
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Two rows per table: one belonging to each fake person.
|
|
703
|
+
*
|
|
704
|
+
* Inserted as the owner of the schema, deliberately - seeding is not the
|
|
705
|
+
* attack, and a policy that blocked the seed would leave nothing to attack.
|
|
706
|
+
*
|
|
707
|
+
* A table that cannot be seeded is recorded rather than skipped quietly. An
|
|
708
|
+
* empty table reads as a safe table, so silently failing here would report an
|
|
709
|
+
* app as secure because the tool could not get a row into it.
|
|
710
|
+
*/
|
|
711
|
+
async function seed(client, schema, tables) {
|
|
712
|
+
const seeded = [];
|
|
713
|
+
const skipped = [];
|
|
714
|
+
// Each person's own rows, table by table, so that what they own points at
|
|
715
|
+
// their own workspace rather than the first one that went in.
|
|
716
|
+
const rows = new Map();
|
|
717
|
+
const parents = parentTables(tables);
|
|
718
|
+
for (const table of dependencyOrder(tables)) {
|
|
719
|
+
const owner = ownerColumn(table);
|
|
720
|
+
const seeding = { rows: rows, implied: impliedKeys(table, tables) };
|
|
721
|
+
|
|
722
|
+
// A table with nobody's name on it still gets a row. Without one, an open
|
|
723
|
+
// door cannot be told from an empty room: a logged-out stranger reads zero
|
|
724
|
+
// rows either way, and the tool reports the app as safe. Settings tables,
|
|
725
|
+
// waitlists and contact forms are exactly this shape, and exactly the ones
|
|
726
|
+
// that get left open.
|
|
727
|
+
//
|
|
728
|
+
// One that other tables point at - a workspace, a team, an organisation -
|
|
729
|
+
// gets one for each person, so the two of them are in different teams
|
|
730
|
+
// and a team rule is tested across teams rather than inside one. Some
|
|
731
|
+
// tables only ever hold one row, so a pair that will not go in falls back
|
|
732
|
+
// to the single row it always had.
|
|
733
|
+
const casts = owner ? [[USER_A, USER_B]]
|
|
734
|
+
: parents.has(table.name) ? [[USER_A, USER_B], [USER_A]] : [[USER_A]];
|
|
735
|
+
|
|
736
|
+
// Each attempt offers a differently-shaped set of values. A table whose
|
|
737
|
+
// rules reject all of them is reported, never quietly passed over.
|
|
738
|
+
let refused = null;
|
|
739
|
+
let landed = false;
|
|
740
|
+
let worked = 0;
|
|
741
|
+
for (const people of casts) {
|
|
742
|
+
for (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
|
|
743
|
+
const theirs = new Map();
|
|
744
|
+
try {
|
|
745
|
+
let nth = 0;
|
|
746
|
+
for (const person of people) {
|
|
747
|
+
nth += 1;
|
|
748
|
+
const row = await rowFor(client, schema, table, person, nth, null, attempt, seeding);
|
|
749
|
+
const result = await insertRow(client, schema, table.name, row, true);
|
|
750
|
+
theirs.set(person, result.rows[0]);
|
|
751
|
+
}
|
|
752
|
+
landed = true;
|
|
753
|
+
worked = attempt;
|
|
754
|
+
rows.set(table.name, theirs);
|
|
755
|
+
} catch (err) {
|
|
756
|
+
refused = err.message;
|
|
757
|
+
// A half-seeded table would make the next attempt collide with its own
|
|
758
|
+
// first row, so anything that did go in is taken back out.
|
|
759
|
+
await client
|
|
760
|
+
.query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
|
|
761
|
+
.catch(() => {});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (landed) break;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (landed) {
|
|
768
|
+
// The shape that worked is kept: any later attack that has to insert into
|
|
769
|
+
// this table can use the same one instead of rediscovering it, and be
|
|
770
|
+
// sure a refusal is the app defending itself rather than a CHECK it
|
|
771
|
+
// never satisfied.
|
|
772
|
+
const entry = { table: table.name, owner: owner, attempt: worked };
|
|
773
|
+
// A table with nobody's name on it that got a row for each person - an
|
|
774
|
+
// organization's invoices, say - holds the second person's own row too.
|
|
775
|
+
// Which row is the first person's is kept, so a write meant for
|
|
776
|
+
// "somebody else's row" is aimed at it. Found on a blind test
|
|
777
|
+
// (HarborLine): an unscoped UPDATE changed the second person's own
|
|
778
|
+
// invoice, and was reported as any customer changing another's.
|
|
779
|
+
const firstRow = !owner && rows.get(table.name) && rows.get(table.name).get(USER_A);
|
|
780
|
+
const pk = primaryKeyOf(table);
|
|
781
|
+
if (firstRow && rows.get(table.name).size === 2 && pk && firstRow[pk] !== null && firstRow[pk] !== undefined) {
|
|
782
|
+
entry.rowOfA = { column: pk, value: String(firstRow[pk]) };
|
|
783
|
+
}
|
|
784
|
+
seeded.push(entry);
|
|
785
|
+
} else {
|
|
786
|
+
// Recorded, never swallowed. The report has to say this table was not
|
|
787
|
+
// checked rather than let an empty table pass for a safe one.
|
|
788
|
+
skipped.push({ table: table.name, why: refused });
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return { seeded: seeded, skipped: skipped };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Reads a table the way a request would, as whoever is asking. */
|
|
795
|
+
async function readAs(client, schema, table, role, userId) {
|
|
796
|
+
await client.query('BEGIN');
|
|
797
|
+
try {
|
|
798
|
+
await client.query('SET LOCAL role TO ' + role);
|
|
799
|
+
// A logged-out visitor is not "no claims". Supabase hands PostgREST the
|
|
800
|
+
// anon key, which is itself a JWT, so request.jwt.claims arrives as a real
|
|
801
|
+
// JSON object that simply has no `sub` in it.
|
|
802
|
+
//
|
|
803
|
+
// Sending an empty string instead made auth.uid() throw on the cast, and a
|
|
804
|
+
// read that throws returns no rows - which is exactly what a properly
|
|
805
|
+
// secured table returns. Every table whose policy calls auth.uid(), which
|
|
806
|
+
// is nearly every table anyone writes, came back looking safe without the
|
|
807
|
+
// rule ever being evaluated.
|
|
808
|
+
await client.query('SELECT set_config($1, $2, true)', [
|
|
809
|
+
'request.jwt.claims',
|
|
810
|
+
JSON.stringify(userId ? { sub: userId, role: role } : { role: role }),
|
|
811
|
+
]);
|
|
812
|
+
const result = await client.query('SELECT * FROM ' + quote(schema) + '.' + quote(table));
|
|
813
|
+
return result.rows;
|
|
814
|
+
} catch (err) {
|
|
815
|
+
// A refusal is an answer: the table is not reachable by this caller at all.
|
|
816
|
+
return { blocked: err.message };
|
|
817
|
+
} finally {
|
|
818
|
+
await client.query('ROLLBACK');
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function rowsOwnedBy(rows, owner, person) {
|
|
823
|
+
if (!Array.isArray(rows)) return 0;
|
|
824
|
+
return rows.filter((r) => String(r[owner]) === person).length;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* A refusal is only an answer when it is the right refusal.
|
|
829
|
+
*
|
|
830
|
+
* "permission denied for table orders" means this caller cannot reach the
|
|
831
|
+
* table at all. That is the attack being defeated, and it is good news worth
|
|
832
|
+
* recording as a pass.
|
|
833
|
+
*
|
|
834
|
+
* Everything else - a schema the policy needs and the role cannot use, a
|
|
835
|
+
* function the policy calls that is not there, a timeout - means the rule was
|
|
836
|
+
* never evaluated. No verdict exists. Both come back as an error and return
|
|
837
|
+
* zero rows, and zero rows is exactly what a perfectly secured table returns,
|
|
838
|
+
* so telling them apart is the difference between "you are safe" and "I could
|
|
839
|
+
* not tell", which is the difference the whole product rests on.
|
|
840
|
+
*/
|
|
841
|
+
function refusalMeans(message) {
|
|
842
|
+
// The multi-word kinds come first. Postgres says "permission denied for
|
|
843
|
+
// materialized view hits", and an alternation that tried `view` first would
|
|
844
|
+
// never reach it - so a matview nobody had granted was filed as untested
|
|
845
|
+
// rather than as the attack being beaten, and a correct app collected a
|
|
846
|
+
// warning it had not earned.
|
|
847
|
+
const denied = /permission denied for (materialized view|foreign table|partitioned table|table|relation|view|sequence)/i;
|
|
848
|
+
return denied.test(String(message)) ? 'unreachable' : 'untested';
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* The table whose rule looks itself up, or null.
|
|
853
|
+
*
|
|
854
|
+
* A rule on organization_members that asks organization_members who is a
|
|
855
|
+
* member has to be checked to be checked, and Postgres stops the request
|
|
856
|
+
* rather than loop. Found on a blind test: every signed-in read touching that
|
|
857
|
+
* table failed, the app was broken for everyone with an account, and the
|
|
858
|
+
* report listed it only as a reason something was not tested. It is a finding
|
|
859
|
+
* in its own right - and it names the table at fault, which is often not the
|
|
860
|
+
* one that was being read.
|
|
861
|
+
*/
|
|
862
|
+
/**
|
|
863
|
+
* The other shape the same loop takes. When the rule reaches its own table
|
|
864
|
+
* through a helper function rather than directly, Postgres does not see a
|
|
865
|
+
* policy recursing - it runs out of stack - and the message names no table.
|
|
866
|
+
* Found on a blind test: every signed-in read failed with "stack depth limit
|
|
867
|
+
* exceeded", and the report filed each one only as untested.
|
|
868
|
+
*/
|
|
869
|
+
function tooDeep(message) {
|
|
870
|
+
return /stack depth limit exceeded/i.test(String(message || ''));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function recursionIn(message) {
|
|
874
|
+
const found = /infinite recursion detected in policy for relation "([^"]+)"/i.exec(String(message || ''));
|
|
875
|
+
return found ? found[1] : null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* What each table gives away, and to whom.
|
|
880
|
+
*
|
|
881
|
+
* Two separate findings, because they are two different conversations with the
|
|
882
|
+
* person who has to fix it:
|
|
883
|
+
*
|
|
884
|
+
* exposed - a logged-out stranger can read the table. This is the one that
|
|
885
|
+
* ends up on a news site.
|
|
886
|
+
* crossed - a signed-in customer can read another customer's rows. Quieter,
|
|
887
|
+
* and the one that breaks trust with the people already paying.
|
|
888
|
+
*/
|
|
889
|
+
async function impersonate(client, schema, tables) {
|
|
890
|
+
const findings = [];
|
|
891
|
+
const completed = [];
|
|
892
|
+
const blocked = [];
|
|
893
|
+
// Rules that looked themselves up, and which read ran into each one.
|
|
894
|
+
const looped = [];
|
|
895
|
+
|
|
896
|
+
/** Did this read produce a verdict, and if not, why not? */
|
|
897
|
+
const settle = (key, table, answer, as) => {
|
|
898
|
+
if (Array.isArray(answer)) {
|
|
899
|
+
completed.push(key);
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
if (refusalMeans(answer.blocked) === 'unreachable') {
|
|
903
|
+
// Refused outright. The attack ran and lost, which is the result we want
|
|
904
|
+
// for a table that is properly closed.
|
|
905
|
+
completed.push(key);
|
|
906
|
+
return false;
|
|
907
|
+
}
|
|
908
|
+
blocked.push({ table: table, key: key, why: as + ': ' + answer.blocked });
|
|
909
|
+
return false;
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
const parents = parentTables(tables);
|
|
913
|
+
for (const table of tables) {
|
|
914
|
+
const owner = ownerColumn(table);
|
|
915
|
+
const tenant = owner ? null : tenantKey(table, tables, parents);
|
|
916
|
+
const anon = await readAs(client, schema, table.name, 'anon', null);
|
|
917
|
+
const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
|
|
918
|
+
|
|
919
|
+
// Looked for on every table, owner or not: a signed-in read of a table
|
|
920
|
+
// with nobody's name on it fails just the same, and it is the same
|
|
921
|
+
// broken page.
|
|
922
|
+
for (const [answer, who] of [[anon, 'anyone'], [asA, 'signed-in']]) {
|
|
923
|
+
if (Array.isArray(answer)) continue;
|
|
924
|
+
const relation = recursionIn(answer.blocked);
|
|
925
|
+
// A loop with no table named is recorded with none; the scan works out
|
|
926
|
+
// which table's rule it is from the rules and helpers it has read.
|
|
927
|
+
if (relation || tooDeep(answer.blocked)) looped.push({ relation: relation || null, table: table.name, who: who });
|
|
928
|
+
}
|
|
929
|
+
// Only a read of the table itself that came back with a verdict, for both
|
|
930
|
+
// callers, shows its rules no longer loop. A read that failed for some
|
|
931
|
+
// other reason shows nothing either way.
|
|
932
|
+
const answered = (answer) => Array.isArray(answer) || refusalMeans(answer.blocked) === 'unreachable';
|
|
933
|
+
if (answered(anon) && answered(asA)) completed.push('recursive:' + table.name);
|
|
934
|
+
|
|
935
|
+
if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
|
|
936
|
+
findings.push({
|
|
937
|
+
kind: 'exposed',
|
|
938
|
+
table: table.name,
|
|
939
|
+
readable: anon.length,
|
|
940
|
+
columns: Object.keys(anon[0] || {}),
|
|
941
|
+
rlsEnabled: table.rlsEnabled,
|
|
942
|
+
isView: Boolean(table.isView),
|
|
943
|
+
// No column says whose a row is - no person, no team: it may be meant
|
|
944
|
+
// to be public, a price list or a blog (blind round 17, DEC-03).
|
|
945
|
+
nobodys: !owner && !tenant,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Crossed is only ever looked for where a row says who it belongs to, so
|
|
950
|
+
// on a table with no owner there is no attack to record either way.
|
|
951
|
+
if (owner && settle('crossed:' + table.name, table.name, asA, 'as a signed-in customer')) {
|
|
952
|
+
const theirs = rowsOwnedBy(asA, owner, USER_B);
|
|
953
|
+
if (theirs > 0) {
|
|
954
|
+
findings.push({
|
|
955
|
+
kind: 'crossed',
|
|
956
|
+
table: table.name,
|
|
957
|
+
owner: owner,
|
|
958
|
+
readable: theirs,
|
|
959
|
+
columns: Object.keys(asA[0] || {}),
|
|
960
|
+
rlsEnabled: table.rlsEnabled,
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// A table whose rows are an organization's, not a person's: its one row is
|
|
966
|
+
// the first person's organization's, and the second person - signed in,
|
|
967
|
+
// in an organization of their own - asks for it. What a logged-out
|
|
968
|
+
// stranger can already read is reported as exposed, and not twice.
|
|
969
|
+
if (tenant) {
|
|
970
|
+
const asB = await readAs(client, schema, table.name, 'authenticated', USER_B);
|
|
971
|
+
const open = Array.isArray(anon) && anon.length > 0;
|
|
972
|
+
if (settle('crossed:' + table.name, table.name, asB, 'as a signed-in user in another organization') &&
|
|
973
|
+
asB.length > 0 && !open) {
|
|
974
|
+
findings.push({
|
|
975
|
+
kind: 'crossed',
|
|
976
|
+
table: table.name,
|
|
977
|
+
owner: tenant,
|
|
978
|
+
tenant: true,
|
|
979
|
+
readable: asB.length,
|
|
980
|
+
columns: Object.keys(asB[0] || {}),
|
|
981
|
+
rlsEnabled: table.rlsEnabled,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return { findings: findings, completed: completed, blocked: blocked, looped: looped };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** A stable shape for comparing one run against another. */
|
|
991
|
+
function summarise(result) {
|
|
992
|
+
const findings = Array.isArray(result) ? result : (result && result.findings) || [];
|
|
993
|
+
return findings
|
|
994
|
+
.map((f) => f.kind + ':' + f.table + ':' + f.readable)
|
|
995
|
+
.sort()
|
|
996
|
+
.join(' | ');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
module.exports = {
|
|
1000
|
+
USER_A: USER_A,
|
|
1001
|
+
USER_B: USER_B,
|
|
1002
|
+
USER_C: USER_C,
|
|
1003
|
+
USER_D: USER_D,
|
|
1004
|
+
refusalMeans: refusalMeans,
|
|
1005
|
+
recursionIn: recursionIn,
|
|
1006
|
+
tooDeep: tooDeep,
|
|
1007
|
+
ownerColumn: ownerColumn,
|
|
1008
|
+
fitTo: fitTo,
|
|
1009
|
+
foreignKeys: foreignKeys,
|
|
1010
|
+
primaryKeyOf: primaryKeyOf,
|
|
1011
|
+
parentFor: parentFor,
|
|
1012
|
+
impliedKeys: impliedKeys,
|
|
1013
|
+
parentTables: parentTables,
|
|
1014
|
+
dependencyOrder: dependencyOrder,
|
|
1015
|
+
valueFor: valueFor,
|
|
1016
|
+
allowedByCheck: allowedByCheck,
|
|
1017
|
+
checksOn: checksOn,
|
|
1018
|
+
checkCandidates: checkCandidates,
|
|
1019
|
+
fittingValues: fittingValues,
|
|
1020
|
+
exactlyOne: exactlyOne,
|
|
1021
|
+
rowFor: rowFor,
|
|
1022
|
+
existingRow: existingRow,
|
|
1023
|
+
insertRow: insertRow,
|
|
1024
|
+
seed: seed,
|
|
1025
|
+
readAs: readAs,
|
|
1026
|
+
impersonate: impersonate,
|
|
1027
|
+
summarise: summarise,
|
|
1028
|
+
};
|