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/tamper.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Can a stranger change your data?
|
|
2
|
+
//
|
|
3
|
+
// Everything before this asks whether the wrong person can READ. This asks
|
|
4
|
+
// whether they can WRITE, and the answer matters more: a leak is bad, but a
|
|
5
|
+
// stranger who can delete your customers table has taken something you cannot
|
|
6
|
+
// get back.
|
|
7
|
+
//
|
|
8
|
+
// It is the commonest disaster in a no-code backend, and the reason is that
|
|
9
|
+
// nothing looks wrong. Supabase grants anon INSERT, UPDATE and DELETE on the
|
|
10
|
+
// public schema by default. Row level security is what takes those back, and
|
|
11
|
+
// on a table where nobody ever switched it on, a logged-out stranger can empty
|
|
12
|
+
// the table with one request while the dashboard shows nothing at all.
|
|
13
|
+
//
|
|
14
|
+
// EVERYTHING HERE IS ROLLED BACK. Every write runs inside a transaction that
|
|
15
|
+
// is always rolled back, measured afterwards to confirm the table came out as
|
|
16
|
+
// it went in. It runs against the copy, and the copy is thrown away - but the
|
|
17
|
+
// rollback is what makes it safe to reason about at all.
|
|
18
|
+
//
|
|
19
|
+
// WHAT WAS MEASURED FIRST (probe-write.js, against a real database)
|
|
20
|
+
//
|
|
21
|
+
// - no row level security + the default grants: a stranger can insert,
|
|
22
|
+
// rewrite and delete. Confirmed, all three.
|
|
23
|
+
// - a SELECT policy and nothing else: writes are refused. A read rule is
|
|
24
|
+
// genuinely not a write rule, so an app like this must not be reported.
|
|
25
|
+
// - FOR ALL USING (true): a stranger can do all three.
|
|
26
|
+
// - an owner policy: a signed-in customer cannot touch another customer's
|
|
27
|
+
// rows, and Postgres reuses USING as the check for UPDATE, so a row cannot
|
|
28
|
+
// be pushed out of its owner's reach either.
|
|
29
|
+
//
|
|
30
|
+
// A statement that returns without error having changed zero rows is a
|
|
31
|
+
// refusal, not a success - row level security filters rows away rather than
|
|
32
|
+
// complaining. The first version of the probe got that wrong, so every verdict
|
|
33
|
+
// here is taken from the number of rows that actually moved.
|
|
34
|
+
|
|
35
|
+
const { quote } = require('./schema.js');
|
|
36
|
+
const attack = require('./attack.js');
|
|
37
|
+
|
|
38
|
+
/** Who is doing the asking, and what a finding should call them. */
|
|
39
|
+
const ACTORS = [
|
|
40
|
+
{ who: 'anyone', role: 'anon', identity: null },
|
|
41
|
+
{ who: 'any customer', role: 'authenticated', identity: attack.USER_B },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs one write the way a request runs it, and never keeps the result.
|
|
46
|
+
*
|
|
47
|
+
* The rollback is not a tidy-up, it is the safety property. Nothing this
|
|
48
|
+
* attack does survives the statement that did it.
|
|
49
|
+
*/
|
|
50
|
+
async function tryWrite(client, role, identity, statement, values) {
|
|
51
|
+
await client.query('BEGIN');
|
|
52
|
+
try {
|
|
53
|
+
await client.query("SET LOCAL statement_timeout = '15s'");
|
|
54
|
+
await client.query('SET LOCAL role TO ' + role);
|
|
55
|
+
await client.query('SELECT set_config($1, $2, true)', [
|
|
56
|
+
'request.jwt.claims',
|
|
57
|
+
JSON.stringify(identity ? { sub: identity, role: role } : { role: role }),
|
|
58
|
+
]);
|
|
59
|
+
const result = await client.query(statement, values || []);
|
|
60
|
+
return { ok: true, count: result.rowCount };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { ok: false, count: 0, why: err.message };
|
|
63
|
+
} finally {
|
|
64
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a write actually achieved.
|
|
70
|
+
*
|
|
71
|
+
* Three outcomes, and the middle one is the one that matters: a statement can
|
|
72
|
+
* succeed and change nothing, which is row level security doing its job.
|
|
73
|
+
*/
|
|
74
|
+
function whatHappened(result) {
|
|
75
|
+
if (result.ok) return result.count > 0 ? 'got through' : 'refused';
|
|
76
|
+
// A WITH CHECK turning a write away IS the app defending itself, and it is
|
|
77
|
+
// the single most common way a correct app says no. Reading it as "I could
|
|
78
|
+
// not tell" put a warning on every table that had got it right, and a
|
|
79
|
+
// warning nobody earned is how a report stops being read.
|
|
80
|
+
if (/violates row-level security policy/i.test(String(result.why))) return 'refused';
|
|
81
|
+
return attack.refusalMeans(result.why) === 'unreachable' ? 'refused' : 'untested';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Every way in, per table, per kind of caller.
|
|
86
|
+
*
|
|
87
|
+
* `seeded` carries the shape of row that worked when the table was seeded, so
|
|
88
|
+
* the insert here is not rejected by some CHECK the seeder already solved.
|
|
89
|
+
*/
|
|
90
|
+
async function tamper(client, schema, tables, seeded, policies) {
|
|
91
|
+
const findings = [];
|
|
92
|
+
const completed = [];
|
|
93
|
+
const blocked = [];
|
|
94
|
+
const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
|
|
95
|
+
|
|
96
|
+
// Which rules each table actually has, so the report can say why a write got
|
|
97
|
+
// through instead of assuming. Without this the report told somebody their
|
|
98
|
+
// rule "covers every command rather than only reading" about a policy
|
|
99
|
+
// written FOR INSERT - the finding was true and the reason was invented,
|
|
100
|
+
// which is the one thing a report cannot do twice.
|
|
101
|
+
//
|
|
102
|
+
// Permissive only. A restrictive policy can narrow what is allowed and
|
|
103
|
+
// never open it, so it is never the reason a write succeeded.
|
|
104
|
+
const rulesFor = new Map();
|
|
105
|
+
for (const policy of policies || []) {
|
|
106
|
+
if (String(policy.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
|
|
107
|
+
const list = rulesFor.get(policy.table_name) || [];
|
|
108
|
+
const cmd = String(policy.cmd || 'ALL').toUpperCase();
|
|
109
|
+
if (!list.includes(cmd)) list.push(cmd);
|
|
110
|
+
rulesFor.set(policy.table_name, list);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const table of tables) {
|
|
114
|
+
if (!shapeFor.has(table.name)) continue; // never seeded, so nothing to protect
|
|
115
|
+
const owner = attack.ownerColumn(table);
|
|
116
|
+
|
|
117
|
+
for (const actor of ACTORS) {
|
|
118
|
+
const key = 'writable:' + table.name + ':' + actor.who;
|
|
119
|
+
const can = [];
|
|
120
|
+
const changed = {};
|
|
121
|
+
let stuck = null;
|
|
122
|
+
|
|
123
|
+
// Rows belonging to the other fake person. Scoped on purpose: a signed-in
|
|
124
|
+
// customer deleting their OWN rows is not a finding, it is the feature,
|
|
125
|
+
// and an unscoped DELETE would report every correctly built app.
|
|
126
|
+
const theirRows = owner
|
|
127
|
+
? ' WHERE ' + quote(owner) + ' = ' + "'" + attack.USER_A + "'"
|
|
128
|
+
: '';
|
|
129
|
+
const at = quote(schema) + '.' + quote(table.name);
|
|
130
|
+
|
|
131
|
+
// Written under a name nobody has used, which is both what makes the
|
|
132
|
+
// row land at all and what makes it the right test: adding a row of
|
|
133
|
+
// your own is the feature, adding one under somebody else name is not.
|
|
134
|
+
const row = await attack.rowFor(client, schema, table, attack.USER_C, 7, null, shapeFor.get(table.name));
|
|
135
|
+
const placeholders = row.values.map((_, i) => '$' + (i + 1));
|
|
136
|
+
const attempts = [
|
|
137
|
+
row.columns.length
|
|
138
|
+
? {
|
|
139
|
+
what: 'add',
|
|
140
|
+
statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
141
|
+
placeholders.join(', ') + ')',
|
|
142
|
+
values: row.values,
|
|
143
|
+
}
|
|
144
|
+
: { what: 'add', statement: 'INSERT INTO ' + at + ' DEFAULT VALUES', values: [] },
|
|
145
|
+
{ what: 'change', statement: 'UPDATE ' + at + ' SET ' + quote(firstWritable(table)) + ' = ' +
|
|
146
|
+
quote(firstWritable(table)) + theirRows, values: [] },
|
|
147
|
+
{ what: 'delete', statement: 'DELETE FROM ' + at + theirRows, values: [] },
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
for (const move of attempts) {
|
|
151
|
+
if (!move.statement) continue;
|
|
152
|
+
const result = await tryWrite(client, actor.role, actor.identity, move.statement, move.values);
|
|
153
|
+
const outcome = whatHappened(result);
|
|
154
|
+
if (outcome === 'got through') {
|
|
155
|
+
can.push(move.what);
|
|
156
|
+
changed[move.what] = result.count;
|
|
157
|
+
} else if (outcome === 'untested') {
|
|
158
|
+
stuck = result.why;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (stuck) {
|
|
163
|
+
// Something went wrong that was not the app defending itself, so no
|
|
164
|
+
// verdict exists for this table and saying nothing would read as safe.
|
|
165
|
+
blocked.push({ table: table.name, key: key, why: 'as ' + actor.who + ': ' + stuck });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
completed.push(key);
|
|
170
|
+
if (can.length) {
|
|
171
|
+
findings.push({
|
|
172
|
+
kind: 'writable',
|
|
173
|
+
table: table.name,
|
|
174
|
+
who: actor.who,
|
|
175
|
+
can: can,
|
|
176
|
+
changed: changed,
|
|
177
|
+
owner: owner,
|
|
178
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
179
|
+
rlsEnabled: table.rlsEnabled,
|
|
180
|
+
rules: rulesFor.get(table.name) || [],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { findings: findings, completed: completed, blocked: blocked };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A column an UPDATE can harmlessly set to itself.
|
|
191
|
+
*
|
|
192
|
+
* Setting a column to its own value changes nothing about the row while still
|
|
193
|
+
* proving the write was allowed - so the finding is real and the data is not
|
|
194
|
+
* even momentarily wrong inside the transaction that gets rolled back.
|
|
195
|
+
*/
|
|
196
|
+
function firstWritable(table) {
|
|
197
|
+
const usable = (table.columns || []).filter((column) => !column.generated && !column.identity);
|
|
198
|
+
const plain = usable.find((column) => !/^(id)$/i.test(column.name));
|
|
199
|
+
return (plain || usable[0] || { name: 'id' }).name;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
ACTORS: ACTORS,
|
|
204
|
+
tryWrite: tryWrite,
|
|
205
|
+
whatHappened: whatHappened,
|
|
206
|
+
firstWritable: firstWritable,
|
|
207
|
+
tamper: tamper,
|
|
208
|
+
};
|
package/trouble.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Everything that can go wrong before a single attack runs, said in words a
|
|
2
|
+
// person who has never opened a terminal can act on.
|
|
3
|
+
//
|
|
4
|
+
// The person this is for clicked "Deploy" in Lovable and has a database
|
|
5
|
+
// because Supabase gave them one. They did not choose Postgres, they do not
|
|
6
|
+
// know what a connection string is, and they will never read a stack trace.
|
|
7
|
+
// A message like
|
|
8
|
+
//
|
|
9
|
+
// Error: getaddrinfo ENOTFOUND db.abcdefgh.supabase.co
|
|
10
|
+
// at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:120:26)
|
|
11
|
+
//
|
|
12
|
+
// tells them only that the tool is not for them. Every branch below turns one
|
|
13
|
+
// of those into a sentence that names what is wrong and what to do next.
|
|
14
|
+
//
|
|
15
|
+
// Two rules hold this file together:
|
|
16
|
+
//
|
|
17
|
+
// 1. Never guess out loud. If the cause cannot be told apart from another
|
|
18
|
+
// cause, say both, rather than picking the likelier one confidently.
|
|
19
|
+
// 2. Never print the connection string back. It carries the password to
|
|
20
|
+
// the whole database, and a person pasting a report into a chat window
|
|
21
|
+
// would paste the credential with it.
|
|
22
|
+
|
|
23
|
+
/** The bits of a connection string that are safe to show somebody. */
|
|
24
|
+
function withoutSecret(text) {
|
|
25
|
+
// The password lives between the first colon after the scheme and the @.
|
|
26
|
+
// Anything else in the string - host, port, database, user - is not a
|
|
27
|
+
// secret and is exactly what the person needs to see to spot their typo.
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(text);
|
|
30
|
+
const user = url.username ? url.username + '@' : '';
|
|
31
|
+
return url.protocol + '//' + user + url.host + url.pathname;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return '(unreadable)';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The placeholder Supabase puts in the string it shows you. Copying the line
|
|
38
|
+
// without replacing this is the single most common first mistake, and the
|
|
39
|
+
// error Postgres gives back for it is "password authentication failed",
|
|
40
|
+
// which sends people off changing their password instead of reading the line
|
|
41
|
+
// they pasted.
|
|
42
|
+
const PLACEHOLDERS = [
|
|
43
|
+
'[YOUR-PASSWORD]',
|
|
44
|
+
'[YOUR_PASSWORD]',
|
|
45
|
+
'[PASSWORD]',
|
|
46
|
+
'[DB-PASSWORD]',
|
|
47
|
+
'YOUR-PASSWORD',
|
|
48
|
+
'[your-password]',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Is this a connection string at all?
|
|
53
|
+
*
|
|
54
|
+
* Checked before connecting, because the failures here are the ones a person
|
|
55
|
+
* can fix in five seconds if told plainly, and the ones Postgres describes
|
|
56
|
+
* worst if they are allowed to reach it.
|
|
57
|
+
*
|
|
58
|
+
* Returns null when the string is usable, or the lines to print when it is
|
|
59
|
+
* not.
|
|
60
|
+
*/
|
|
61
|
+
function readConnectionString(raw) {
|
|
62
|
+
const text = String(raw == null ? '' : raw).trim();
|
|
63
|
+
|
|
64
|
+
if (!text) {
|
|
65
|
+
return [
|
|
66
|
+
'I did not get a connection string.',
|
|
67
|
+
'',
|
|
68
|
+
'It is the line Supabase shows under Project Settings -> Database ->',
|
|
69
|
+
'Connection string. It starts with postgresql://',
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A wrapping pair of quotes survives a copy out of a code block and makes
|
|
74
|
+
// the URL unparseable for a reason nobody would ever guess from the error.
|
|
75
|
+
const unquoted = text.replace(/^["']|["']$/g, '');
|
|
76
|
+
|
|
77
|
+
for (const placeholder of PLACEHOLDERS) {
|
|
78
|
+
if (unquoted.includes(placeholder)) {
|
|
79
|
+
return [
|
|
80
|
+
'That string still has ' + placeholder + ' in it.',
|
|
81
|
+
'',
|
|
82
|
+
'Supabase shows you the line with a blank left in it for your database',
|
|
83
|
+
'password. Replace ' + placeholder + ' - square brackets and all - with',
|
|
84
|
+
'the password, then paste it again.',
|
|
85
|
+
'',
|
|
86
|
+
'If you do not know the password: Supabase dashboard -> Project Settings',
|
|
87
|
+
'-> Database -> Database password -> Reset database password. Resetting it',
|
|
88
|
+
'will break anything already using the old one, so check first.',
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (/^https?:\/\//i.test(unquoted)) {
|
|
94
|
+
return [
|
|
95
|
+
'That is a web address, not a database connection string.',
|
|
96
|
+
'',
|
|
97
|
+
'The one beginning https://...supabase.co is your project URL - the one',
|
|
98
|
+
'your app uses through the Supabase library. I need the database itself.',
|
|
99
|
+
'',
|
|
100
|
+
'Supabase dashboard -> Project Settings -> Database -> Connection string',
|
|
101
|
+
'-> URI. It begins postgresql:// and has a password in it.',
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The anon key and the service role key are both JWTs, and both get pasted
|
|
106
|
+
// here. The service_role key is a far more dangerous thing to have in a
|
|
107
|
+
// clipboard than the one being asked for, so this says so.
|
|
108
|
+
if (/^eyJ[A-Za-z0-9_-]+\./.test(unquoted)) {
|
|
109
|
+
return [
|
|
110
|
+
'That is one of your API keys, not a database connection string.',
|
|
111
|
+
'',
|
|
112
|
+
'If it was the service_role key, treat it as leaked now that it has been',
|
|
113
|
+
'in a terminal: Supabase dashboard -> Project Settings -> API -> Rotate.',
|
|
114
|
+
'',
|
|
115
|
+
'What I need is under Project Settings -> Database -> Connection string',
|
|
116
|
+
'-> URI. It begins postgresql://',
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let url;
|
|
121
|
+
try {
|
|
122
|
+
url = new URL(unquoted);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return [
|
|
125
|
+
'I could not read that as a connection string.',
|
|
126
|
+
'',
|
|
127
|
+
'It should be one line, no spaces, beginning postgresql:// - like',
|
|
128
|
+
'postgresql://postgres:PASSWORD@db.something.supabase.co:5432/postgres',
|
|
129
|
+
'',
|
|
130
|
+
'If you copied it out of a web page, check that the whole line came with',
|
|
131
|
+
'it and that it did not get broken in half.',
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (url.protocol !== 'postgres:' && url.protocol !== 'postgresql:') {
|
|
136
|
+
return [
|
|
137
|
+
'That does not look like a Postgres connection string.',
|
|
138
|
+
'',
|
|
139
|
+
'It begins "' + url.protocol + '//" and I need one beginning',
|
|
140
|
+
'"postgresql://". Supabase dashboard -> Project Settings -> Database ->',
|
|
141
|
+
'Connection string -> URI.',
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!url.hostname) {
|
|
146
|
+
return [
|
|
147
|
+
'That connection string has no server name in it.',
|
|
148
|
+
'',
|
|
149
|
+
'There should be a name between the @ and the : near the end, like',
|
|
150
|
+
'@db.something.supabase.co:5432. Copy the whole line again.',
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!url.password) {
|
|
155
|
+
return [
|
|
156
|
+
'That connection string has no password in it.',
|
|
157
|
+
'',
|
|
158
|
+
'The password goes between the colon and the @, like',
|
|
159
|
+
'postgresql://postgres:PASSWORD@db.something.supabase.co:5432/postgres',
|
|
160
|
+
'',
|
|
161
|
+
'Supabase leaves a blank there when it shows you the line, so it has to',
|
|
162
|
+
'be filled in before it will work.',
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Supabase hands out three connection strings and two of them will not do.
|
|
171
|
+
*
|
|
172
|
+
* This is a warning rather than a refusal: the shapes are recognised by their
|
|
173
|
+
* host names, those names have changed before, and refusing to run on a
|
|
174
|
+
* string that would have worked is worse than saying something and trying.
|
|
175
|
+
*/
|
|
176
|
+
function poolerWarning(raw) {
|
|
177
|
+
let url;
|
|
178
|
+
try {
|
|
179
|
+
url = new URL(String(raw).trim().replace(/^["']|["']$/g, ''));
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The transaction pooler hands a different backend to every statement.
|
|
185
|
+
// The whole attack is "become another role inside one transaction and try
|
|
186
|
+
// to read", which needs the statements to stay together on one connection.
|
|
187
|
+
if (url.port === '6543') {
|
|
188
|
+
return [
|
|
189
|
+
'That is the transaction pooler (port 6543). It gives every statement a',
|
|
190
|
+
'different connection, and these attacks have to stay on one.',
|
|
191
|
+
'',
|
|
192
|
+
'Use the session pooler or the direct connection instead - same page in',
|
|
193
|
+
'Supabase, the one whose port is 5432.',
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Postgres says why it refused in a five character code. The message beside
|
|
201
|
+
// it is written for whoever is holding the database, not for whoever is
|
|
202
|
+
// holding the app, so the code is what gets read here and the message is not.
|
|
203
|
+
const BY_CODE = {
|
|
204
|
+
// password authentication failed
|
|
205
|
+
'28P01': [
|
|
206
|
+
'The server answered, but the password was wrong.',
|
|
207
|
+
'',
|
|
208
|
+
'Everything else in the string was right - the address found a real',
|
|
209
|
+
'database. It is only the part between the colon and the @.',
|
|
210
|
+
'',
|
|
211
|
+
'Supabase dashboard -> Project Settings -> Database -> Database password.',
|
|
212
|
+
'If you never set one, reset it there and paste the new one here.',
|
|
213
|
+
],
|
|
214
|
+
// invalid_authorization_specification - usually the user name
|
|
215
|
+
'28000': [
|
|
216
|
+
'The server answered, but it would not let that user in.',
|
|
217
|
+
'',
|
|
218
|
+
'Check the name between // and the colon. For Supabase it is "postgres"',
|
|
219
|
+
'on a direct connection, and "postgres.something" on a pooled one - the',
|
|
220
|
+
'two are not interchangeable.',
|
|
221
|
+
],
|
|
222
|
+
// invalid_catalog_name
|
|
223
|
+
'3D000': [
|
|
224
|
+
'The server answered, but there is no database by that name on it.',
|
|
225
|
+
'',
|
|
226
|
+
'That is the word after the last slash. On Supabase it is "postgres" -',
|
|
227
|
+
'not the name of your project.',
|
|
228
|
+
],
|
|
229
|
+
// insufficient_privilege
|
|
230
|
+
'42501': [
|
|
231
|
+
'I connected, but this user is not allowed to do what I need.',
|
|
232
|
+
'',
|
|
233
|
+
'I have to create one temporary schema, copy the shape of your tables',
|
|
234
|
+
'into it, and delete it again. This user cannot create a schema.',
|
|
235
|
+
'',
|
|
236
|
+
'Use the connection string from Project Settings -> Database rather than',
|
|
237
|
+
'one you made yourself - that one is the owner and can.',
|
|
238
|
+
],
|
|
239
|
+
// too_many_connections
|
|
240
|
+
'53300': [
|
|
241
|
+
'Your database is already at its connection limit, so it turned me away.',
|
|
242
|
+
'',
|
|
243
|
+
'This is about how busy the database is, not about anything being wrong',
|
|
244
|
+
'with it. Close anything else that is connected - a SQL editor tab, a',
|
|
245
|
+
'running app - and try again in a minute.',
|
|
246
|
+
],
|
|
247
|
+
// cannot_connect_now - the server is starting up
|
|
248
|
+
'57P03': [
|
|
249
|
+
'The database is awake but not ready yet.',
|
|
250
|
+
'',
|
|
251
|
+
'Free Supabase projects go to sleep when nobody uses them, and take a few',
|
|
252
|
+
'seconds to come back. Try again in half a minute.',
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Node's own failures, which happen before Postgres has said anything at all.
|
|
257
|
+
const BY_SYSCALL = {
|
|
258
|
+
ENOTFOUND: [
|
|
259
|
+
'I could not find that server.',
|
|
260
|
+
'',
|
|
261
|
+
'Either there is a typo in the address, or this computer is not online.',
|
|
262
|
+
'',
|
|
263
|
+
'The address is the part between the @ and the : near the end. Compare it',
|
|
264
|
+
'with the one in Supabase -> Project Settings -> Database. If your project',
|
|
265
|
+
'was paused or deleted, its address stops existing too.',
|
|
266
|
+
],
|
|
267
|
+
ECONNREFUSED: [
|
|
268
|
+
'That server is there, but nothing is listening on that port.',
|
|
269
|
+
'',
|
|
270
|
+
'The port is the number after the last colon, before the slash. Supabase',
|
|
271
|
+
'uses 5432 for a direct or session connection.',
|
|
272
|
+
],
|
|
273
|
+
ETIMEDOUT: [
|
|
274
|
+
'I reached the network but the database never answered.',
|
|
275
|
+
'',
|
|
276
|
+
'This is almost always a firewall between you and it - office wifi, a',
|
|
277
|
+
'company laptop, or a VPN. Databases talk on port 5432 and many networks',
|
|
278
|
+
'block it outright.',
|
|
279
|
+
'',
|
|
280
|
+
'Try again on a home connection or a phone hotspot. Nothing is wrong with',
|
|
281
|
+
'your app.',
|
|
282
|
+
],
|
|
283
|
+
EHOSTUNREACH: [
|
|
284
|
+
'This computer has no route to that server.',
|
|
285
|
+
'',
|
|
286
|
+
'Supabase direct connections answer on IPv6 only, and a lot of home and',
|
|
287
|
+
'office networks have no IPv6 at all. The session pooler answers on IPv4.',
|
|
288
|
+
'',
|
|
289
|
+
'Supabase dashboard -> Project Settings -> Database -> Connection string,',
|
|
290
|
+
'and take the "Session pooler" one instead.',
|
|
291
|
+
],
|
|
292
|
+
ENETUNREACH: [
|
|
293
|
+
'This computer has no route to that server.',
|
|
294
|
+
'',
|
|
295
|
+
'Supabase direct connections answer on IPv6 only, and a lot of home and',
|
|
296
|
+
'office networks have no IPv6 at all. The session pooler answers on IPv4.',
|
|
297
|
+
'',
|
|
298
|
+
'Supabase dashboard -> Project Settings -> Database -> Connection string,',
|
|
299
|
+
'and take the "Session pooler" one instead.',
|
|
300
|
+
],
|
|
301
|
+
// Not an SSL problem, whatever it looks like: this command turns encryption
|
|
302
|
+
// on for every connection that is not to this machine. An earlier draft of
|
|
303
|
+
// this message told people to add ?sslmode=require, which would have made
|
|
304
|
+
// things worse - the driver reads that as full certificate verification and
|
|
305
|
+
// refuses Supabase's own certificate authority outright.
|
|
306
|
+
ECONNRESET: [
|
|
307
|
+
'The connection was cut while I was opening it.',
|
|
308
|
+
'',
|
|
309
|
+
'Usually this is the network rather than the database - a VPN, a captive',
|
|
310
|
+
'wifi portal, or a free Supabase project waking up.',
|
|
311
|
+
'',
|
|
312
|
+
'Try it again. If it happens twice in a row, try it on a different',
|
|
313
|
+
'network, such as a phone hotspot.',
|
|
314
|
+
],
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* One failure, in plain English.
|
|
319
|
+
*
|
|
320
|
+
* Always returns something. A branch that has not been written yet is worse
|
|
321
|
+
* than a generic sentence, but a generic sentence with the original message
|
|
322
|
+
* kept underneath is not a dead end - somebody can search for it. What never
|
|
323
|
+
* survives is the stack: the person cannot use it and it makes the tool look
|
|
324
|
+
* like it broke rather than like it has something to tell them.
|
|
325
|
+
*/
|
|
326
|
+
function explain(err, raw) {
|
|
327
|
+
const code = err && err.code ? String(err.code) : '';
|
|
328
|
+
|
|
329
|
+
if (BY_CODE[code]) return BY_CODE[code].slice();
|
|
330
|
+
if (BY_SYSCALL[code]) return BY_SYSCALL[code].slice();
|
|
331
|
+
|
|
332
|
+
const message = err && err.message ? String(err.message) : String(err);
|
|
333
|
+
|
|
334
|
+
// Certificate failures arrive with several different codes depending on
|
|
335
|
+
// which part of the chain gave up, so they are recognised by their wording.
|
|
336
|
+
if (/self[- ]signed certificate|unable to verify|CERT_|certificate/i.test(message)) {
|
|
337
|
+
return [
|
|
338
|
+
'The database offered a security certificate I could not check.',
|
|
339
|
+
'',
|
|
340
|
+
'This is normal for some hosts and not for others, so I stopped rather',
|
|
341
|
+
'than trusting it quietly.',
|
|
342
|
+
'',
|
|
343
|
+
'If this is your own database and you expected it, add ?sslmode=no-verify',
|
|
344
|
+
'to the end of the connection string.',
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (/timeout|timed out/i.test(message)) {
|
|
349
|
+
return BY_SYSCALL.ETIMEDOUT.slice();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (/password|authentication/i.test(message)) {
|
|
353
|
+
return BY_CODE['28P01'].slice();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Nothing matched. Say what happened and where, without pretending to know
|
|
357
|
+
// why, and without the stack.
|
|
358
|
+
const lines = [
|
|
359
|
+
'I could not connect to the database.',
|
|
360
|
+
'',
|
|
361
|
+
'The server said: ' + message,
|
|
362
|
+
];
|
|
363
|
+
if (raw) {
|
|
364
|
+
lines.push('');
|
|
365
|
+
lines.push('I was trying to reach: ' + withoutSecret(raw));
|
|
366
|
+
lines.push('(your password is not shown, and was not written anywhere)');
|
|
367
|
+
}
|
|
368
|
+
return lines;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
readConnectionString: readConnectionString,
|
|
373
|
+
poolerWarning: poolerWarning,
|
|
374
|
+
explain: explain,
|
|
375
|
+
withoutSecret: withoutSecret,
|
|
376
|
+
PLACEHOLDERS: PLACEHOLDERS,
|
|
377
|
+
};
|