Skip to content

Commit 9258d62

Browse files
committed
feat: validate supabase direct connections
1 parent 01d79ab commit 9258d62

5 files changed

Lines changed: 131 additions & 47 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@ lerna-debug.log*
3737
# Sentry Config File
3838
.sentryclirc
3939
lgtm
40-
sync
40+
/sync

src/server/http.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,24 @@ async function onSync(req: Request) {
2222
try {
2323
body = SyncRequest.parse(JSON.parse(bodyString));
2424
} catch (e: unknown) {
25-
console.log(e);
2625
if (e instanceof ZodError) {
27-
return new Response(e.message, { status: 400 });
26+
return Response.json(
27+
{
28+
kind: "error",
29+
type: "invalid_body",
30+
error: e.issues.map((issue) => issue.message).join("\n"),
31+
},
32+
{ status: 400 }
33+
);
2834
}
29-
return new Response("Invalid body", { status: 400 });
35+
return Response.json(
36+
{
37+
kind: "error",
38+
type: "unknown_error",
39+
error: String(e),
40+
},
41+
{ status: 400 }
42+
);
3043
}
3144
const { seed, schema, requiredRows, maxRows } = body;
3245
const span = trace.getActiveSpan();
@@ -45,21 +58,21 @@ async function onSync(req: Request) {
4558
span?.setAttribute("requiredRows", requiredRows);
4659
span?.setAttribute("db.host", url.hostname);
4760
let dbUrl: URL;
48-
try {
49-
dbUrl = new URL(body.db);
50-
} catch (e: unknown) {
51-
span?.setStatus({ code: SpanStatusCode.ERROR, message: "invalid_db_url" });
52-
if (e instanceof Error) {
53-
return Response.json(
54-
{ kind: "error", type: "invalid_db_url", error: e.message },
55-
{ status: 400 }
56-
);
57-
}
58-
return new Response("Invalid db url", { status: 400 });
59-
}
61+
// try {
62+
// dbUrl = new URL(body.db);
63+
// } catch (e: unknown) {
64+
// span?.setStatus({ code: SpanStatusCode.ERROR, message: "invalid_db_url" });
65+
// if (e instanceof Error) {
66+
// return Response.json(
67+
// { kind: "error", type: "invalid_db_url", error: e.message },
68+
// { status: 400 }
69+
// );
70+
// }
71+
// return new Response("Invalid db url", { status: 400 });
72+
// }
6073
let result: SyncResult;
6174
try {
62-
result = await syncer.syncWithUrl(dbUrl, schema, {
75+
result = await syncer.syncWithUrl(body.db, schema, {
6376
requiredRows,
6477
maxRows,
6578
seed,

src/server/sync.dto.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
11
import { z } from "zod/v4";
2+
import { Connectable } from "../sync/connectable.ts";
23

34
export const SyncRequest = z.object({
4-
// We shouldn't be doing separate validations on both the front end and here? Should probably consolidate
5-
db: z
6-
.string()
7-
.refine(
8-
(val) => val.startsWith("postgres://") || val.startsWith("postgresql://"),
9-
{
10-
message: "Must start with 'postgres://' or 'postgresql://'",
11-
}
12-
),
5+
db: z.string().transform(Connectable.transform),
136
seed: z.coerce.number().min(0).max(1).default(0),
147
schema: z.coerce.string().default("public"),
158
requiredRows: z.coerce.number().positive().default(2),

src/sync/connectable.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { z } from "zod/v4";
2+
import { env } from "../env.ts";
3+
4+
/**
5+
* Represents a valid connection to a database.
6+
* Connectable instances are always pre-validated and don't need to be validated again.
7+
*/
8+
export class Connectable {
9+
private constructor(public readonly url: URL) {}
10+
11+
/**
12+
* Custom logic for parsing a string into a Connectable through zod.
13+
*/
14+
static transform(
15+
urlString: string,
16+
ctx: z.RefinementCtx<string>
17+
): Connectable {
18+
if (
19+
!urlString.startsWith("postgres://") &&
20+
!urlString.startsWith("postgresql://")
21+
) {
22+
ctx.addIssue({
23+
code: "custom",
24+
message: "URL must start with 'postgres://' or 'postgresql://'",
25+
expected: "string",
26+
input: urlString,
27+
});
28+
return z.NEVER;
29+
}
30+
31+
let url: URL;
32+
try {
33+
url = new URL(urlString);
34+
} catch (_: unknown) {
35+
ctx.addIssue({
36+
code: "invalid_type",
37+
message: "Invalid URL",
38+
expected: "string",
39+
input: urlString,
40+
});
41+
return z.NEVER;
42+
}
43+
// we don't want to allow localhost access for hosted sync instances
44+
// to prevent users from connecting to our hosted db
45+
// (even though all our dbs should should be password protected)
46+
const isLocalhost =
47+
url.hostname === "localhost" ||
48+
// ipv4 localhost
49+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(url.hostname) ||
50+
// ipv6 localhost
51+
url.hostname === "[::1]";
52+
if (isLocalhost && env.HOSTED) {
53+
ctx.addIssue({
54+
code: "custom",
55+
message:
56+
"Syncing to localhost is not allowed. Run the sync server locally to access your local database",
57+
});
58+
}
59+
// Our current hosted sync instances do not support ipv6
60+
// we need users to use pooler.supabase.com instead
61+
if (env.HOSTED && Connectable.isDirectSupabaseConnection(url)) {
62+
const account = Connectable.extractSupabaseAccount(url);
63+
// send the user directly to the right place if there's an account available
64+
const link = account
65+
? `https://supabase.com/dashboard/project/${account}?showConnect=true`
66+
: "https://supabase.com/docs/guides/troubleshooting/supabase--your-network-ipv4-and-ipv6-compatibility-cHe3BP";
67+
ctx.addIssue({
68+
code: "custom",
69+
message: `You are using a direct connection to a supabase instance. Supabase does not accept IPv4 connections, try using a transaction pooler connection instead ${link}`,
70+
});
71+
}
72+
if (ctx.issues.length > 0) {
73+
return z.NEVER;
74+
}
75+
return new Connectable(url);
76+
}
77+
78+
private static extractSupabaseAccount(url: URL): string | undefined {
79+
const match = url.toString().match(/db\.(\w+)\.supabase\.co/);
80+
if (!match) {
81+
return;
82+
}
83+
return match[1];
84+
}
85+
86+
private static isDirectSupabaseConnection(url: URL) {
87+
return (
88+
url.hostname.includes("supabase.co") &&
89+
!url.hostname.includes("pooler.supabase.co")
90+
);
91+
}
92+
93+
toString() {
94+
return this.url.toString();
95+
}
96+
}

src/sync/syncer.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { PostgresSchemaLink } from "./schema.ts";
1414
import { withSpan } from "../otel.ts";
1515
import { SpanStatusCode } from "@opentelemetry/api";
16-
import { env } from "../env.ts";
16+
import { Connectable } from "./connectable.ts";
1717

1818
type SyncOptions = DependencyAnalyzerOptions;
1919

@@ -72,29 +72,11 @@ export class PostgresSyncer {
7272
constructor() {}
7373

7474
async syncWithUrl(
75-
url: URL,
75+
connectable: Connectable,
7676
schemaName: string,
7777
options: SyncOptions
7878
): Promise<SyncResult> {
79-
// we don't want to allow localhost access for hosted sync instances
80-
// to prevent users from connecting to our hosted db
81-
// (even though all our dbs should should be password protected)
82-
const isLocalhost =
83-
url.hostname === "localhost" ||
84-
// ipv4 localhost
85-
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(url.hostname) ||
86-
// ipv6 localhost
87-
url.hostname === "[::1]";
88-
if (isLocalhost && env.HOSTED) {
89-
return {
90-
kind: "error",
91-
type: "postgres_connection_error",
92-
error: new Error(
93-
"Syncing to localhost is not allowed. Run the sync server locally to access your local database"
94-
),
95-
};
96-
}
97-
const urlString = url.toString();
79+
const urlString = connectable.toString();
9880
let sql = this.connections.get(urlString);
9981
if (!sql) {
10082
sql = postgres(urlString);

0 commit comments

Comments
 (0)