Skip to content

Commit ccf8b63

Browse files
committed
refactor(ssh): centralize ssh2 internal API access with version guards
1 parent 2452a1e commit ccf8b63

5 files changed

Lines changed: 242 additions & 70 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@
136136
"react-html-parser": "^2.0.2",
137137
"react-router-dom": "6.30.3",
138138
"simple-git": "^3.30.0",
139-
"ssh2": "^1.17.0",
139+
"ssh2": "~1.17.0",
140140
"uuid": "^13.0.0",
141141
"validator": "^13.15.26",
142142
"yargs": "^17.7.2"

src/proxy/ssh/AgentForwarding.ts

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,16 @@
2424

2525
import { SSHAgentProxy } from './AgentProxy';
2626
import { ClientWithUser } from './types';
27-
28-
// Import BaseAgent from ssh2 for custom agent implementation
27+
import {
28+
getProtocol,
29+
getChannelManager,
30+
findAvailableChannelId,
31+
getChannelModule,
32+
} from './sshInternals';
33+
34+
// Import BaseAgent from ssh2 for custom agent implementation.
35+
// Like the other accesses in ./sshInternals, this path is not in ssh2's
36+
// package.json "exports". Verified working on ssh2 ~1.17.0.
2937
const { BaseAgent } = require('ssh2/lib/agent.js');
3038

