Public: anyone can read this; the crew can change it.

A test pins the SSH version exchange: junk lines before the banner are bounded, and a slow or endless pre-banner stream is cut off #7189

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7184-a-test-pins-the-ssh-version-exchange-jun → main 1 commit fork

  • Merged. scout/7184-a-test-pins-the-ssh-version-exchange-jun was merged into main as 6c8c957.

Review

Sign in to review this change.
1 file changed +113 −0
test/ssh-version-exchange.test.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * The SSH version exchange on a public port: bounded pre-banner junk, only
3 * SSH-2.0, and a silent peer cut off by the login grace.
4 */
5
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtempSync, rmSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createLog } from "../src/kernel/log.ts";
11 import { loadOrCreateHostKey } from "../src/ssh/keys.ts";
12 import { serveSsh } from "../src/ssh/server.ts";
13 import { Transport } from "../src/ssh/transport.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17 const log = createLog("ssh-version-test");
18 const enc = new TextEncoder();
19
20 let root = "";
21 beforeAll(() => {
22 root = mkdtempSync(join(tmpdir(), "ssh-version-"));
23 });
24 afterAll(() => rmSync(root, { recursive: true, force: true }));
25
26 function harness() {
27 const written: Uint8Array[] = [];
28 const closed: string[] = [];
29 const payloads: number[] = [];
30 const transport = new Transport(
31 {
32 write: (data) => void written.push(data.slice()),
33 payload: (_p, seq) => void payloads.push(seq),
34 closed: (why) => void closed.push(why),
35 log,
36 },
37 { hostKey: loadOrCreateHostKey(root), serverVersion: "SSH-2.0-test" },
38 );
39 transport.start();
40 return { transport, written, closed };
41 }
42
43 test("a few junk lines before a valid SSH-2.0 line are tolerated", () => {
44 const { transport, closed } = harness();
45 transport.receive(enc.encode("hello\r\nwelcome to nothing\n"));
46 transport.receive(enc.encode("SSH-2.0-client\r\n"));
47 expect(closed).toEqual([]);
48 });
49
50 test("junk past the limit ends the connection, having kept no more than the limit", () => {
51 const { transport, closed } = harness();
52 const line = enc.encode(`${"j".repeat(98)}\r\n`);
53 let fed = 0;
54 while (closed.length === 0 && fed < 100_000) {
55 transport.receive(line);
56 fed += line.length;
57 }
58 expect(closed).toEqual(["too much pre-version banner"]);
59 // Stopped at the first line past 1024 bytes, not after streaming on.
60 expect(fed).toBeLessThanOrEqual(1024 + line.length);
61 // Anything after the cut is ignored.
62 transport.receive(enc.encode("SSH-2.0-late\r\n"));
63 expect(closed.length).toBe(1);
64 });
65
66 test("an endless line with no newline is refused at 255 bytes", () => {
67 const { transport, closed } = harness();
68 transport.receive(enc.encode("x".repeat(300)));
69 expect(closed).toEqual(["version line too long"]);
70 });
71
72 test("a version line that is not SSH-2.0 is refused", () => {
73 for (const line of ["SSH-1.5-x", "SSH-1.99-x", "SSH-2.1-x", "SSH-2.0"]) {
74 const { transport, closed } = harness();
75 transport.receive(enc.encode(`${line}\r\n`));
76 expect(closed.length).toBe(1);
77 expect(closed[0]).toContain("unsupported protocol version");
78 }
79 });
80
81 test("a peer that sends nothing is closed by the login grace", async () => {
82 const server = await serveSsh({
83 port: 0,
84 hostname: "127.0.0.1",
85 hostKeyDir: mkdtempSync(join(root, "hostkey-")),
86 log,
87 loginGraceMs: 50,
88 authorize: () => false,
89 onSession: () => {},
90 });
91 try {
92 let got = "";
93 const ended = Promise.withResolvers<void>();
94 await Bun.connect({
95 hostname: "127.0.0.1",
96 port: server.port,
97 socket: {
98 data: (_s, d) => void (got += Buffer.from(d).toString("latin1")),
99 close: () => ended.resolve(),
100 error: () => ended.resolve(),
101 },
102 });
103 await Promise.race([
104 ended.promise,
105 Bun.sleep(3000).then(() => {
106 throw new Error("silent peer was not closed");
107 }),
108 ]);
109 expect(got.startsWith("SSH-2.0-forge\r\n")).toBe(true);
110 } finally {
111 server.stop();
112 }
113 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs