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

A test pins SSH channel limits: a 9th channel, a tiny max packet, too many env vars and an oversized data packet are each refused #7215

Merged opened this

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

Merged

mason/forge:mason/7204-a-test-pins-ssh-channel-limits-a-9th-cha → main 1 commit fork 1 thread · 1 unresolved

  • Merged. mason/7204-a-test-pins-ssh-channel-limits-a-9th-cha was merged into main as 809b339.

Review

Sign in to review this change.
1 file changed +209 −0
test/ssh-channel-limits.test.ts added +209
@@ -0,0 +1,209 @@
1 /**
2 * The caps that keep an authenticated SSH peer from growing the forge's
3 * memory or wedging a channel: at most 8 channels, a max packet floor, 32 env
4 * vars of at most 1,024 bytes, CHANNEL_DATA no larger than 32,768 bytes, and
5 * a remote window that never passes 2^32-1. Driven through `Channels`
6 * directly with hand-built payloads, no socket.
7 */
8
9 import { describe, expect, test } from "bun:test";
10 import { createLog } from "../src/kernel/log.ts";
11 import { Channels, type SshSession } from "../src/ssh/channels.ts";
12 import {
13 DISCONNECT,
14 MSG,
15 OPEN_FAILURE,
16 Reader,
17 WireError,
18 Writer,
19 } from "../src/ssh/wire.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23 const log = createLog("ssh-channel-limits-test");
24 const UINT32_MAX = 0xffffffff;
25
26 function harness() {
27 const sent: Uint8Array[] = [];
28 const disconnects: { reason: number; description: string }[] = [];
29 const sessions: SshSession[] = [];
30 const channels = new Channels({
31 send: (payload) => sent.push(payload),
32 unimplemented: () => {},
33 disconnect: (reason, description) =>
34 disconnects.push({ reason, description }),
35 onSession: (session) => sessions.push(session),
36 key: {
37 algo: "ssh-ed25519",
38 blob: new Uint8Array(),
39 fingerprint: "",
40 line: "",
41 },
42 username: "tester",
43 remoteAddress: "127.0.0.1",
44 log,
45 });
46 let seq = 0;
47 return {
48 sent,
49 disconnects,
50 sessions,
51 /** Hand one payload over the way `src/ssh/server.ts` pumps it: a
52 * `WireError` is a PROTOCOL_ERROR disconnect, anything else escapes. */
53 handle(payload: Uint8Array): void {
54 try {
55 channels.handle(payload, seq++);
56 } catch (cause) {
57 if (!(cause instanceof WireError)) throw cause;
58 disconnects.push({
59 reason: DISCONNECT.PROTOCOL_ERROR,
60 description: cause.message,
61 });
62 }
63 },
64 last: () => sent[sent.length - 1]!,
65 };
66 }
67
68 function open(peerId: number, maxPacket = 32768, window = 1 << 20) {
69 return new Writer(64)
70 .u8(MSG.CHANNEL_OPEN)
71 .string("session")
72 .u32(peerId)
73 .u32(window)
74 .u32(maxPacket)
75 .build();
76 }
77
78 function request(
79 channel: number,
80 type: string,
81 fields: Uint8Array = new Uint8Array(),
82 ) {
83 return new Writer(256 + fields.length)
84 .u8(MSG.CHANNEL_REQUEST)
85 .u32(channel)
86 .string(type)
87 .bool(false)
88 .raw(fields)
89 .build();
90 }
91
92 function env(channel: number, name: string, value: string) {
93 return request(
94 channel,
95 "env",
96 new Writer(64 + value.length).string(name).string(value).build(),
97 );
98 }
99
100 function data(channel: number, bytes: number) {
101 return new Writer(bytes + 16)
102 .u8(MSG.CHANNEL_DATA)
103 .u32(channel)
104 .string(new Uint8Array(bytes))
105 .build();
106 }
107
108 function adjust(channel: number, bytes: number) {
109 return new Writer(16)
110 .u8(MSG.CHANNEL_WINDOW_ADJUST)
111 .u32(channel)
112 .u32(bytes)
113 .build();
114 }
115
116 /** The reason code of a CHANNEL_OPEN_FAILURE, or undefined for anything else. */
117 function openFailure(
118 payload: Uint8Array,
119 ): { peerId: number; reason: number } | undefined {
120 const r = new Reader(payload);
121 if (r.u8() !== MSG.CHANNEL_OPEN_FAILURE) return undefined;
122 return { peerId: r.u32(), reason: r.u32() };
123 }
124
125 describe("ssh channel limits", () => {
126 test("a 9th channel is refused with RESOURCE_SHORTAGE until one closes", () => {
127 const h = harness();
128 for (let peer = 100; peer < 108; peer++) {
129 h.handle(open(peer));
130 expect(h.last()[0]).toBe(MSG.CHANNEL_OPEN_CONFIRMATION);
131 }
132
133 h.handle(open(108));
134 expect(openFailure(h.last())).toEqual({
135 peerId: 108,
136 reason: OPEN_FAILURE.RESOURCE_SHORTAGE,
137 });
138
139 // Our ids run 1..8; closing one frees a slot.
140 h.handle(new Writer(8).u8(MSG.CHANNEL_CLOSE).u32(3).build());
141 h.handle(open(109));
142 expect(h.last()[0]).toBe(MSG.CHANNEL_OPEN_CONFIRMATION);
143 h.handle(open(110));
144 expect(openFailure(h.last())?.reason).toBe(OPEN_FAILURE.RESOURCE_SHORTAGE);
145 expect(h.disconnects).toEqual([]);
146 });
147
148 test("a max packet below the floor is ADMINISTRATIVELY_PROHIBITED", () => {
149 const h = harness();
150 h.handle(open(1, 127));
151 expect(openFailure(h.last())).toEqual({

Only ASCII values are used, so the char-vs-byte difference in the source cap is hidden. Add a 600-character value of 2-byte characters.

152 peerId: 1,
153 reason: OPEN_FAILURE.ADMINISTRATIVELY_PROHIBITED,
154 });
155 h.handle(open(2, 128));
156 expect(h.last()[0]).toBe(MSG.CHANNEL_OPEN_CONFIRMATION);
157 expect(h.disconnects).toEqual([]);
158 });
159
160 test("env keeps at most 32 variables, none longer than 1,024 bytes", () => {
161 const h = harness();
162 h.handle(open(1));
163 h.handle(env(1, "BIG", "x".repeat(2000)));
164 h.handle(env(1, "EDGE", "y".repeat(1024)));
165 for (let i = 0; i < 40; i++) h.handle(env(1, `VAR_${i}`, String(i)));
166 h.handle(request(1, "shell"));
167
168 const session = h.sessions[0]!;
169 expect(Object.keys(session.env)).toHaveLength(32);
170 expect(session.env.BIG).toBeUndefined();
171 expect(session.env.EDGE).toBe("y".repeat(1024));
172 expect(session.env.VAR_30).toBe("30");
173 expect(session.env.VAR_31).toBeUndefined();
174 expect(h.disconnects).toEqual([]);
175 });
176
177 test("CHANNEL_DATA past 32,768 bytes disconnects and is never delivered", () => {
178 const h = harness();
179 h.handle(open(1));
180 h.handle(request(1, "shell"));
181 const received: number[] = [];
182 h.sessions[0]!.onData((bytes) => received.push(bytes.length));
183
184 h.handle(data(1, 32768));
185 expect(h.disconnects).toEqual([]);
186 expect(() => h.handle(data(1, 32769))).not.toThrow();
187 expect(h.disconnects).toEqual([
188 {
189 reason: DISCONNECT.PROTOCOL_ERROR,
190 description: "channel data exceeds maximum packet size",
191 },
192 ]);
193 expect(received).toEqual([32768]);
194 });
195
196 test("a WINDOW_ADJUST past 2^32-1 disconnects", () => {
197 const h = harness();
198 h.handle(open(1, 32768, 1 << 20));
199 h.handle(adjust(1, UINT32_MAX - (1 << 20)));
200 expect(h.disconnects).toEqual([]);
201 expect(() => h.handle(adjust(1, 1))).not.toThrow();
202 expect(h.disconnects).toEqual([
203 {
204 reason: DISCONNECT.PROTOCOL_ERROR,
205 description: "window adjust overflow",
206 },
207 ]);
208 });
209 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs