Skip to content

Commit 5d03b16

Browse files
committed
Merge branch 'fix/supabase-checkup-issues' into 'main'
fix(cli): pick http/https transport from URL protocol See merge request postgres-ai/postgresai!241
2 parents 5f65050 + 85a1dc3 commit 5d03b16

4 files changed

Lines changed: 119 additions & 10 deletions

File tree

.pre-commit-config.yaml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,15 @@ repos:
2626

2727
- repo: local
2828
hooks:
29-
# Catch potential SQL injection patterns in TypeScript
29+
# Catch potential SQL injection patterns in TypeScript.
30+
# NOTE: `entry` MUST be a literal block scalar (|-). A plain YAML scalar
31+
# breaks parsing because the bash command contains `: ` (e.g. in the
32+
# "ERROR: Possible ..." echo), which YAML interprets as a mapping
33+
# separator inside the value. Don't "simplify" back to a plain scalar.
3034
- id: sql-injection-check
3135
name: SQL injection check (TypeScript)
32-
entry: bash -c 'grep -rn --include="*.ts" -E "(\`SELECT|\`INSERT|\`UPDATE|\`DELETE|\`DROP|\`ALTER|\`CREATE).*\$\{" "$@" && echo "ERROR: Possible SQL injection — use parameterized queries" && exit 1 || exit 0' --
36+
entry: |-
37+
bash -c 'grep -rn --include="*.ts" -E "(\`SELECT|\`INSERT|\`UPDATE|\`DELETE|\`DROP|\`ALTER|\`CREATE).*\$\{" "$@" && echo "ERROR: Possible SQL injection — use parameterized queries" && exit 1 || exit 0' --
3338
language: system
3439
files: '\.(ts|js)$'
3540
exclude: '(test|spec|__tests__)'

cli/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ coverage/
66
# Auto-generated at build time (do not commit)
77
lib/metrics-embedded.ts
88
lib/checkup-dictionary-embedded.ts
9+
10+
# Local-dev tarballs from `npm pack` (consumed by credential-service Dockerfile.dev)
11+
*.tgz

cli/lib/checkup-api.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as http from "http";
12
import * as https from "https";
23
import { URL } from "url";
34
import { normalizeBaseUrl } from "./util";
@@ -27,15 +28,15 @@ function isRetryableError(err: unknown): boolean {
2728
// Retry on server errors (5xx), not on client errors (4xx)
2829
return err.statusCode >= 500 && err.statusCode < 600;
2930
}
30-
31+
3132
// Check for Node.js error codes (works on Error and Error-like objects)
3233
if (typeof err === "object" && err !== null && "code" in err) {
3334
const code = String((err as { code: unknown }).code);
3435
if (["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT"].includes(code)) {
3536
return true;
3637
}
3738
}
38-
39+
3940
if (err instanceof Error) {
4041
const msg = err.message.toLowerCase();
4142
// Retry on network-related errors based on message content
@@ -49,7 +50,7 @@ function isRetryableError(err: unknown): boolean {
4950
msg.includes("network")
5051
);
5152
}
52-
53+
5354
return false;
5455
}
5556

