kryptheon-night 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -0
- package/attack.js +555 -0
- package/bin/kryptheon-night.js +248 -0
- package/collision.js +309 -0
- package/connect.js +60 -0
- package/finding.js +552 -0
- package/intro.js +202 -0
- package/orphan.js +230 -0
- package/package.json +73 -0
- package/recheck.js +219 -0
- package/scan.js +569 -0
- package/schema.js +922 -0
- package/tamper.js +208 -0
- package/trouble.js +377 -0
package/attack.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
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
|
+
/** The column that says who a row belongs to, or null if nothing does. */
|
|
35
|
+
function ownerColumn(table) {
|
|
36
|
+
const uuids = table.columns.filter((c) => c.type === 'uuid');
|
|
37
|
+
for (const name of OWNER_NAMES) {
|
|
38
|
+
const found = uuids.find((c) => c.name === name);
|
|
39
|
+
if (found) return found.name;
|
|
40
|
+
}
|
|
41
|
+
// A profiles table keyed by the person themselves.
|
|
42
|
+
if (/profile|user|account|member/i.test(table.name)) {
|
|
43
|
+
const id = uuids.find((c) => c.name === 'id');
|
|
44
|
+
if (id) return id.name;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Keeps a generated value inside a declared width like varchar(20). */
|
|
50
|
+
function fitTo(text, type) {
|
|
51
|
+
const match = /^[a-z ]*\((\d+)\)$/.exec(String(type).trim());
|
|
52
|
+
if (!match) return text;
|
|
53
|
+
const limit = Number(match[1]);
|
|
54
|
+
return text.length > limit ? text.slice(0, limit) : text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Something valid to put in a column, so a row can exist at all.
|
|
59
|
+
*
|
|
60
|
+
* `distinct` is what keeps two seeded rows from being identical. Without it
|
|
61
|
+
* every row carried the same text, so a table with a unique email column
|
|
62
|
+
* refused the second insert and the whole table was reported as "not checked"
|
|
63
|
+
* - a well-built app treated as an unknown one. The tag goes at the front
|
|
64
|
+
* because a narrow varchar truncates the end, and two rows truncated to the
|
|
65
|
+
* same string is that bug all over again.
|
|
66
|
+
*/
|
|
67
|
+
function valueFor(column, owner, distinct, attempt) {
|
|
68
|
+
// A domain is somebody's own type with a rule bolted on. The rule cannot be
|
|
69
|
+
// guessed at from here, but the type underneath it can be filled in.
|
|
70
|
+
const type = String(column.base_type || column.type).toLowerCase();
|
|
71
|
+
const tag = distinct === undefined || distinct === null ? '' : String(distinct);
|
|
72
|
+
const step = Number(tag) || 0;
|
|
73
|
+
|
|
74
|
+
// An enum accepts one of a fixed list and nothing else. Every generated
|
|
75
|
+
// string was rejected, and the table went down as "not checked".
|
|
76
|
+
//
|
|
77
|
+
// Insisting on a real array rather than accepting anything with a length:
|
|
78
|
+
// the labels arrived once as the string "{new,paid,shipped}", whose first
|
|
79
|
+
// element is the character "{", and that is a value the database rejects
|
|
80
|
+
// just as firmly while looking like the code is working.
|
|
81
|
+
if (Array.isArray(column.enum_labels) && column.enum_labels.length) return column.enum_labels[0];
|
|
82
|
+
// Anything at all is allowed in an empty array, whatever the element type.
|
|
83
|
+
if (column.is_array || /\[\]$/.test(type)) return '{}';
|
|
84
|
+
|
|
85
|
+
if (type === 'uuid') return owner;
|
|
86
|
+
if (/^(integer|bigint|smallint|numeric|decimal|real|double|money)/.test(type)) return 1 + step;
|
|
87
|
+
if (/^bool/.test(type)) return true;
|
|
88
|
+
if (/^(timestamp|date)/.test(type)) return new Date().toISOString();
|
|
89
|
+
if (/^time/.test(type)) return '12:00:00';
|
|
90
|
+
if (/^interval/.test(type)) return '1 day';
|
|
91
|
+
if (/^json/.test(type)) return '{}';
|
|
92
|
+
if (/^(inet|cidr)/.test(type)) return '192.0.2.' + (1 + step);
|
|
93
|
+
if (/^macaddr8/.test(type)) return '08:00:2b:01:02:03:04:0' + (5 + step);
|
|
94
|
+
if (/^macaddr/.test(type)) return '08:00:2b:01:02:0' + (3 + step);
|
|
95
|
+
if (/^(tsvector|tsquery)/.test(type)) return 'kryptheon';
|
|
96
|
+
if (/^bytea/.test(type)) return Buffer.from('kryptheon');
|
|
97
|
+
if (/^xml/.test(type)) return '<kryptheon/>';
|
|
98
|
+
if (/^bit/.test(type)) return '0';
|
|
99
|
+
if (/^(point|line|lseg|box|path|polygon|circle)/.test(type)) return '(0,0)';
|
|
100
|
+
|
|
101
|
+
// Text is where the rules live that cannot be read: a domain that insists on
|
|
102
|
+
// an @, a CHECK on a length, a regex for a product code. Rather than pretend
|
|
103
|
+
// to understand them, the seeder works down a short ladder of shapes and
|
|
104
|
+
// keeps whichever one the database accepts.
|
|
105
|
+
const shapes = [
|
|
106
|
+
tag ? tag + ' kryptheon test' : 'kryptheon test',
|
|
107
|
+
'kryptheon' + (tag || '') + '@example.com',
|
|
108
|
+
'KN' + (tag || '1'),
|
|
109
|
+
String(step + 1),
|
|
110
|
+
'https://example.com/kryptheon',
|
|
111
|
+
];
|
|
112
|
+
return fitTo(shapes[Math.min(Math.max(attempt || 0, 0), shapes.length - 1)], type);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Values a CHECK constraint will actually accept for one column.
|
|
117
|
+
*
|
|
118
|
+
* `status text CHECK (status IN ('open','closed'))` is one of the most common
|
|
119
|
+
* things anyone writes, and Postgres stores it as
|
|
120
|
+
* `CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text])))`. Reading the
|
|
121
|
+
* literals back out turns a table that could never be seeded into one that can.
|
|
122
|
+
*
|
|
123
|
+
* Only this one shape is understood, deliberately. A CHECK can contain
|
|
124
|
+
* anything, and pretending to satisfy an arbitrary one would mean inventing
|
|
125
|
+
* rows that the app itself would reject.
|
|
126
|
+
*/
|
|
127
|
+
function allowedByCheck(table, columnName) {
|
|
128
|
+
const found = [];
|
|
129
|
+
const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
130
|
+
// Both spellings of the same rule. On a text column Postgres writes
|
|
131
|
+
// state = ANY (ARRAY['open'::text, ...])
|
|
132
|
+
// and on a varchar column it writes
|
|
133
|
+
// (state)::text = ANY ((ARRAY['open'::character varying, ...])::text[])
|
|
134
|
+
// which the first pattern could not cross: it stopped at the bracket that
|
|
135
|
+
// closes the cast. A varchar column with an IN list is an ordinary thing to
|
|
136
|
+
// write, and every table with one was seeded with an invented value, refused
|
|
137
|
+
// by its own constraint, and reported as a table that could not be checked.
|
|
138
|
+
//
|
|
139
|
+
// What may sit between the column and the = is spelled out rather than left
|
|
140
|
+
// to a negated class: anything looser reaches across the next AND and hands
|
|
141
|
+
// one column's allowed values to another.
|
|
142
|
+
const anyOf = new RegExp(
|
|
143
|
+
'\\b' + escaped + '\\b\\)?(?:::[a-z ]+)?\\s*=\\s*ANY\\s*\\(+\\s*ARRAY\\[(.*?)\\]',
|
|
144
|
+
'i',
|
|
145
|
+
);
|
|
146
|
+
for (const constraint of table.constraints || []) {
|
|
147
|
+
if (constraint.kind !== 'c') continue;
|
|
148
|
+
const match = anyOf.exec(String(constraint.definition));
|
|
149
|
+
if (!match) continue;
|
|
150
|
+
// Picked out one at a time rather than split on commas, which came apart
|
|
151
|
+
// in the middle of any value that had a comma in it.
|
|
152
|
+
const literals = /'((?:[^']|'')*)'/g;
|
|
153
|
+
let literal;
|
|
154
|
+
while ((literal = literals.exec(match[1])) !== null) {
|
|
155
|
+
found.push(literal[1].split("''").join("'"));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The foreign keys on a table, read out of Postgres's own wording.
|
|
163
|
+
*
|
|
164
|
+
* Every column of the key, not just the first. A key over (org_id, cart_id)
|
|
165
|
+
* used to be read as though it were only org_id: the second column got an
|
|
166
|
+
* invented value, the pair pointed at no row that existed, and the table was
|
|
167
|
+
* reported as one that could not be checked.
|
|
168
|
+
*/
|
|
169
|
+
function foreignKeys(table) {
|
|
170
|
+
const keys = [];
|
|
171
|
+
const unquote = (text) => text.trim().split('"').join('');
|
|
172
|
+
for (const constraint of table.constraints || []) {
|
|
173
|
+
if (constraint.kind !== 'f') continue;
|
|
174
|
+
const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(constraint.definition);
|
|
175
|
+
if (!match) continue;
|
|
176
|
+
keys.push({
|
|
177
|
+
columns: match[1].split(',').map(unquote),
|
|
178
|
+
refTable: unquote(match[2].split('.').pop()),
|
|
179
|
+
refColumns: match[3].split(',').map(unquote),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return keys;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parents before children.
|
|
187
|
+
*
|
|
188
|
+
* Tables come back in alphabetical order, which put `orders` before `profiles`
|
|
189
|
+
* and made every insert fail on the foreign key - on the first real app it was
|
|
190
|
+
* pointed at, because every app has one of these. A cycle is left in whatever
|
|
191
|
+
* order it arrived: it cannot be satisfied anyway, and the table that fails is
|
|
192
|
+
* reported rather than dropped.
|
|
193
|
+
*/
|
|
194
|
+
function dependencyOrder(tables) {
|
|
195
|
+
const byName = new Map(tables.map((t) => [t.name, t]));
|
|
196
|
+
const ordered = [];
|
|
197
|
+
const done = new Set();
|
|
198
|
+
const visiting = new Set();
|
|
199
|
+
|
|
200
|
+
const visit = (table) => {
|
|
201
|
+
if (done.has(table.name) || visiting.has(table.name)) return;
|
|
202
|
+
visiting.add(table.name);
|
|
203
|
+
for (const key of foreignKeys(table)) {
|
|
204
|
+
const parent = byName.get(key.refTable);
|
|
205
|
+
if (parent && parent.name !== table.name) visit(parent);
|
|
206
|
+
}
|
|
207
|
+
visiting.delete(table.name);
|
|
208
|
+
done.add(table.name);
|
|
209
|
+
ordered.push(table);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
for (const table of tables) visit(table);
|
|
213
|
+
return ordered;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One row that is really in the parent table, so a foreign key is satisfied.
|
|
218
|
+
*
|
|
219
|
+
* The whole row, not one column of it. A composite key has to point at a pair
|
|
220
|
+
* that exists together: borrowing each column from a separate row would build
|
|
221
|
+
* a combination the parent never had.
|
|
222
|
+
*/
|
|
223
|
+
async function existingRow(client, schema, key) {
|
|
224
|
+
try {
|
|
225
|
+
const columns = key.refColumns.map((name, i) => quote(name) + ' AS v' + i).join(', ');
|
|
226
|
+
const { rows } = await client.query(
|
|
227
|
+
'SELECT ' + columns + ' FROM ' + quote(schema) + '.' + quote(key.refTable) + ' LIMIT 1',
|
|
228
|
+
);
|
|
229
|
+
if (!rows.length) return null;
|
|
230
|
+
return key.refColumns.map((name, i) => rows[0]['v' + i]);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A row that will actually go in.
|
|
238
|
+
*
|
|
239
|
+
* Owner column set to the person, foreign keys pointing at rows that really
|
|
240
|
+
* exist, every NOT NULL column filled, and anything with a default left to
|
|
241
|
+
* supply its own value.
|
|
242
|
+
*
|
|
243
|
+
* `overrides` is what the Collision attack needs: it forces one column to a
|
|
244
|
+
* chosen value while everything else stays distinct, so when two inserts race
|
|
245
|
+
* they collide on that column and on nothing else. Without it a second unique
|
|
246
|
+
* column elsewhere in the table would refuse the insert, and the refusal would
|
|
247
|
+
* be read as the app defending itself.
|
|
248
|
+
*/
|
|
249
|
+
async function rowFor(client, schema, table, person, distinct, overrides, attempt) {
|
|
250
|
+
const forced = overrides || {};
|
|
251
|
+
const owner = ownerColumn(table);
|
|
252
|
+
const columns = [];
|
|
253
|
+
const values = [];
|
|
254
|
+
|
|
255
|
+
// Every column that takes part in a foreign key, and the value it has to
|
|
256
|
+
// hold. Resolved one key at a time so that all of a composite key's columns
|
|
257
|
+
// come from the same parent row.
|
|
258
|
+
const borrowed = new Map();
|
|
259
|
+
for (const key of foreignKeys(table)) {
|
|
260
|
+
const row = await existingRow(client, schema, key);
|
|
261
|
+
if (!row) continue;
|
|
262
|
+
key.columns.forEach((name, i) => {
|
|
263
|
+
if (!borrowed.has(name)) borrowed.set(name, row[i]);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const partOfKey = new Set();
|
|
267
|
+
for (const key of foreignKeys(table)) key.columns.forEach((name) => partOfKey.add(name));
|
|
268
|
+
|
|
269
|
+
for (const column of table.columns) {
|
|
270
|
+
if (Object.prototype.hasOwnProperty.call(forced, column.name)) {
|
|
271
|
+
columns.push(column.name);
|
|
272
|
+
values.push(forced[column.name]);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (column.name === owner) {
|
|
276
|
+
columns.push(column.name);
|
|
277
|
+
values.push(person);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// A column pointing at another table has to hold something that is
|
|
281
|
+
// actually there, whatever its type would otherwise suggest.
|
|
282
|
+
if (partOfKey.has(column.name)) {
|
|
283
|
+
if (borrowed.has(column.name)) {
|
|
284
|
+
columns.push(column.name);
|
|
285
|
+
values.push(borrowed.get(column.name));
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (column.not_null) throw new Error('nothing to point ' + column.name + ' at');
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
// A generated column computes itself and refuses to be written to at all.
|
|
292
|
+
//
|
|
293
|
+
// Two halves, and only one of them can be observed. An identity column
|
|
294
|
+
// carries no default_expr, so without the identity half the seeder writes
|
|
295
|
+
// to it and Postgres refuses the row - both engines are caught doing it.
|
|
296
|
+
// A stored generated column keeps its expression IN default_expr, so the
|
|
297
|
+
// line below skips it whether or not this one does: removing the generated
|
|
298
|
+
// half changes nothing any fixture could see. It stays because that is a
|
|
299
|
+
// fact about how readColumns fills the shape, not about Postgres, and the
|
|
300
|
+
// day it changes this is the only thing standing in the way.
|
|
301
|
+
if (column.generated || column.identity) continue;
|
|
302
|
+
// Anything with a default can supply its own value.
|
|
303
|
+
if (column.default_expr) continue;
|
|
304
|
+
if (!column.not_null) continue;
|
|
305
|
+
columns.push(column.name);
|
|
306
|
+
// A CHECK that lists what it will accept beats anything invented here.
|
|
307
|
+
const allowed = allowedByCheck(table, column.name);
|
|
308
|
+
values.push(
|
|
309
|
+
allowed.length
|
|
310
|
+
? allowed[Math.min(Math.max(attempt || 0, 0), allowed.length - 1)]
|
|
311
|
+
: valueFor(column, person, distinct, attempt),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { columns: columns, values: values };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Puts a built row in. Separate so the same row can be raced against itself. */
|
|
319
|
+
function insertRow(client, schema, table, row) {
|
|
320
|
+
const where = 'INSERT INTO ' + quote(schema) + '.' + quote(table);
|
|
321
|
+
// A table of nothing but an id and its defaults leaves no columns to name,
|
|
322
|
+
// and "INSERT INTO t () VALUES ()" is a syntax error. Postgres has a spelling
|
|
323
|
+
// for exactly this, and without it every settings and flags table in the
|
|
324
|
+
// world came back as one that could not be checked.
|
|
325
|
+
if (!row.columns.length) return client.query(where + ' DEFAULT VALUES');
|
|
326
|
+
const placeholders = row.values.map((_, i) => '$' + (i + 1));
|
|
327
|
+
return client.query(
|
|
328
|
+
where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')',
|
|
329
|
+
row.values,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// How many differently-shaped values to try before giving up on a table. The
|
|
334
|
+
// rules that reject the first attempt - a domain insisting on an @, a CHECK on
|
|
335
|
+
// a length - cannot be read out of the catalogue, so the only honest way to
|
|
336
|
+
// satisfy them is to offer something else and see.
|
|
337
|
+
const SHAPES_TO_TRY = 5;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Two rows per table: one belonging to each fake person.
|
|
341
|
+
*
|
|
342
|
+
* Inserted as the owner of the schema, deliberately - seeding is not the
|
|
343
|
+
* attack, and a policy that blocked the seed would leave nothing to attack.
|
|
344
|
+
*
|
|
345
|
+
* A table that cannot be seeded is recorded rather than skipped quietly. An
|
|
346
|
+
* empty table reads as a safe table, so silently failing here would report an
|
|
347
|
+
* app as secure because the tool could not get a row into it.
|
|
348
|
+
*/
|
|
349
|
+
async function seed(client, schema, tables) {
|
|
350
|
+
const seeded = [];
|
|
351
|
+
const skipped = [];
|
|
352
|
+
for (const table of dependencyOrder(tables)) {
|
|
353
|
+
const owner = ownerColumn(table);
|
|
354
|
+
|
|
355
|
+
// A table with nobody's name on it still gets a row. Without one, an open
|
|
356
|
+
// door cannot be told from an empty room: a logged-out stranger reads zero
|
|
357
|
+
// rows either way, and the tool reports the app as safe. Settings tables,
|
|
358
|
+
// waitlists and contact forms are exactly this shape, and exactly the ones
|
|
359
|
+
// that get left open.
|
|
360
|
+
const people = owner ? [USER_A, USER_B] : [USER_A];
|
|
361
|
+
|
|
362
|
+
// Each attempt offers a differently-shaped set of values. A table whose
|
|
363
|
+
// rules reject all of them is reported, never quietly passed over.
|
|
364
|
+
let refused = null;
|
|
365
|
+
let landed = false;
|
|
366
|
+
let worked = 0;
|
|
367
|
+
for (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
|
|
368
|
+
try {
|
|
369
|
+
let nth = 0;
|
|
370
|
+
for (const person of people) {
|
|
371
|
+
nth += 1;
|
|
372
|
+
const row = await rowFor(client, schema, table, person, nth, null, attempt);
|
|
373
|
+
await insertRow(client, schema, table.name, row);
|
|
374
|
+
}
|
|
375
|
+
landed = true;
|
|
376
|
+
worked = attempt;
|
|
377
|
+
} catch (err) {
|
|
378
|
+
refused = err.message;
|
|
379
|
+
// A half-seeded table would make the next attempt collide with its own
|
|
380
|
+
// first row, so anything that did go in is taken back out.
|
|
381
|
+
await client
|
|
382
|
+
.query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
|
|
383
|
+
.catch(() => {});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (landed) {
|
|
388
|
+
// The shape that worked is kept: any later attack that has to insert into
|
|
389
|
+
// this table can use the same one instead of rediscovering it, and be
|
|
390
|
+
// sure a refusal is the app defending itself rather than a CHECK it
|
|
391
|
+
// never satisfied.
|
|
392
|
+
seeded.push({ table: table.name, owner: owner, attempt: worked });
|
|
393
|
+
} else {
|
|
394
|
+
// Recorded, never swallowed. The report has to say this table was not
|
|
395
|
+
// checked rather than let an empty table pass for a safe one.
|
|
396
|
+
skipped.push({ table: table.name, why: refused });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return { seeded: seeded, skipped: skipped };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Reads a table the way a request would, as whoever is asking. */
|
|
403
|
+
async function readAs(client, schema, table, role, userId) {
|
|
404
|
+
await client.query('BEGIN');
|
|
405
|
+
try {
|
|
406
|
+
await client.query('SET LOCAL role TO ' + role);
|
|
407
|
+
// A logged-out visitor is not "no claims". Supabase hands PostgREST the
|
|
408
|
+
// anon key, which is itself a JWT, so request.jwt.claims arrives as a real
|
|
409
|
+
// JSON object that simply has no `sub` in it.
|
|
410
|
+
//
|
|
411
|
+
// Sending an empty string instead made auth.uid() throw on the cast, and a
|
|
412
|
+
// read that throws returns no rows - which is exactly what a properly
|
|
413
|
+
// secured table returns. Every table whose policy calls auth.uid(), which
|
|
414
|
+
// is nearly every table anyone writes, came back looking safe without the
|
|
415
|
+
// rule ever being evaluated.
|
|
416
|
+
await client.query('SELECT set_config($1, $2, true)', [
|
|
417
|
+
'request.jwt.claims',
|
|
418
|
+
JSON.stringify(userId ? { sub: userId, role: role } : { role: role }),
|
|
419
|
+
]);
|
|
420
|
+
const result = await client.query('SELECT * FROM ' + quote(schema) + '.' + quote(table));
|
|
421
|
+
return result.rows;
|
|
422
|
+
} catch (err) {
|
|
423
|
+
// A refusal is an answer: the table is not reachable by this caller at all.
|
|
424
|
+
return { blocked: err.message };
|
|
425
|
+
} finally {
|
|
426
|
+
await client.query('ROLLBACK');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function rowsOwnedBy(rows, owner, person) {
|
|
431
|
+
if (!Array.isArray(rows)) return 0;
|
|
432
|
+
return rows.filter((r) => String(r[owner]) === person).length;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A refusal is only an answer when it is the right refusal.
|
|
437
|
+
*
|
|
438
|
+
* "permission denied for table orders" means this caller cannot reach the
|
|
439
|
+
* table at all. That is the attack being defeated, and it is good news worth
|
|
440
|
+
* recording as a pass.
|
|
441
|
+
*
|
|
442
|
+
* Everything else - a schema the policy needs and the role cannot use, a
|
|
443
|
+
* function the policy calls that is not there, a timeout - means the rule was
|
|
444
|
+
* never evaluated. No verdict exists. Both come back as an error and return
|
|
445
|
+
* zero rows, and zero rows is exactly what a perfectly secured table returns,
|
|
446
|
+
* so telling them apart is the difference between "you are safe" and "I could
|
|
447
|
+
* not tell", which is the difference the whole product rests on.
|
|
448
|
+
*/
|
|
449
|
+
function refusalMeans(message) {
|
|
450
|
+
// The multi-word kinds come first. Postgres says "permission denied for
|
|
451
|
+
// materialized view hits", and an alternation that tried `view` first would
|
|
452
|
+
// never reach it - so a matview nobody had granted was filed as untested
|
|
453
|
+
// rather than as the attack being beaten, and a correct app collected a
|
|
454
|
+
// warning it had not earned.
|
|
455
|
+
const denied = /permission denied for (materialized view|foreign table|partitioned table|table|relation|view|sequence)/i;
|
|
456
|
+
return denied.test(String(message)) ? 'unreachable' : 'untested';
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* What each table gives away, and to whom.
|
|
461
|
+
*
|
|
462
|
+
* Two separate findings, because they are two different conversations with the
|
|
463
|
+
* person who has to fix it:
|
|
464
|
+
*
|
|
465
|
+
* exposed - a logged-out stranger can read the table. This is the one that
|
|
466
|
+
* ends up on a news site.
|
|
467
|
+
* crossed - a signed-in customer can read another customer's rows. Quieter,
|
|
468
|
+
* and the one that breaks trust with the people already paying.
|
|
469
|
+
*/
|
|
470
|
+
async function impersonate(client, schema, tables) {
|
|
471
|
+
const findings = [];
|
|
472
|
+
const completed = [];
|
|
473
|
+
const blocked = [];
|
|
474
|
+
|
|
475
|
+
/** Did this read produce a verdict, and if not, why not? */
|
|
476
|
+
const settle = (key, table, answer, as) => {
|
|
477
|
+
if (Array.isArray(answer)) {
|
|
478
|
+
completed.push(key);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
if (refusalMeans(answer.blocked) === 'unreachable') {
|
|
482
|
+
// Refused outright. The attack ran and lost, which is the result we want
|
|
483
|
+
// for a table that is properly closed.
|
|
484
|
+
completed.push(key);
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
blocked.push({ table: table, key: key, why: as + ': ' + answer.blocked });
|
|
488
|
+
return false;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
for (const table of tables) {
|
|
492
|
+
const owner = ownerColumn(table);
|
|
493
|
+
const anon = await readAs(client, schema, table.name, 'anon', null);
|
|
494
|
+
const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
|
|
495
|
+
|
|
496
|
+
if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
|
|
497
|
+
findings.push({
|
|
498
|
+
kind: 'exposed',
|
|
499
|
+
table: table.name,
|
|
500
|
+
readable: anon.length,
|
|
501
|
+
columns: Object.keys(anon[0] || {}),
|
|
502
|
+
rlsEnabled: table.rlsEnabled,
|
|
503
|
+
isView: Boolean(table.isView),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Crossed is only ever looked for where a row says who it belongs to, so
|
|
508
|
+
// on a table with no owner there is no attack to record either way.
|
|
509
|
+
if (owner && settle('crossed:' + table.name, table.name, asA, 'as a signed-in customer')) {
|
|
510
|
+
const theirs = rowsOwnedBy(asA, owner, USER_B);
|
|
511
|
+
if (theirs > 0) {
|
|
512
|
+
findings.push({
|
|
513
|
+
kind: 'crossed',
|
|
514
|
+
table: table.name,
|
|
515
|
+
owner: owner,
|
|
516
|
+
readable: theirs,
|
|
517
|
+
columns: Object.keys(asA[0] || {}),
|
|
518
|
+
rlsEnabled: table.rlsEnabled,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return { findings: findings, completed: completed, blocked: blocked };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** A stable shape for comparing one run against another. */
|
|
528
|
+
function summarise(result) {
|
|
529
|
+
const findings = Array.isArray(result) ? result : (result && result.findings) || [];
|
|
530
|
+
return findings
|
|
531
|
+
.map((f) => f.kind + ':' + f.table + ':' + f.readable)
|
|
532
|
+
.sort()
|
|
533
|
+
.join(' | ');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
module.exports = {
|
|
537
|
+
USER_A: USER_A,
|
|
538
|
+
USER_B: USER_B,
|
|
539
|
+
USER_C: USER_C,
|
|
540
|
+
USER_D: USER_D,
|
|
541
|
+
refusalMeans: refusalMeans,
|
|
542
|
+
ownerColumn: ownerColumn,
|
|
543
|
+
fitTo: fitTo,
|
|
544
|
+
foreignKeys: foreignKeys,
|
|
545
|
+
dependencyOrder: dependencyOrder,
|
|
546
|
+
valueFor: valueFor,
|
|
547
|
+
allowedByCheck: allowedByCheck,
|
|
548
|
+
rowFor: rowFor,
|
|
549
|
+
existingRow: existingRow,
|
|
550
|
+
insertRow: insertRow,
|
|
551
|
+
seed: seed,
|
|
552
|
+
readAs: readAs,
|
|
553
|
+
impersonate: impersonate,
|
|
554
|
+
summarise: summarise,
|
|
555
|
+
};
|