3139
/**
@@ -204,45 +212,51 @@ export class LazySSHAgent extends BaseAgent {
204212
export async function openTemporaryAgentChannel(
205213
client: ClientWithUser,
206214
): Promise<SSHAgentProxy | null> {
207-
// Access internal protocol handler (not exposed in public API)
208-
const proto = (client as any)._protocol;
209-
if (!proto) {
210-
console.error('[SSH] No protocol found on client connection');
215+
// All ssh2 internals access goes through ./sshInternals, which throws a
216+
// clear version-aware error if the internal API has changed.
217+
let proto;
218+
let chanMgr;
219+
let channelModule;
220+
try {
221+
proto = getProtocol(client);
222+
chanMgr = getChannelManager(client);
223+
channelModule = getChannelModule();
224+
} catch (err) {
225+
console.error('[SSH]', err instanceof Error ? err.message : String(err));
211226
return null;
212227
}
213228

214-
// Find next available channel ID by checking internal ChannelManager
215-
// This prevents conflicts with channels that ssh2 might be managing
216-
const chanMgr = (client as any)._chanMgr;
217-
let localChan = 1; // Start from 1 (0 is typically main session)
218-
219-
if (chanMgr && chanMgr._channels) {
220-
// Find first available channel ID
221-
while (chanMgr._channels[localChan] !== undefined) {
222-
localChan++;
223-
}
224-
}
229+
// 0 is typically the main session, start scanning from 1
230+
const localChan = findAvailableChannelId(chanMgr, 1);
225231

226232
console.log(`[SSH] Opening agent channel with ID ${localChan}`);
227233

228234
return new Promise((resolve) => {
229-
const originalHandler = (proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION;
230-
const handlerWrapper = (self: any, info: any) => {
235+
const originalHandler = proto._handlers.CHANNEL_OPEN_CONFIRMATION;
236+
237+
const restoreHandler = () => {
231238
if (originalHandler) {
232-
originalHandler(self, info);
239+
proto._handlers.CHANNEL_OPEN_CONFIRMATION = originalHandler;
240+
} else {
241+
delete proto._handlers.CHANNEL_OPEN_CONFIRMATION;
233242
}
243+
};
234244

235-
if (info.recipient === localChan) {
236-
clearTimeout(timeout);
245+
const handlerWrapper = (...args: unknown[]) => {
246+
if (originalHandler) {
247+
originalHandler(...args);
248+
}
237249

238-
// Restore original handler
239-
if (originalHandler) {
240-
(proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION = originalHandler;
241-
} else {
242-
delete (proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION;
243-
}
250+
const info = args[1] as {
251+
recipient?: number;
252+
sender: number;
253+
window: number;
254+
packetSize: number;
255+
};
256+
if (info?.recipient === localChan) {
257+
clearTimeout(timeout);
258+
restoreHandler();
244259

245-
// Create a Channel object manually
246260
try {
247261
const channelInfo = {
248262
type: 'auth-agent@openssh.com',
@@ -260,18 +274,15 @@ export async function openTemporaryAgentChannel(
260274
},
261275
};
262276

263-
const { Channel } = require('ssh2/lib/Channel');
264-
const channel = new Channel(client, channelInfo, { server: true });
277+
const channel = new channelModule.Channel(client, channelInfo, { server: true });
265278

266279
// Register channel with ChannelManager
267-
const chanMgr = (client as any)._chanMgr;
268-
if (chanMgr) {
269-
chanMgr._channels[localChan] = channel;
270-
chanMgr._count++;
271-
}
272-
273-
// Create the agent proxy
274-
const agentProxy = new SSHAgentProxy(channel);
280+
chanMgr._channels[localChan] = channel;
281+
chanMgr._count++;
282+
283+
const agentProxy = new SSHAgentProxy(
284+
channel as ConstructorParameters<typeof SSHAgentProxy>[0],
285+
);
275286
resolve(agentProxy);
276287
} catch (err) {
277288
console.error('[SSH] Failed to create Channel/AgentProxy:', err);
@@ -280,22 +291,15 @@ export async function openTemporaryAgentChannel(
280291
}
281292
};
282293

283-
// Install our handler
284-
(proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION = handlerWrapper;
294+
proto._handlers.CHANNEL_OPEN_CONFIRMATION = handlerWrapper;
285295

286296
const timeout = setTimeout(() => {
287297
console.error('[SSH] Timeout waiting for channel confirmation');
288-
if (originalHandler) {
289-
(proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION = originalHandler;
290-
} else {
291-
delete (proto as any)._handlers.CHANNEL_OPEN_CONFIRMATION;
292-
}
298+
restoreHandler();
293299
resolve(null);
294300
}, 5000);
295301

296-
// Send the channel open request
297-
const { MAX_WINDOW, PACKET_SIZE } = require('ssh2/lib/Channel');
298-
proto.openssh_authAgent(localChan, MAX_WINDOW, PACKET_SIZE);
302+
proto.openssh_authAgent(localChan, channelModule.MAX_WINDOW, channelModule.PACKET_SIZE);
299303
});
300304
}
301305

src/proxy/ssh/server.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { ClientWithUser, SSH2ServerOptions } from './types';
3030
import { createMockResponse } from './sshHelpers';
3131
import { processGitUrl } from '../routes/helper';
3232
import { ensureHostKey } from './hostKeyManager';
33+
import { getProtocol, getSessionOutgoingChannelId } from './sshInternals';
3334

3435
export class SSHServer {
3536
private server: ssh2.Server;
@@ -264,17 +265,18 @@ export class SSHServer {
264265
if (typeof accept === 'function') {
265266
accept();
266267
} else {
267-
// Client sent wantReply=false, manually send CHANNEL_SUCCESS
268+
// Client sent wantReply=false, manually send CHANNEL_SUCCESS via ssh2 internals.
268269
try {
269-
const channelInfo = (session as any)._chanInfo;
270-
if (channelInfo && channelInfo.outgoing && channelInfo.outgoing.id !== undefined) {
271-
const proto = (client as any)._protocol || (client as any)._sock;
272-
if (proto && typeof proto.channelSuccess === 'function') {
273-
proto.channelSuccess(channelInfo.outgoing.id);
274-
}
270+
const outgoingChannelId = getSessionOutgoingChannelId(session);
271+
if (outgoingChannelId !== undefined) {
272+
const proto = getProtocol(client);
273+
proto.channelSuccess(outgoingChannelId);
275274
}
276275
} catch (err) {
277-
console.error('[SSH] Failed to send CHANNEL_SUCCESS:', err);
276+
console.error(
277+
'[SSH] Failed to send CHANNEL_SUCCESS:',
278+
err instanceof Error ? err.message : String(err),
279+
);
278280
}
279281
}
280282

src/proxy/ssh/sshInternals.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Thin abstraction over ssh2 internal APIs.
19+
*
20+
* ssh2 does not expose a public server-side API for opening an
21+
* `auth-agent@openssh.com` channel back to the connected client. To implement
22+
* SSH agent forwarding from the server side we reach into underscore-prefixed
23+
* internals (`_protocol`, `_chanMgr`, `_handlers`) and into the internal module
24+
* `ssh2/lib/Channel`. Those symbols have NO semver stability guarantee.
25+
*
26+
* This module is the ONLY place allowed to access those internals. Every access
27+
* is guarded and throws a descriptive, version-aware error when the shape of
28+
* the internals changes. If ssh2 is upgraded and something breaks, the error
29+
* will tell you which symbol disappeared and on which installed version.
30+
*
31+
* Verified working against ssh2 1.17.x. The package.json pin is `~1.17.0`
32+
* (patch-only) to force a manual review on minor/major bumps.
33+
*/
34+
35+
import * as ssh2 from 'ssh2';
36+
37+
const ssh2Version: string = (() => {
38+
try {
39+
40+
return require('ssh2/package.json').version;
41+
} catch {
42+
return 'unknown';
43+
}
44+
})();
45+
46+
const VERIFIED_RANGE = '1.17.x';
47+
48+
function fail(detail: string): never {
49+
throw new Error(
50+
`ssh2 internal API changed: ${detail} ` +
51+
`(installed ssh2 version: ${ssh2Version}, verified working on ${VERIFIED_RANGE}). ` +
52+
`If you upgraded ssh2, review src/proxy/ssh/sshInternals.ts and pin to a known-working version.`,
53+
);
54+
}
55+
56+
export interface Ssh2Protocol {
57+
openssh_authAgent(localChan: number, maxWindow: number, packetSize: number): void;
58+
channelSuccess(channelId: number): void;
59+
_handlers: Record<string, (...args: unknown[]) => unknown>;
60+
}
61+
62+
export interface Ssh2ChannelManager {
63+
_channels: Record<number, unknown>;
64+
_count: number;
65+
}
66+
67+
export interface ChannelConstructor {
68+
new (client: unknown, info: unknown, opts: { server: boolean }): unknown;
69+
}
70+
71+
export interface ChannelModule {
72+
Channel: ChannelConstructor;
73+
MAX_WINDOW: number;
74+
PACKET_SIZE: number;
75+
}
76+
77+
/**
78+
* Retrieve the internal `_protocol` object of an ssh2 Connection.
79+
* Throws a descriptive error if the internal shape is not as expected.
80+
*/
81+
export function getProtocol(client: ssh2.Connection): Ssh2Protocol {
82+
const proto = (client as unknown as { _protocol?: unknown })._protocol as
83+
| Partial<Ssh2Protocol>
84+
| undefined;
85+
if (!proto) fail('client._protocol is missing');
86+
if (typeof proto.openssh_authAgent !== 'function') {
87+
fail('client._protocol.openssh_authAgent is missing or not a function');
88+
}
89+
if (typeof proto.channelSuccess !== 'function') {
90+
fail('client._protocol.channelSuccess is missing or not a function');
91+
}
92+
if (!proto._handlers || typeof proto._handlers !== 'object') {
93+
fail('client._protocol._handlers is missing or not an object');
94+
}
95+
return proto as Ssh2Protocol;
96+
}
97+
98+
/**
99+
* Retrieve the internal `_chanMgr` object of an ssh2 Connection.
100+
*/
101+
export function getChannelManager(client: ssh2.Connection): Ssh2ChannelManager {
102+
const chanMgr = (client as unknown as { _chanMgr?: unknown })._chanMgr as
103+
| Partial<Ssh2ChannelManager>
104+
| undefined;
105+
if (!chanMgr) fail('client._chanMgr is missing');
106+
if (!chanMgr._channels || typeof chanMgr._channels !== 'object') {
107+
fail('client._chanMgr._channels is missing or not an object');
108+
}
109+
if (typeof chanMgr._count !== 'number') {
110+
fail('client._chanMgr._count is missing or not a number');
111+
}
112+
return chanMgr as Ssh2ChannelManager;
113+
}
114+
115+
/**
116+
* Find the first channel ID not currently in use by the given channel manager.
117+
* Starts scanning at `startId` (default 1; 0 is typically the main session).
118+
*/
119+
export function findAvailableChannelId(chanMgr: Ssh2ChannelManager, startId = 1): number {
120+
let id = startId;
121+
while (chanMgr._channels[id] !== undefined) id++;
122+
return id;
123+
}
124+
125+
let cachedChannelModule: ChannelModule | null = null;
126+
127+
/**
128+
* Load the internal `ssh2/lib/Channel` module and validate its exports.
129+
* The result is cached for subsequent calls.
130+
*/
131+
export function getChannelModule(): ChannelModule {
132+
if (cachedChannelModule) return cachedChannelModule;
133+
134+
let mod: Partial<ChannelModule>;
135+
try {
136+
137+
mod = require('ssh2/lib/Channel');
138+
} catch (err) {
139+
fail(`cannot require('ssh2/lib/Channel'): ${err instanceof Error ? err.message : String(err)}`);
140+
}
141+
if (typeof mod.Channel !== 'function') fail('ssh2/lib/Channel does not export Channel');
142+
if (typeof mod.MAX_WINDOW !== 'number') fail('ssh2/lib/Channel does not export MAX_WINDOW');
143+
if (typeof mod.PACKET_SIZE !== 'number') fail('ssh2/lib/Channel does not export PACKET_SIZE');
144+
145+
cachedChannelModule = mod as ChannelModule;
146+
return cachedChannelModule;
147+
}
148+
149+
/**
150+
* Extract the outgoing channel id from an ssh2 server session via its internal
151+
* `_chanInfo` property. Returns undefined if the info is not available yet.
152+
* Used to send a manual CHANNEL_SUCCESS when the client sets wantReply=false.
153+
*/
154+
export function getSessionOutgoingChannelId(session: unknown): number | undefined {
155+
const info = (session as { _chanInfo?: { outgoing?: { id?: unknown } } } | undefined)?._chanInfo;
156+
const id = info?.outgoing?.id;
157+
return typeof id === 'number' ? id : undefined;
158+
}
159+
160+
/**
161+
* For tests: exposes the installed version string.
162+
*/
163+
export function getInstalledSsh2Version(): string {
164+
return ssh2Version;
165+
}

0 commit comments

Comments
 (0)