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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7184-a-test-pins-the-ssh-version-exchange-jun → main 1 commit fork
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 | }); |
new above
offline