@malloy-publisher/server 0.0.218 → 0.0.219

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.
Files changed (24) hide show
  1. package/dist/app/api-doc.yaml +22 -6
  2. package/dist/app/assets/{EnvironmentPage-DxOiCxcd.js → EnvironmentPage-gehnjfC6.js} +1 -1
  3. package/dist/app/assets/{HomePage-i8qAtD6x.js → HomePage-8LQBytE4.js} +1 -1
  4. package/dist/app/assets/{MainPage-B58d5pmX.js → MainPage-DDaZLJw-.js} +1 -1
  5. package/dist/app/assets/{MaterializationsPage-6OEVM543.js → MaterializationsPage-D7P1Kp6O.js} +1 -1
  6. package/dist/app/assets/{ModelPage-8d5l7YkU.js → ModelPage-Do_vhxOc.js} +1 -1
  7. package/dist/app/assets/{PackagePage-BVmoVsPQ.js → PackagePage-Cz9fVwSG.js} +1 -1
  8. package/dist/app/assets/{RouteError-DFX521_t.js → RouteError-UONCloyN.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-CzucDJ-T.js → WorkbookPage-Bhzqvbq_.js} +1 -1
  10. package/dist/app/assets/{core-B95VQkAV.es-Cbn-yKG-.js → core-BiGj7BML.es-kMHAa8tP.js} +1 -1
  11. package/dist/app/assets/{index--xJ1pzL-.js → index-VzRbxcF7.js} +3 -3
  12. package/dist/app/assets/{index-DxRKma36.js → index-ddq4-5hu.js} +1 -1
  13. package/dist/app/assets/{index-Bxkza-hz.js → index-qOQF9CXq.js} +1 -1
  14. package/dist/app/assets/{index.umd-o-yUIEtS.js → index.umd-B2kmxDh7.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +267 -220
  17. package/package.json +1 -1
  18. package/src/service/connection.spec.ts +70 -0
  19. package/src/service/connection.ts +50 -5
  20. package/src/service/connection_config.spec.ts +138 -0
  21. package/src/service/connection_config.ts +81 -2
  22. package/src/service/proxy.spec.ts +82 -35
  23. package/src/service/proxy.ts +55 -40
  24. package/dist/sshcrypto-8m50vnmb.node +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.218",
4
+ "version": "0.0.219",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -1,10 +1,12 @@
1
1
  import { DuckDBConnection } from "@malloydata/db-duckdb";
2
2
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3
3
  import fs from "fs/promises";
4
+ import os from "os";
4
5
  import path from "path";
5
6
  import sinon from "sinon";
6
7
  import { components } from "../api";
7
8
  import {
9
+ buildProxiedSslQuery,
8
10
  createEnvironmentConnections,
9
11
  testConnectionConfig,
10
12
  } from "./connection";
@@ -1846,3 +1848,71 @@ describe("connection integration tests", () => {
1846
1848
  );
1847
1849
  });
1848
1850
  });
1851
+
1852
+ describe("buildProxiedSslQuery", () => {
1853
+ const ORIG_CA = process.env.NODE_EXTRA_CA_CERTS;
1854
+ afterEach(() => {
1855
+ if (ORIG_CA === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
1856
+ else process.env.NODE_EXTRA_CA_CERTS = ORIG_CA;
1857
+ });
1858
+
1859
+ it("defaults to no-verify when sslmode is unset (force-SSL target isn't rejected for plaintext)", () => {
1860
+ expect(buildProxiedSslQuery("conn")).toBe("?sslmode=no-verify");
1861
+ });
1862
+
1863
+ it("returns no-verify for explicit no-verify", () => {
1864
+ expect(buildProxiedSslQuery("conn", "no-verify")).toBe(
1865
+ "?sslmode=no-verify",
1866
+ );
1867
+ });
1868
+
1869
+ it("emits an explicit sslmode=disable, never an empty query", () => {
1870
+ // Regression: an empty query lets pg fall back to the deployment's PGSSLMODE
1871
+ // env, which would verify and fail the 127.0.0.1 hostname check.
1872
+ expect(buildProxiedSslQuery("conn", "disable")).toBe("?sslmode=disable");
1873
+ });
1874
+
1875
+ it("builds a chain-verifying, hostname-skipped query for verify-ca", () => {
1876
+ process.env.NODE_EXTRA_CA_CERTS = "/etc/ssl/certs/rds bundle.pem";
1877
+ const q = buildProxiedSslQuery("conn", "verify-ca");
1878
+ expect(q).toContain("uselibpqcompat=true");
1879
+ expect(q).toContain("sslmode=verify-ca");
1880
+ expect(q).toContain(
1881
+ `sslrootcert=${encodeURIComponent("/etc/ssl/certs/rds bundle.pem")}`,
1882
+ );
1883
+ });
1884
+
1885
+ it("throws for verify-ca when no CA bundle is available", () => {
1886
+ delete process.env.NODE_EXTRA_CA_CERTS;
1887
+ expect(() => buildProxiedSslQuery("conn", "verify-ca")).toThrow(
1888
+ /no trusted CA bundle/i,
1889
+ );
1890
+ });
1891
+
1892
+ it("throws for an unsupported sslmode", () => {
1893
+ expect(() => buildProxiedSslQuery("conn", "require")).toThrow(
1894
+ /unsupported sslmode/i,
1895
+ );
1896
+ });
1897
+
1898
+ it("verify-ca connectionString parses to an ssl config (pg-connection-string version guard)", async () => {
1899
+ // uselibpqcompat=true (pg-connection-string >= 2.7) is what turns verify-ca
1900
+ // into "verify chain, skip hostname". If pg is ever downgraded below that
1901
+ // floor, sslmode would be ignored — this guard fails loudly instead.
1902
+ const caPath = path.join(os.tmpdir(), `proxy-ca-${process.pid}.pem`);
1903
+ await fs.writeFile(
1904
+ caPath,
1905
+ "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
1906
+ );
1907
+ process.env.NODE_EXTRA_CA_CERTS = caPath;
1908
+ try {
1909
+ const q = buildProxiedSslQuery("conn", "verify-ca");
1910
+ const { parse } = await import("pg-connection-string");
1911
+ const parsed = parse(`postgresql://127.0.0.1:5432/db${q}`);
1912
+ expect(parsed.ssl).toBeTruthy();
1913
+ expect(typeof parsed.ssl).toBe("object");
1914
+ } finally {
1915
+ await fs.rm(caPath, { force: true });
1916
+ }
1917
+ });
1918
+ });
@@ -925,6 +925,44 @@ function buildSnowflakePrivateKeyConnection(
925
925
  });
926
926
  }
927
927
 
928
+ // TLS mode for a proxied postgres connection, as `pg` connectionString query
929
+ // params. @malloydata/db-postgres exposes no ssl config, but it forwards a
930
+ // connectionString straight to pg — which parses sslmode. The tunnel terminates
931
+ // at 127.0.0.1, so pg's hostname check can't apply: `verify-ca` validates the
932
+ // cert chain against the trusted CA bundle (NODE_EXTRA_CA_CERTS, e.g. the baked
933
+ // Amazon RDS roots) WITHOUT the hostname; `no-verify` encrypts without checking;
934
+ // `disable` uses no TLS. Full verify-full through a tunnel needs a servername
935
+ // override (a raw ssl object) — awaits an upstream passthrough (malloydata/malloy#2960).
936
+ export function buildProxiedSslQuery(name: string, sslmode?: string): string {
937
+ const caBundle = process.env.NODE_EXTRA_CA_CERTS;
938
+ // Default: encrypt (no-verify) so a force-SSL target (the common RDS case)
939
+ // isn't rejected for plaintext. Operators opt up to verify-ca when the
940
+ // target's CA is in the trust bundle, or down to disable for non-TLS DBs.
941
+ const mode = sslmode ?? "no-verify";
942
+ switch (mode) {
943
+ case "disable":
944
+ // Explicit: with no sslmode in the connectionString, pg falls back to
945
+ // the PGSSLMODE env (set on the non-proxied path), which would verify
946
+ // and fail the 127.0.0.1 hostname check. Force plaintext explicitly.
947
+ return "?sslmode=disable";
948
+ case "no-verify":
949
+ return "?sslmode=no-verify";
950
+ case "verify-ca":
951
+ // Env-set backstop; validateConnectionShape does the readable-file check
952
+ // at config load (so a bad bundle fails fast instead of at connect time).
953
+ if (!caBundle) {
954
+ throw new Error(
955
+ `Connection proxy on '${name}' uses sslmode 'verify-ca' but no trusted CA bundle is available (NODE_EXTRA_CA_CERTS is unset). Add the CA bundle to the image or use sslmode 'no-verify'.`,
956
+ );
957
+ }
958
+ return `?uselibpqcompat=true&sslmode=verify-ca&sslrootcert=${encodeURIComponent(caBundle)}`;
959
+ default:
960
+ throw new Error(
961
+ `Connection proxy on '${name}' has unsupported sslmode '${mode}' (expected disable | no-verify | verify-ca).`,
962
+ );
963
+ }
964
+ }
965
+
928
966
  function buildProxiedPostgresConnection(
929
967
  metadata: EnvironmentConnectionMetadata,
930
968
  endpoint: ProxyEndpoint,
@@ -936,13 +974,20 @@ function buildProxiedPostgresConnection(
936
974
  `Proxied connection '${name}' has type 'postgres' but no postgresConnection config.`,
937
975
  );
938
976
  }
977
+ // Build a connectionString to the LOCAL tunnel endpoint carrying the TLS mode.
978
+ // This is NOT the tenant's connectionString (rejected in validateConnectionShape
979
+ // because it can't be tunneled) — we construct it ourselves and it targets
980
+ // 127.0.0.1:<localport>, so there's no ambiguity about where it connects.
981
+ const enc = encodeURIComponent;
982
+ const auth = pg.userName
983
+ ? `${enc(pg.userName)}${pg.password ? `:${enc(pg.password)}` : ""}@`
984
+ : "";
985
+ const db = pg.databaseName ? `/${enc(pg.databaseName)}` : "";
986
+ const ssl = buildProxiedSslQuery(name, pg.sslmode);
987
+ const connectionString = `postgresql://${auth}${endpoint.host}:${endpoint.port}${db}${ssl}`;
939
988
  return new PooledPostgresConnection({
940
989
  name,
941
- host: endpoint.host,
942
- port: endpoint.port,
943
- username: pg.userName,
944
- password: pg.password,
945
- databaseName: pg.databaseName,
990
+ connectionString,
946
991
  // Pool sizing mirrors buildSnowflakePrivateKeyConnection.
947
992
  poolMin: 1,
948
993
  poolMax: 20,
@@ -464,4 +464,142 @@ describe("SSH proxy validation", () => {
464
464
  "requires explicit host and port",
465
465
  );
466
466
  });
467
+
468
+ it("accepts a multi-line hostKey and sslmode=no-verify", () => {
469
+ const conn: ApiConnection = {
470
+ ...validSshProxy,
471
+ postgresConnection: {
472
+ ...validSshProxy.postgresConnection!,
473
+ sslmode: "no-verify",
474
+ },
475
+ proxy: {
476
+ type: "ssh",
477
+ ssh: {
478
+ ...validSshProxy.proxy!.ssh!,
479
+ hostKey:
480
+ "bastion ssh-ed25519 AAAAdecoy\nbastion ssh-rsa AAAAreal",
481
+ },
482
+ },
483
+ };
484
+ expect(() => assembleEnvironmentConnections([conn])).not.toThrow();
485
+ });
486
+
487
+ it("rejects a hostKey that parses to zero keys (comment/blank only) — fail-closed at config load", () => {
488
+ const conn: ApiConnection = {
489
+ ...validSshProxy,
490
+ proxy: {
491
+ type: "ssh",
492
+ ssh: {
493
+ ...validSshProxy.proxy!.ssh!,
494
+ hostKey: "# ssh-keyscan bastion timed out\n \n",
495
+ },
496
+ },
497
+ };
498
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
499
+ "no usable host-key line",
500
+ );
501
+ });
502
+
503
+ it("rejects a whitespace-only hostKey (must not silently connect unpinned)", () => {
504
+ const conn: ApiConnection = {
505
+ ...validSshProxy,
506
+ proxy: {
507
+ type: "ssh",
508
+ ssh: {
509
+ ...validSshProxy.proxy!.ssh!,
510
+ hostKey: " ",
511
+ },
512
+ },
513
+ };
514
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
515
+ "no usable host-key line",
516
+ );
517
+ });
518
+
519
+ it("treats an empty-string hostKey as unpinned (omission-equivalent)", () => {
520
+ const conn: ApiConnection = {
521
+ ...validSshProxy,
522
+ proxy: {
523
+ type: "ssh",
524
+ ssh: { ...validSshProxy.proxy!.ssh!, hostKey: "" },
525
+ },
526
+ };
527
+ expect(() => assembleEnvironmentConnections([conn])).not.toThrow();
528
+ });
529
+
530
+ it("rejects an unsupported sslmode", () => {
531
+ const conn = {
532
+ ...validSshProxy,
533
+ postgresConnection: {
534
+ ...validSshProxy.postgresConnection!,
535
+ sslmode: "require",
536
+ },
537
+ } as unknown as ApiConnection;
538
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
539
+ "unsupported sslmode 'require'",
540
+ );
541
+ });
542
+
543
+ it("rejects an empty-string sslmode at config load (not deferred to connect)", () => {
544
+ const conn = {
545
+ ...validSshProxy,
546
+ postgresConnection: {
547
+ ...validSshProxy.postgresConnection!,
548
+ sslmode: "",
549
+ },
550
+ } as unknown as ApiConnection;
551
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
552
+ "unsupported sslmode",
553
+ );
554
+ });
555
+
556
+ it("rejects sslmode=verify-ca when no CA bundle is available", () => {
557
+ const prior = process.env.NODE_EXTRA_CA_CERTS;
558
+ delete process.env.NODE_EXTRA_CA_CERTS;
559
+ try {
560
+ const conn: ApiConnection = {
561
+ ...validSshProxy,
562
+ postgresConnection: {
563
+ ...validSshProxy.postgresConnection!,
564
+ sslmode: "verify-ca",
565
+ },
566
+ };
567
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
568
+ "verify-ca",
569
+ );
570
+ } finally {
571
+ if (prior !== undefined) process.env.NODE_EXTRA_CA_CERTS = prior;
572
+ }
573
+ });
574
+
575
+ it("rejects sslmode set on a non-proxied connection (silent-ignore footgun)", () => {
576
+ const conn: ApiConnection = {
577
+ name: "pg-direct",
578
+ type: "postgres",
579
+ postgresConnection: {
580
+ host: "db.example.com",
581
+ port: 5432,
582
+ databaseName: "mydb",
583
+ userName: "user",
584
+ password: "pass",
585
+ sslmode: "verify-ca",
586
+ },
587
+ };
588
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
589
+ "only supported for proxied connections",
590
+ );
591
+ });
592
+
593
+ it("rejects a proxied database name with URI-reserved characters", () => {
594
+ const conn: ApiConnection = {
595
+ ...validSshProxy,
596
+ postgresConnection: {
597
+ ...validSshProxy.postgresConnection!,
598
+ databaseName: "sales@prod",
599
+ },
600
+ };
601
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
602
+ "URI-reserved characters",
603
+ );
604
+ });
467
605
  });
@@ -1,10 +1,18 @@
1
1
  import { createPrivateKey } from "crypto";
2
+ import { existsSync } from "fs";
2
3
  import path from "path";
3
4
  import { components } from "../api";
5
+ import { parseHostKeys } from "./proxy";
4
6
 
5
7
  type ApiConnection = components["schemas"]["Connection"];
6
8
  type AttachedDatabase = components["schemas"]["AttachedDatabase"];
7
9
 
10
+ // TLS modes accepted on a proxied postgres connection. Canonical here (rather
11
+ // than in connection.ts, which imports this module) so both the config-load
12
+ // validator and the connect-time builder derive from one list. Mirrors the
13
+ // `sslmode` enum in api-doc.yaml.
14
+ export const PROXIED_SSLMODES = ["disable", "no-verify", "verify-ca"] as const;
15
+
8
16
  export type CoreConnectionEntry = {
9
17
  is: string;
10
18
  [key: string]: unknown;
@@ -265,8 +273,11 @@ function validateConnectionShape(connection: ApiConnection): void {
265
273
  // authorized by whoever configures the connection — deliberately NOT gated
266
274
  // by an env flag, and kept separate from the `publisher` HTTP multi-hop
267
275
  // type's PUBLISHER_ALLOW_PROXY_CONNECTIONS gate below (that flag is about
268
- // publisher-to-publisher proxying, a distinct operator decision). Host-key
269
- // pinning is fail-closed at connect time (see openProxy).
276
+ // publisher-to-publisher proxying, a distinct operator decision). Optional
277
+ // host-key pinning is fail-closed at connect time when configured (see
278
+ // openProxy); the proxy-specific fields are validated up front below so a
279
+ // permanent misconfig fails at config load, not by repeatedly dialing the
280
+ // tenant's bastion at query time.
270
281
  if (connection.proxy.type !== "ssh") {
271
282
  throw new Error(
272
283
  `Connection '${connection.name}' has an unsupported proxy type '${connection.proxy.type}'. Only 'ssh' is supported.`,
@@ -303,6 +314,74 @@ function validateConnectionShape(connection: ApiConnection): void {
303
314
  `postgres connection; the connectionString form is not supported with a proxy.`,
304
315
  );
305
316
  }
317
+
318
+ // hostKey is optional (omitted or empty string => connect unpinned), but a
319
+ // non-empty hostKey that parses to zero keys — only blank lines, whitespace,
320
+ // or `#` comments, e.g. a paste that grabbed just ssh-keyscan's
321
+ // `# host:port ...` header — is a misconfigured pin, not a licence to
322
+ // connect unverified. Reject it here so the operator gets a config error
323
+ // instead of a silently unpinned tunnel. (Truthiness, not trim(): "" is the
324
+ // unpinned signal; " " is a non-empty value that must yield a key.)
325
+ const hostKey = connection.proxy.ssh?.hostKey;
326
+ if (hostKey && parseHostKeys(hostKey).size === 0) {
327
+ throw new Error(
328
+ `Connection proxy on '${connection.name}' has a hostKey with no usable host-key line ` +
329
+ `(only blanks/comments). Provide an OpenSSH known_hosts line or base64 blob, or omit ` +
330
+ `hostKey to connect unpinned.`,
331
+ );
332
+ }
333
+
334
+ // Validate the proxied TLS mode up front. The tunnel is dialed lazily on
335
+ // first lookup and a failed build is retried on every subsequent query, so
336
+ // a permanent sslmode misconfig left to throw at connect time would re-dial
337
+ // the tenant's bastion indefinitely. Fail at config load instead.
338
+ // `!= null` (not truthiness) so a present-but-empty sslmode ("") is caught
339
+ // here as unsupported rather than slipping through to fail at tunnel-build;
340
+ // null/undefined mean unset (server applies the default).
341
+ const sslmode = connection.postgresConnection?.sslmode;
342
+ if (sslmode != null) {
343
+ if (!(PROXIED_SSLMODES as readonly string[]).includes(sslmode)) {
344
+ throw new Error(
345
+ `Connection proxy on '${connection.name}' has unsupported sslmode '${sslmode}' ` +
346
+ `(expected ${PROXIED_SSLMODES.join(" | ")}).`,
347
+ );
348
+ }
349
+ if (sslmode === "verify-ca") {
350
+ const caBundle = process.env.NODE_EXTRA_CA_CERTS;
351
+ if (!caBundle || !existsSync(caBundle)) {
352
+ throw new Error(
353
+ `Connection proxy on '${connection.name}' uses sslmode 'verify-ca' but no readable ` +
354
+ `CA bundle is available (NODE_EXTRA_CA_CERTS is unset or points at a missing file). ` +
355
+ `Add the CA bundle to the image or use sslmode 'no-verify'.`,
356
+ );
357
+ }
358
+ }
359
+ }
360
+
361
+ // The proxied path builds a connectionString to the local tunnel endpoint;
362
+ // pg decodes the database path with decodeURI, which leaves URI-reserved
363
+ // characters percent-encoded — so a db name containing them would resolve
364
+ // to the wrong database. Reject it clearly rather than failing later with a
365
+ // confusing "database does not exist". (user/password use decodeURIComponent
366
+ // on parse and round-trip fine.)
367
+ const dbName = connection.postgresConnection?.databaseName;
368
+ if (dbName && decodeURI(encodeURIComponent(dbName)) !== dbName) {
369
+ throw new Error(
370
+ `Connection proxy on '${connection.name}' has a database name with characters that can't ` +
371
+ `be carried over a proxied connection (${JSON.stringify(dbName)}). Use a database name ` +
372
+ `without URI-reserved characters (; , / ? : @ & = + $ #).`,
373
+ );
374
+ }
375
+ }
376
+
377
+ // sslmode is only honored on the proxied path (the direct path builds TLS from
378
+ // the deployment PGSSLMODE). Reject it on a non-proxied connection so a tenant
379
+ // who sets it doesn't silently get a different TLS posture than they asked for.
380
+ if (!connection.proxy && connection.postgresConnection?.sslmode) {
381
+ throw new Error(
382
+ `Connection '${connection.name}' sets postgresConnection.sslmode but has no proxy; sslmode is ` +
383
+ `only supported for proxied connections (direct connections use the deployment PGSSLMODE).`,
384
+ );
306
385
  }
307
386
 
308
387
  switch (connection.type) {
@@ -300,39 +300,69 @@ describe("openProxy — SSH tunnel", () => {
300
300
  },
301
301
  { host: "127.0.0.1", port: echoServer.port },
302
302
  ),
303
- ).rejects.toThrow(/host-key mismatch/i);
303
+ ).rejects.toThrow(/host-key verification failed/i);
304
304
  });
305
305
 
306
- it("rejects when hostKey is absent and opt-out env is not set", async () => {
307
- const orig = process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY;
308
- delete process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY;
309
-
306
+ it("connects when hostKey is absent unpinned self-service default", async () => {
307
+ // No hostKey → connect without host-key verification (the SSH transport is
308
+ // still encrypted). Matches mainstream BI tools' SSH-tunnel default.
309
+ const ep = await openProxy(
310
+ {
311
+ type: "ssh",
312
+ ssh: {
313
+ host: "127.0.0.1",
314
+ port: sshServer.port,
315
+ username: "testuser",
316
+ privateKey: clientPrivatePem,
317
+ },
318
+ },
319
+ { host: "127.0.0.1", port: echoServer.port },
320
+ );
310
321
  try {
311
- await expect(
312
- openProxy(
313
- {
314
- type: "ssh",
315
- ssh: {
316
- host: "127.0.0.1",
317
- port: sshServer.port,
318
- username: "testuser",
319
- privateKey: clientPrivatePem,
320
- },
321
- },
322
- { host: "127.0.0.1", port: echoServer.port },
323
- ),
324
- ).rejects.toThrow(/no hostKey provided/i);
322
+ const reply = await connectAndSend(ep.port, "unpinned");
323
+ expect(reply).toContain("unpinned");
325
324
  } finally {
326
- if (orig !== undefined) {
327
- process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY = orig;
328
- }
325
+ await closeQuietly(() => ep.close());
329
326
  }
330
327
  });
331
328
 
332
- it("connects when hostKey is absent and opt-out env is set", async () => {
333
- process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY = "true";
329
+ it("accepts a multi-line hostKey listing several keys matches any (LB bastion)", async () => {
330
+ // A load-balanced bastion presents a different host key per backend, so the
331
+ // pin lists every backend's key and any match is accepted.
332
+ const decoy = Buffer.alloc(32, 0xff).toString("base64");
333
+ const multiKey = [
334
+ `bastion.example.com ssh-ed25519 ${decoy}`,
335
+ `bastion.example.com ssh-rsa ${sshServer.hostKeyBase64}`,
336
+ ].join("\n");
337
+ const ep = await openProxy(
338
+ {
339
+ type: "ssh",
340
+ ssh: {
341
+ host: "127.0.0.1",
342
+ port: sshServer.port,
343
+ username: "testuser",
344
+ privateKey: clientPrivatePem,
345
+ hostKey: multiKey,
346
+ },
347
+ },
348
+ { host: "127.0.0.1", port: echoServer.port },
349
+ );
334
350
  try {
335
- const ep = await openProxy(
351
+ const reply = await connectAndSend(ep.port, "multi-key");
352
+ expect(reply).toContain("multi-key");
353
+ } finally {
354
+ await closeQuietly(() => ep.close());
355
+ }
356
+ });
357
+
358
+ it("fails closed when hostKey is set but parses to zero keys (comment/blank only)", async () => {
359
+ // A set-but-degenerate pin must NOT fall through to the unpinned branch —
360
+ // the security boundary is gated on hostKey being configured, not on it
361
+ // parsing to >=1 key. (Config load rejects this earlier; this guards the
362
+ // boundary itself.)
363
+ const commentOnly = "# ssh-keyscan bastion.example.com timed out\n \n";
364
+ await expect(
365
+ openProxy(
336
366
  {
337
367
  type: "ssh",
338
368
  ssh: {
@@ -340,19 +370,36 @@ describe("openProxy — SSH tunnel", () => {
340
370
  port: sshServer.port,
341
371
  username: "testuser",
342
372
  privateKey: clientPrivatePem,
373
+ hostKey: commentOnly,
343
374
  },
344
375
  },
345
376
  { host: "127.0.0.1", port: echoServer.port },
346
- );
347
- try {
348
- const reply = await connectAndSend(ep.port, "allow-unknown");
349
- expect(reply).toContain("allow-unknown");
350
- } finally {
351
- await closeQuietly(() => ep.close());
352
- }
353
- } finally {
354
- delete process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY;
355
- }
377
+ ),
378
+ ).rejects.toThrow(/host-key verification failed/i);
379
+ });
380
+
381
+ it("rejects a multi-line hostKey when none of the listed keys match", async () => {
382
+ const decoy1 = Buffer.alloc(32, 0xff).toString("base64");
383
+ const decoy2 = Buffer.alloc(32, 0xaa).toString("base64");
384
+ const multiKey = [
385
+ `bastion.example.com ssh-ed25519 ${decoy1}`,
386
+ `bastion.example.com ssh-rsa ${decoy2}`,
387
+ ].join("\n");
388
+ await expect(
389
+ openProxy(
390
+ {
391
+ type: "ssh",
392
+ ssh: {
393
+ host: "127.0.0.1",
394
+ port: sshServer.port,
395
+ username: "testuser",
396
+ privateKey: clientPrivatePem,
397
+ hostKey: multiKey,
398
+ },
399
+ },
400
+ { host: "127.0.0.1", port: echoServer.port },
401
+ ),
402
+ ).rejects.toThrow(/host-key verification failed/i);
356
403
  });
357
404
 
358
405
  it("rejects for unsupported proxy type", async () => {