kryptheon-night 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schema.js +75 -3
- package/trouble.js +56 -0
package/package.json
CHANGED
package/schema.js
CHANGED
|
@@ -499,6 +499,44 @@ function rewriteSchemaRefs(expr, fromSchema, toSchema) {
|
|
|
499
499
|
return String(expr).split(quote(fromSchema) + '.').join(quote(toSchema) + '.').split(fromSchema + '.').join(toSchema + '.');
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Points a reference with no schema on it at the copy.
|
|
504
|
+
*
|
|
505
|
+
* The one the other two rewrites could not see. `pg_get_constraintdef` writes
|
|
506
|
+
* the schema only when the referenced table is *not* reachable through
|
|
507
|
+
* `search_path` - so an app living in `app_something` comes back as
|
|
508
|
+
* "REFERENCES app_something.profiles(id)" and is rewritten, while the same app
|
|
509
|
+
* living in `public` comes back as "REFERENCES profiles(id)" and there is
|
|
510
|
+
* nothing to rewrite. Replayed into the copy, that bare name resolves through
|
|
511
|
+
* `search_path` all over again, and binds to the customer's real table.
|
|
512
|
+
*
|
|
513
|
+
* Found on the first real Supabase project this was ever pointed at, which is
|
|
514
|
+
* the first app that was not in a schema of its own:
|
|
515
|
+
*
|
|
516
|
+
* notes_owner_fkey -> public.profiles (the customer's own table)
|
|
517
|
+
* orders_user_id_fkey -> public.profiles (the customer's own table)
|
|
518
|
+
*
|
|
519
|
+
* Nothing was written to those tables - the copy only pointed at them - but
|
|
520
|
+
* the copy was not the app, which is the same failure three other bugs in this
|
|
521
|
+
* file were. Here it meant every insert into the copy was checked against a
|
|
522
|
+
* table the seeder had put nothing in, so two tables out of four were never
|
|
523
|
+
* attacked and were reported, honestly but uselessly, as not checked.
|
|
524
|
+
*
|
|
525
|
+
* Only names the plan itself owns are touched. A reference to something
|
|
526
|
+
* outside the schema is the stand-in's business, not this one's.
|
|
527
|
+
*/
|
|
528
|
+
function qualifyOwnRefs(definition, plan, target) {
|
|
529
|
+
const own = new Set((plan.tables || []).map((table) => table.name));
|
|
530
|
+
// A replacer function, never a replacement string: `$&` and friends are read
|
|
531
|
+
// as instructions inside one, and that has already cost this project an
|
|
532
|
+
// engine that would not install.
|
|
533
|
+
return String(definition).replace(
|
|
534
|
+
/(\bREFERENCES\s+)("?)([^".\s(]+)\2(\s*\()/gi,
|
|
535
|
+
(whole, before, quoteMark, name, after) =>
|
|
536
|
+
(own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
502
540
|
/**
|
|
503
541
|
* Points a foreign key at the stand-in instead of at the real outside table.
|
|
504
542
|
*
|
|
@@ -669,9 +707,13 @@ async function writeSchema(client, plan, target) {
|
|
|
669
707
|
for (const constraint of table.constraints) {
|
|
670
708
|
const isForeign = constraint.kind === 'f';
|
|
671
709
|
if (isForeign !== wantForeign) continue;
|
|
672
|
-
const definition =
|
|
673
|
-
|
|
674
|
-
|
|
710
|
+
const definition = qualifyOwnRefs(
|
|
711
|
+
rewriteExternalRefs(
|
|
712
|
+
rewriteSchemaRefs(constraint.definition, plan.schema, target),
|
|
713
|
+
plan.external,
|
|
714
|
+
target,
|
|
715
|
+
),
|
|
716
|
+
plan,
|
|
675
717
|
target,
|
|
676
718
|
);
|
|
677
719
|
statements.push(
|
|
@@ -770,6 +812,36 @@ async function writeSchema(client, plan, target) {
|
|
|
770
812
|
for (const statement of statements) {
|
|
771
813
|
await client.query(statement);
|
|
772
814
|
}
|
|
815
|
+
|
|
816
|
+
// And then look at what was actually built, rather than at what was meant.
|
|
817
|
+
//
|
|
818
|
+
// Everything above this line reasons about strings. The guard at the top of
|
|
819
|
+
// this file reads the statements before they run; this one asks Postgres
|
|
820
|
+
// where the copy ended up pointing, which is the only account of it that
|
|
821
|
+
// cannot be fooled by a spelling nobody anticipated - and one was not. A
|
|
822
|
+
// bare "REFERENCES profiles(id)" replayed into the copy bound to the
|
|
823
|
+
// customer's own table, silently, on every app that lives in `public`.
|
|
824
|
+
//
|
|
825
|
+
// Nothing is written to a table outside the copy either way. The damage is
|
|
826
|
+
// subtler than that: a copy tied to the customer's rows is not the app being
|
|
827
|
+
// attacked, and every verdict taken from it is about something else.
|
|
828
|
+
const { rows: strays } = await client.query(
|
|
829
|
+
`SELECT con.conname, rn.nspname AS points_at
|
|
830
|
+
FROM pg_constraint con
|
|
831
|
+
JOIN pg_class cl ON cl.oid = con.conrelid
|
|
832
|
+
JOIN pg_namespace cn ON cn.oid = cl.relnamespace
|
|
833
|
+
JOIN pg_class rc ON rc.oid = con.confrelid
|
|
834
|
+
JOIN pg_namespace rn ON rn.oid = rc.relnamespace
|
|
835
|
+
WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1`,
|
|
836
|
+
[target],
|
|
837
|
+
);
|
|
838
|
+
if (strays.length) {
|
|
839
|
+
throw new Error(
|
|
840
|
+
'the copy points outside itself, so it is not the app: ' +
|
|
841
|
+
strays.map((row) => row.conname + ' -> ' + row.points_at).join(', '),
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
773
845
|
return statements;
|
|
774
846
|
}
|
|
775
847
|
|
package/trouble.js
CHANGED
|
@@ -314,6 +314,34 @@ const BY_SYSCALL = {
|
|
|
314
314
|
],
|
|
315
315
|
};
|
|
316
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Is this Supabase's direct connection, and if so whose?
|
|
319
|
+
*
|
|
320
|
+
* `db.<ref>.supabase.co` is the direct connection. Supabase stopped giving it
|
|
321
|
+
* an IPv4 address, so today it resolves to an AAAA record and nothing else -
|
|
322
|
+
* and on a machine with no IPv6 the name does not resolve at all. The failure
|
|
323
|
+
* that produces is ENOTFOUND: not "unreachable", which would at least hint at
|
|
324
|
+
* the network, but "no such name", which reads as a typo.
|
|
325
|
+
*
|
|
326
|
+
* Measured on a real project: A = ENODATA, AAAA = 2406:da1c:..., and no
|
|
327
|
+
* global IPv6 on the machine. The scan said "there is a typo in the address,
|
|
328
|
+
* or this computer is not online... if your project was paused or deleted",
|
|
329
|
+
* and all three of those were false. That sends somebody to reset a password
|
|
330
|
+
* or rebuild a project that was never broken.
|
|
331
|
+
*
|
|
332
|
+
* The ref is handed back so the pooler string can be spelled out with their
|
|
333
|
+
* own project in it rather than as a shape to fill in.
|
|
334
|
+
*/
|
|
335
|
+
function supabaseDirectRef(raw) {
|
|
336
|
+
try {
|
|
337
|
+
const host = new URL(String(raw).trim().replace(/^["']|["']$/g, '')).hostname;
|
|
338
|
+
const match = /^db\.([a-z0-9]+)\.supabase\.(co|com)$/i.exec(host);
|
|
339
|
+
return match ? match[1] : null;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
317
345
|
/**
|
|
318
346
|
* One failure, in plain English.
|
|
319
347
|
*
|
|
@@ -326,6 +354,33 @@ const BY_SYSCALL = {
|
|
|
326
354
|
function explain(err, raw) {
|
|
327
355
|
const code = err && err.code ? String(err.code) : '';
|
|
328
356
|
|
|
357
|
+
// Checked before the general ENOTFOUND, because for this one address the
|
|
358
|
+
// general answer is wrong in every one of its three guesses.
|
|
359
|
+
const ref = code === 'ENOTFOUND' ? supabaseDirectRef(raw) : null;
|
|
360
|
+
if (ref) {
|
|
361
|
+
return [
|
|
362
|
+
'I could not find that server - and I think I know why.',
|
|
363
|
+
'',
|
|
364
|
+
'That address is Supabase’s direct connection, and it now answers only',
|
|
365
|
+
'over IPv6. Plenty of home and office networks have no IPv6 at all, and on',
|
|
366
|
+
'those the name does not resolve to anything - which is what just',
|
|
367
|
+
'happened here.',
|
|
368
|
+
'',
|
|
369
|
+
'There is nothing wrong with your project or your password. Use the',
|
|
370
|
+
'session pooler instead, which answers over the ordinary internet:',
|
|
371
|
+
'',
|
|
372
|
+
' Supabase dashboard -> Project Settings -> Database ->',
|
|
373
|
+
' Connection string -> URI -> and pick "Session pooler".',
|
|
374
|
+
'',
|
|
375
|
+
'It looks like this, with your password in the middle:',
|
|
376
|
+
'',
|
|
377
|
+
' postgresql://postgres.' + ref + ':PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres',
|
|
378
|
+
'',
|
|
379
|
+
'Take the port 5432 one. The other pooler, on 6543, hands every statement',
|
|
380
|
+
'a different connection and these attacks have to stay on one.',
|
|
381
|
+
];
|
|
382
|
+
}
|
|
383
|
+
|
|
329
384
|
if (BY_CODE[code]) return BY_CODE[code].slice();
|
|
330
385
|
if (BY_SYSCALL[code]) return BY_SYSCALL[code].slice();
|
|
331
386
|
|
|
@@ -372,6 +427,7 @@ module.exports = {
|
|
|
372
427
|
readConnectionString: readConnectionString,
|
|
373
428
|
poolerWarning: poolerWarning,
|
|
374
429
|
explain: explain,
|
|
430
|
+
supabaseDirectRef: supabaseDirectRef,
|
|
375
431
|
withoutSecret: withoutSecret,
|
|
376
432
|
PLACEHOLDERS: PLACEHOLDERS,
|
|
377
433
|
};
|