@@ -226,7 +227,25 @@ async function postRpc<T>(params: {
226227
resolve(value);
227228
};
228229

229-
const req = https.request(
230+
// Transport is picked from the URL protocol so the CLI can talk to a
231+
// local-dev PostgREST over plain HTTP. Production URLs are always HTTPS;
232+
// to guard against typos (e.g. a missing 's' in 'https://') silently
233+
// leaking the API key in cleartext, refuse HTTP to non-loopback hosts
234+
// unless the operator explicitly opts in via CHECKUP_ALLOW_HTTP=1.
235+
if (url.protocol === "http:") {
236+
// WHATWG URL keeps IPv6 literals bracketed in .hostname
237+
// (e.g. `[::1]`), so strip the brackets before matching the allowlist.
238+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
239+
const isLoopback = ["localhost", "127.0.0.1", "::1"].includes(hostname);
240+
if (!isLoopback && process.env.CHECKUP_ALLOW_HTTP !== "1") {
241+
throw new Error(
242+
`Refusing to send API key over plaintext HTTP to '${url.host}'. ` +
243+
`Use https://, a loopback hostname, or set CHECKUP_ALLOW_HTTP=1.`
244+
);
245+
}
246+
}
247+
const transport = url.protocol === "http:" ? http : https;
248+
const req = transport.request(
230249
url,
231250
{
232251
method: "POST",
@@ -277,7 +296,7 @@ async function postRpc<T>(params: {
277296
req.destroy(); // Backup: ensure request is terminated
278297
settledReject(new Error(`RPC ${rpcName} timed out after ${timeoutMs}ms (no response)`));
279298
}, timeoutMs);
280-
299+
281300
req.on("error", (err: Error) => {
282301
// Handle abort as timeout (may already be rejected by timeout handler)
283302
if (err.name === "AbortError" || (err as any).code === "ABORT_ERR") {
@@ -295,7 +314,7 @@ async function postRpc<T>(params: {
295314
settledReject(err);
296315
}
297316
});
298-
317+
299318
req.write(body);
300319
req.end();
301320
});

cli/test/checkup.test.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,90 @@ describe("checkup-api", () => {
12901290
}
12911291
expect(attempts).toBe(2); // Should retry on ECONNRESET
12921292
});
1293+
1294+
// Transport selection — pick http/https by URL protocol, but refuse HTTP
1295+
// to non-loopback hosts unless CHECKUP_ALLOW_HTTP=1 is set (prevents
1296+
// typo-driven plaintext API-key leaks like http://api.postgres.ai/...).
1297+
describe("transport selection", () => {
1298+
test("https URL does not trip the guard (network error expected)", async () => {
1299+
let caught: Error | null = null;
1300+
try {
1301+
await api.createCheckupReport({
1302+
apiKey: "dummy",
1303+
apiBaseUrl: "https://127.0.0.1:1/api", // port 1 — connect refused
1304+
project: "p",
1305+
});
1306+
} catch (e) {
1307+
caught = e as Error;
1308+
}
1309+
expect(caught).not.toBeNull();
1310+
expect(caught!.message).not.toMatch(/Refusing to send API key/);
1311+
});
1312+
1313+
test("http on loopback does not trip the guard (network error expected)", async () => {
1314+
// IPv6 loopback is written as `[::1]` in URLs; WHATWG URL preserves
1315+
// the brackets in .hostname, so the guard must strip them before
1316+
// matching the allowlist.
1317+
for (const host of ["localhost", "127.0.0.1", "[::1]"]) {
1318+
let caught: Error | null = null;
1319+
try {
1320+
await api.createCheckupReport({
1321+
apiKey: "dummy",
1322+
apiBaseUrl: `http://${host}:1/api`, // port 1 — connect refused
1323+
project: "p",
1324+
});
1325+
} catch (e) {
1326+
caught = e as Error;
1327+
}
1328+
expect(caught).not.toBeNull();
1329+
expect(caught!.message).not.toMatch(/Refusing to send API key/);
1330+
}
1331+
});
1332+
1333+
test("http to non-loopback host is refused by the guard", async () => {
1334+
const saved = process.env.CHECKUP_ALLOW_HTTP;
1335+
delete process.env.CHECKUP_ALLOW_HTTP;
1336+
try {
1337+
let caught: Error | null = null;
1338+
try {
1339+
await api.createCheckupReport({
1340+
apiKey: "dummy",
1341+
apiBaseUrl: "http://example.com/api",
1342+
project: "p",
1343+
});
1344+
} catch (e) {
1345+
caught = e as Error;
1346+
}
1347+
expect(caught).not.toBeNull();
1348+
expect(caught!.message).toMatch(/Refusing to send API key over plaintext HTTP/);
1349+
expect(caught!.message).toMatch(/example\.com/);
1350+
} finally {
1351+
if (saved !== undefined) process.env.CHECKUP_ALLOW_HTTP = saved;
1352+
}
1353+
});
1354+
1355+
test("CHECKUP_ALLOW_HTTP=1 bypasses the guard for non-loopback hosts", async () => {
1356+
const saved = process.env.CHECKUP_ALLOW_HTTP;
1357+
process.env.CHECKUP_ALLOW_HTTP = "1";
1358+
try {
1359+
let caught: Error | null = null;
1360+
try {
1361+
await api.createCheckupReport({
1362+
apiKey: "dummy",
1363+
apiBaseUrl: "http://127.0.0.2:1/api", // non-loopback-match hostname, connect refused port
1364+
project: "p",
1365+
});
1366+
} catch (e) {
1367+
caught = e as Error;
1368+
}
1369+
expect(caught).not.toBeNull();
1370+
expect(caught!.message).not.toMatch(/Refusing to send API key/);
1371+
} finally {
1372+
if (saved === undefined) delete process.env.CHECKUP_ALLOW_HTTP;
1373+
else process.env.CHECKUP_ALLOW_HTTP = saved;
1374+
}
1375+
});
1376+
});
12931377
});
12941378

12951379
// Tests for checkup-summary module
@@ -1821,5 +1905,3 @@ describe("checkup-summary", () => {
18211905
expect(result.message).toBe("No redundant indexes");
18221906
});
18231907
});
1824-
1825-

0 commit comments

Comments
 (0)