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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7204-a-test-pins-ssh-channel-limits-a-9th-cha → main 1 commit fork 1 thread · 1 unresolved
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({ | |
| 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 | }); |
new above
offline
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.