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

A test pins SSH packet framing: a bad length or bad padding is a DISCONNECT, the socket closes, and the next connection is served #7187

Merged opened this

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

Merged

scout/forge:scout/7181-a-test-pins-ssh-packet-framing-a-bad-len → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/7181-a-test-pins-ssh-packet-framing-a-bad-len was merged into main as 90606cd.

Review

Sign in to review this change.
1 file changed +237 −0
test/ssh-framing-refusals.test.ts added +237
@@ -0,0 +1,237 @@
1 /**
2 * The SSH transport's framing refusals, over a real loopback listener: a bad
3 * packet length or bad padding is a DISCONNECT(PROTOCOL_ERROR), the socket
4 * closes, nothing throws into the listener, and the next connection is served.
5 */
6
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtempSync, rmSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createLog } from "../src/kernel/log.ts";
12 import { serveSsh, type SshServer } from "../src/ssh/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 const PROTOCOL_ERROR = 2;
18 const MSG_DISCONNECT = 1;
19 const MSG_KEXINIT = 20;
20 /** RFC 4253 §6.1: implementations must handle 35000 bytes of packet. */
21 const MAX_PLAINTEXT = 35000;
22
23 let root = "";
24 let server: SshServer;
25 const faults: unknown[] = [];
26 const onFault = (e: unknown) => void faults.push(e);
27
28 beforeAll(async () => {
29 root = mkdtempSync(join(tmpdir(), "forge-ssh-framing-"));
30 process.on("uncaughtException", onFault);
31 server = await serveSsh({
32 port: 0,
33 hostname: "127.0.0.1",
34 hostKeyDir: root,
35 log: createLog("ssh-framing-test"),
36 authorize: () => true,
37 onSession: () => {},
38 });
39 });
40
41 afterAll(() => {
42 process.off("uncaughtException", onFault);
43 expect(faults).toEqual([]);
44 void server.stop();
45 rmSync(root, { recursive: true, force: true });
46 });
47
48 const VERSION = new TextEncoder().encode("SSH-2.0-framing-test\r\n");
49
50 function frame(length: number, body: Uint8Array): Uint8Array {
51 const out = new Uint8Array(4 + body.length);
52 new DataView(out.buffer).setUint32(0, length);
53 out.set(body, 4);
54 return out;
55 }
56
57 function cat(a: Uint8Array, b: Uint8Array): Uint8Array {
58 const out = new Uint8Array(a.length + b.length);
59 out.set(a);
60 out.set(b, a.length);
61 return out;
62 }
63
64 /** Send `frames`, collect everything until the server closes. */
65 function talk(frames: readonly Uint8Array[]): Promise<Uint8Array> {
66 return new Promise((resolve, reject) => {
67 let got: Uint8Array = new Uint8Array(0);
68 const timer = setTimeout(
69 () => reject(new Error("server never closed the connection")),
70 5000,
71 );
72 const done = () => {
73 clearTimeout(timer);
74 resolve(got);
75 };
76 void Bun.connect({
77 hostname: "127.0.0.1",
78 port: server.port,
79 socket: {
80 open(socket) {
81 for (const f of frames) socket.write(f);
82 },
83 data(_s, chunk) {
84 got = cat(got, new Uint8Array(chunk));
85 },
86 close: done,
87 error: done,
88 },
89 }).catch(reject);
90 });
91 }
92
93 interface Packet {
94 type: number;
95 payload: Uint8Array;
96 }
97
98 /** Split the server's plaintext output into its version line and packets. */
99 function parse(bytes: Uint8Array): { version: string; packets: Packet[] } {
100 const nl = bytes.indexOf(0x0a);
101 const version = new TextDecoder().decode(bytes.subarray(0, nl)).trim();
102 const packets: Packet[] = [];
103 let at = nl + 1;
104 while (at + 5 <= bytes.length) {
105 const view = new DataView(bytes.buffer, bytes.byteOffset + at);
106 const length = view.getUint32(0);
107 const padding = bytes[at + 4]!;
108 if (at + 4 + length > bytes.length) break;
109 const payload = bytes.subarray(at + 5, at + 4 + length - padding);
110 packets.push({ type: payload[0]!, payload });
111 at += 4 + length;
112 }
113 return { version, packets };
114 }
115
116 function disconnectOf(bytes: Uint8Array): { reason: number; text: string } {
117 const last = parse(bytes).packets.at(-1);
118 expect(last?.type).toBe(MSG_DISCONNECT);
119 const view = new DataView(
120 last!.payload.buffer,
121 last!.payload.byteOffset,
122 last!.payload.length,
123 );
124 const n = view.getUint32(5);
125 return {
126 reason: view.getUint32(1),
127 text: new TextDecoder().decode(last!.payload.subarray(9, 9 + n)),
128 };
129 }
130
131 async function servedNormally(): Promise<void> {
132 // A well-formed client: version line, then silence until we hang up.
133 const got = await new Promise<Uint8Array>((resolve, reject) => {
134 let data: Uint8Array = new Uint8Array(0);
135 void Bun.connect({
136 hostname: "127.0.0.1",
137 port: server.port,
138 socket: {
139 open(s) {
140 s.write(VERSION);
141 },
142 data(s, chunk) {
143 data = cat(data, new Uint8Array(chunk));
144 if (parse(data).packets.some((p) => p.type === MSG_KEXINIT)) {
145 s.end();
146 resolve(data);
147 }
148 },
149 error(_s, e) {
150 reject(e);
151 },
152 },
153 }).catch(reject);
154 });
155 const { version, packets } = parse(got);
156 expect(version).toStartWith("SSH-2.0-");
157 expect(packets[0]?.type).toBe(MSG_KEXINIT);
158 }
159
160 test("a zero packet length is a PROTOCOL_ERROR disconnect and a closed socket", async () => {
161 const got = await talk([cat(VERSION, frame(0, new Uint8Array(0)))]);
162 expect(disconnectOf(got)).toEqual({
163 reason: PROTOCOL_ERROR,
164 text: "bad packet length",
165 });
166 await servedNormally();
167 });
168
169 const BAD_LENGTH = { reason: PROTOCOL_ERROR, text: "bad packet length" };
170 const BAD_PADDING = { reason: PROTOCOL_ERROR, text: "bad padding length" };
171
172 // 4 + length is a multiple of 8 in each of these, so only the size is wrong;
173 // only the 4-byte header is sent, so the refusal must come from the length
174 // alone and never from buffering (or waiting for) that many bytes.
175 for (const length of [MAX_PLAINTEXT + 4, 0xfffffffc]) {
176 test(`an aligned length of ${length} is over the limit and refused on the header`, async () => {
177 expect((4 + length) % 8).toBe(0);
178 const got = await talk([cat(VERSION, frame(length, new Uint8Array(0)))]);
179 expect(disconnectOf(got)).toEqual(BAD_LENGTH);
180 await servedNormally();
181 });
182 }
183
184 test("the largest aligned length under the limit is not refused", async () => {
185 // 34996 + 4 = 35000. Header only: the server must wait, not disconnect.
186 const length = MAX_PLAINTEXT - 4;
187 const got = await new Promise<Uint8Array>((resolve) => {
188 let data: Uint8Array = new Uint8Array(0);
189 void Bun.connect({
190 hostname: "127.0.0.1",
191 port: server.port,
192 socket: {
193 open(s) {
194 s.write(cat(VERSION, frame(length, new Uint8Array(0))));
195 setTimeout(() => {
196 s.end();
197 resolve(data);
198 }, 200);

Nit, not blocking: this negative case watches a fixed 200 ms window. It can't flake red. But a regression that disconnects after more than 200 ms would pass here unnoticed. A follow-up could send the rest of the 34996-byte body and assert the server goes on to reply, which would turn the negative into a positive.

199 },
200 data(_s, chunk) {
201 data = cat(data, new Uint8Array(chunk));
202 },
203 },
204 });
205 });
206 expect(parse(got).packets.some((p) => p.type === MSG_DISCONNECT)).toBe(false);
207 });
208
209 // Misaligned lengths: refused by the alignment clause alone.
210 for (const length of [9, MAX_PLAINTEXT + 1, 0xffffffff]) {
211 test(`a misaligned length of ${length} is refused`, async () => {
212 expect((4 + length) % 8).not.toBe(0);
213 const got = await talk([cat(VERSION, frame(length, new Uint8Array(0)))]);
214 expect(disconnectOf(got)).toEqual(BAD_LENGTH);
215 await servedNormally();
216 });
217 }
218
219 // Aligned, in-range lengths with a padding byte the packet cannot hold.
220 const paddings: [string, number, number][] = [
221 ["padding 3 (below the minimum of 4)", 12, 3],
222 ["padding 0", 12, 0],
223 ["padding 3 in a longer packet", 20, 3],
224 ["padding == length - 1", 20, 19],
225 ["padding == length", 12, 12],
226 ["padding 250 in a small packet", 12, 250],
227 ];
228 for (const [name, length, padding] of paddings) {
229 test(`${name} is a PROTOCOL_ERROR disconnect`, async () => {
230 expect((4 + length) % 8).toBe(0);
231 const body = new Uint8Array(length);
232 body[0] = padding;
233 const got = await talk([cat(VERSION, frame(length, body))]);
234 expect(disconnectOf(got)).toEqual(BAD_PADDING);
235 await servedNormally();
236 });
237 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs