heroku 11.10.0 → 11.10.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
6
 
7
+ ## [11.10.1-beta.0](https://github.com/heroku/cli/compare/v11.10.0...v11.10.1-beta.0) (2026-09-23)
8
+
9
+
10
+ ### Bug Fixes
11
+
12
+ * **deps:** bump fast-uri to 3.1.7 (closes 4 advisories) ([#3908](https://github.com/heroku/cli/issues/3908)) ([ffe207d](https://github.com/heroku/cli/commit/ffe207dc6e50fd1d6fcfddf345dde94deb1db5b3)), closes [#391](https://github.com/heroku/cli/issues/391) [#392](https://github.com/heroku/cli/issues/392) [#399](https://github.com/heroku/cli/issues/399) [#400](https://github.com/heroku/cli/issues/400)
13
+ * **deps:** resolve GHSA-73wf-gq98-2v4g in browserslist ([#3909](https://github.com/heroku/cli/issues/3909)) ([fe3cd8b](https://github.com/heroku/cli/commit/fe3cd8b5f88b3b22491319879bdb9568db42486a)), closes [#396](https://github.com/heroku/cli/issues/396)
14
+ * fix misleading config output when 2FA fails ([#3887](https://github.com/heroku/cli/issues/3887)) ([e0fd81f](https://github.com/heroku/cli/commit/e0fd81f0b4bafc5d2919c5e3cf83a39279299b20))
15
+ * parse Procfile process types containing dashes in heroku local ([#3883](https://github.com/heroku/cli/issues/3883)) ([5118f40](https://github.com/heroku/cli/commit/5118f40a868e7c07604e2986efc68ea45e9feaee))
16
+ * updates opentelemetry deps to match sentry's peer deps ([#3900](https://github.com/heroku/cli/issues/3900)) ([f66d998](https://github.com/heroku/cli/commit/f66d9984b2dd1b8c0cc6c1e4d0f1819340fc4b63))
17
+
18
+
19
+ ### Code Refactoring
20
+
21
+ * drop @heroku/socksv5 fork to clear ip-address HIGH advisory ([#3939](https://github.com/heroku/cli/issues/3939)) ([af20ead](https://github.com/heroku/cli/commit/af20eadf8bfb65278e8b26bcffdea3cf1a743833))
22
+
23
+
24
+ ### Continuous Integration
25
+
26
+ * automatically label each PR with a code-size bucket ([#3941](https://github.com/heroku/cli/issues/3941)) ([2d13484](https://github.com/heroku/cli/commit/2d1348438ec8ab1361a773bce30b3c5c93d8a1e6))
27
+
7
28
  ## [11.10.0](https://github.com/heroku/cli/compare/v11.9.0...v11.10.0) (2026-08-18)
8
29
 
9
30
 
@@ -43,11 +43,18 @@ RACK_ENV: staging`)];
43
43
  });
44
44
  const varsCopy = argv.map((v) => color.name(v.split('=')[0])).join(', ');
45
45
  ux.action.start(`Setting ${varsCopy} and restarting ${color.app(flags.app)}`);
46
- let { body: config } = await this.heroku.patch(`/apps/${flags.app}/config-vars`, {
47
- body: vars,
48
- });
49
- const release = await lastRelease(this.heroku, flags.app);
50
- ux.action.stop(`done, ${color.name('v' + release.version)}`);
46
+ let config;
47
+ try {
48
+ ({ body: config } = await this.heroku.patch(`/apps/${flags.app}/config-vars`, {
49
+ body: vars,
50
+ }));
51
+ const release = await lastRelease(this.heroku, flags.app);
52
+ ux.action.stop(`done, ${color.name('v' + release.version)}`);
53
+ }
54
+ catch (error) {
55
+ ux.action.stop(color.red('!'));
56
+ throw error;
57
+ }
51
58
  config = Object.fromEntries(Object.entries(config)
52
59
  .filter(([k]) => vars[k])
53
60
  .map(([k, v]) => [color.name(k), v]));
@@ -34,11 +34,17 @@ Unsetting RAILS_ENV, RACK_ENV and restarting example... done, v10`)];
34
34
  }
35
35
  const vars = argv.map(v => color.name(v)).join(', ');
36
36
  ux.action.start(`Unsetting ${vars} and restarting ${color.app(flags.app)}`);
37
- await this.heroku.patch(`/apps/${flags.app}/config-vars`, {
38
- // body will be like {FOO: null, BAR: null}
39
- body: Object.fromEntries(argv.map(v => [v, null])),
40
- });
41
- const release = await lastRelease();
42
- ux.action.stop('done, ' + color.name(`v${release.version}`));
37
+ try {
38
+ await this.heroku.patch(`/apps/${flags.app}/config-vars`, {
39
+ // body will be like {FOO: null, BAR: null}
40
+ body: Object.fromEntries(argv.map(v => [v, null])),
41
+ });
42
+ const release = await lastRelease();
43
+ ux.action.stop('done, ' + color.name(`v${release.version}`));
44
+ }
45
+ catch (error) {
46
+ ux.action.stop(color.red('!'));
47
+ throw error;
48
+ }
43
49
  }
44
50
  }
@@ -1,11 +1,13 @@
1
1
  import { Command, flags } from '@heroku-cli/command';
2
2
  import * as color from '@heroku/heroku-cli-util/color';
3
- import socks from '@heroku/socksv5';
4
3
  import { Args, ux } from '@oclif/core';
4
+ import debug from 'debug';
5
5
  import net from 'node:net';
6
+ import { SocksClient } from 'socks';
6
7
  import tsheredoc from 'tsheredoc';
7
8
  import { HerokuExec } from '../../lib/ps-exec/exec.js';
8
9
  const heredoc = tsheredoc.default;
10
+ const forwardDebug = debug('cli:ps:forward');
9
11
  export default class Forward extends Command {
10
12
  static args = {
11
13
  port: Args.string({ description: 'port or list of ports to forward', required: true }),
@@ -48,27 +50,72 @@ export default class Forward extends Command {
48
50
  // that's ok because localPort is only here for backwards compat
49
51
  return [ports[0], ports[1] || localPort || ports[0]];
50
52
  });
53
+ // Each local listener binds asynchronously, so we don't know until its
54
+ // 'listening'/'error' event whether the port was actually claimed. Resolve
55
+ // this once every listener has settled with the count that bound, so run()
56
+ // can fail loudly if forwarding nothing (every requested port in use)
57
+ // instead of silently exiting 0.
58
+ let resolveBoundCount;
59
+ const boundCountPromise = new Promise(resolve => {
60
+ resolveBoundCount = resolve;
61
+ });
51
62
  await exec.initFeature(context, this.heroku, async (configVars) => {
52
63
  await exec.createSocksProxy(context, this.heroku, configVars, (dynoIp, dynoName, socksPort) => {
64
+ let bound = 0;
65
+ let settled = 0;
66
+ const onSettled = () => {
67
+ settled++;
68
+ if (settled === portMappings.length)
69
+ resolveBoundCount(bound);
70
+ };
53
71
  for (const portMapping of portMappings) {
54
72
  const [localPortNum, remotePort] = portMapping;
55
- ux.stdout(`Listening on ${color.bold(localPortNum)} and forwarding to ${color.bold(`${dynoName}:${remotePort}`)}`);
56
73
  net.createServer(connIn => {
57
- socks.connect({
58
- auths: [socks.auth.None()], // eslint-disable-line new-cap
59
- host: '0.0.0.0',
60
- port: Number.parseInt(remotePort, 10),
61
- proxyHost: 'localhost',
62
- proxyPort: socksPort,
63
- }, socket => {
64
- connIn.pipe(socket);
65
- socket.pipe(connIn);
74
+ // Without a handler a socket reset (e.g. the peer hangs up) surfaces
75
+ // as an unhandled 'error' and crashes the CLI.
76
+ connIn.on('error', err => {
77
+ forwardDebug('local connection error: %o', err);
78
+ connIn.destroy();
79
+ });
80
+ SocksClient.createConnection({
81
+ command: 'connect',
82
+ destination: { host: '0.0.0.0', port: Number.parseInt(remotePort, 10) },
83
+ proxy: { host: 'localhost', port: socksPort, type: 5 },
84
+ }, (err, info) => {
85
+ if (err || !info) {
86
+ forwardDebug('SOCKS connection to %s:%s failed: %o', dynoName, remotePort, err);
87
+ connIn.destroy();
88
+ return;
89
+ }
90
+ info.socket.on('error', socketErr => {
91
+ forwardDebug('proxied socket error: %o', socketErr);
92
+ connIn.destroy();
93
+ });
94
+ connIn.pipe(info.socket);
95
+ info.socket.pipe(connIn);
66
96
  });
67
- }).listen(Number.parseInt(localPortNum, 10));
97
+ // Bind loopback-only so the forwarded port is reachable only from
98
+ // this machine — it fronts a no-auth tunnel into the dyno, so it must
99
+ // not be exposed to other hosts on the LAN.
100
+ }).listen(Number.parseInt(localPortNum, 10), '127.0.0.1')
101
+ .on('listening', () => {
102
+ bound++;
103
+ ux.stdout(`Listening on ${color.bold(localPortNum)} and forwarding to ${color.bold(`${dynoName}:${remotePort}`)}`);
104
+ onSettled();
105
+ })
106
+ .on('error', (err) => {
107
+ const detail = err.code === 'EADDRINUSE' ? 'port is already in use' : err.message;
108
+ ux.warn(`Cannot forward to ${dynoName}:${remotePort} on local port ${localPortNum}: ${detail}`);
109
+ onSettled();
110
+ });
68
111
  }
69
- ux.stdout(`Use ${color.magenta('CTRL+C')} to stop port forwarding`);
70
112
  });
71
113
  }, 'forward');
114
+ const boundCount = await boundCountPromise;
115
+ if (boundCount === 0) {
116
+ this.error('Could not forward any of the requested ports; every requested local port is already in use.');
117
+ }
118
+ ux.stdout(`Use ${color.magenta('CTRL+C')} to stop port forwarding`);
72
119
  // Keep the process running until interrupted
73
120
  await new Promise(resolve => {
74
121
  process.once('SIGINT', resolve);
@@ -1,5 +1,5 @@
1
1
  import debug from 'debug';
2
- import { got } from 'got';
2
+ import got from 'got';
3
3
  import { createReadStream, promises as fs } from 'node:fs';
4
4
  import { gitService } from './git.js';
5
5
  const ciDebug = debug('ci');
@@ -4,7 +4,7 @@ function parseProcfile(content) {
4
4
  if (/^\s*#/.test(line)) {
5
5
  return false;
6
6
  }
7
- if (/\w/.test(line) && !/^\s*\w+:/.test(line)) {
7
+ if (/\w/.test(line) && !/^\s*[\w-]+:/.test(line)) {
8
8
  throw new Error('line ' + (i + 1) + ' parse error: ' + line);
9
9
  }
10
10
  return line.match(/\w/);
@@ -0,0 +1,46 @@
1
+ import net from 'node:net';
2
+ /**
3
+ * A minimal SOCKS5 server supporting only the CONNECT command with no
4
+ * authentication — the exact subset the `heroku ps:socks`/`ps:forward`
5
+ * commands require.
6
+ *
7
+ * Unlike a general SOCKS proxy, this server never dials the destination
8
+ * itself: the destination lives inside the dyno's private network and is only
9
+ * reachable through an SSH tunnel. Instead, the connection handler receives the
10
+ * parsed request and an `accept()` callback that hands back the raw client
11
+ * socket, so the caller can pipe it to an SSH `forwardOut` stream.
12
+ *
13
+ * @see https://datatracker.ietf.org/doc/html/rfc1928
14
+ */
15
+ export interface SocksRequestInfo {
16
+ cmd: number;
17
+ dstAddr: string;
18
+ dstPort: number;
19
+ srcAddr: string;
20
+ srcPort: number;
21
+ }
22
+ /**
23
+ * Approves the connection: sends the SOCKS success reply and returns the raw
24
+ * client socket for the caller to bridge to its own upstream. This server only
25
+ * supports this intercept mode — it never dials the destination itself. Returns
26
+ * `null` if the connection was already settled by a prior `accept`/`deny` or the
27
+ * socket is no longer writable.
28
+ */
29
+ export type AcceptFn = () => net.Socket | null;
30
+ export type DenyFn = () => void;
31
+ export type ConnectionHandler = (info: SocksRequestInfo, accept: AcceptFn, deny: DenyFn) => void;
32
+ export declare class Socks5Server {
33
+ private readonly handler;
34
+ readonly server: net.Server;
35
+ constructor(handler: ConnectionHandler);
36
+ close(callback?: () => void): this;
37
+ listen(port: number, host: string, callback?: () => void): this;
38
+ on(event: 'error', listener: (err: NodeJS.ErrnoException) => void): this;
39
+ private _handleConnection;
40
+ private _readAddress;
41
+ }
42
+ declare function createServer(handler: ConnectionHandler): Socks5Server;
43
+ declare const _default: {
44
+ createServer: typeof createServer;
45
+ };
46
+ export default _default;
@@ -0,0 +1,172 @@
1
+ // cspell:ignore atyp nmethods
2
+ import net from 'node:net';
3
+ const SOCKS_VERSION = 0x05;
4
+ const AUTH_NONE = 0x00;
5
+ const AUTH_NO_ACCEPTABLE = 0xFF;
6
+ const CMD_CONNECT = 0x01;
7
+ const ATYP_IPV4 = 0x01;
8
+ const ATYP_DOMAIN = 0x03;
9
+ const ATYP_IPV6 = 0x04;
10
+ const REP_SUCCESS = 0x00;
11
+ const REP_GENERAL_FAILURE = 0x01;
12
+ const REP_CMD_NOT_SUPPORTED = 0x07;
13
+ // Guardrails against a client that connects but stalls or floods before the
14
+ // handshake completes. A well-behaved client sends only a few hundred bytes and
15
+ // finishes promptly; anything larger or slower is dropped.
16
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
17
+ const MAX_HANDSHAKE_BYTES = 1024;
18
+ // Replies carry a null bind address (0.0.0.0:0); clients doing CONNECT ignore it.
19
+ const reply = (rep) => Buffer.from([SOCKS_VERSION, rep, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0]);
20
+ export class Socks5Server {
21
+ handler;
22
+ server;
23
+ constructor(handler) {
24
+ this.handler = handler;
25
+ this.server = net.createServer(socket => this._handleConnection(socket));
26
+ }
27
+ close(callback) {
28
+ this.server.close(callback);
29
+ return this;
30
+ }
31
+ listen(port, host, callback) {
32
+ this.server.listen(port, host, callback);
33
+ return this;
34
+ }
35
+ // Passthrough for the underlying server's 'error' event (e.g. an EADDRINUSE
36
+ // bind failure), so callers can report it cleanly instead of letting it
37
+ // surface as an unhandled 'error' that crashes the process.
38
+ on(event, listener) {
39
+ this.server.on(event, listener);
40
+ return this;
41
+ }
42
+ _handleConnection(socket) {
43
+ socket.on('error', () => socket.destroy());
44
+ // Drop a client that connects but never finishes the handshake, so a slow or
45
+ // idle peer can't pin the socket open. Cleared once the request is parsed.
46
+ socket.setTimeout(HANDSHAKE_TIMEOUT_MS);
47
+ socket.on('timeout', () => socket.destroy());
48
+ let settled = false;
49
+ let buffer = Buffer.alloc(0);
50
+ // Sequential reader over the accumulating buffer. Each step consumes a fixed
51
+ // number of bytes once they are available and advances to the next.
52
+ const steps = [];
53
+ const readBytes = (need, run) => steps.push({ need, run });
54
+ const pump = () => {
55
+ while (steps.length > 0 && buffer.length >= steps[0].need) {
56
+ const { need, run } = steps.shift();
57
+ const bytes = buffer.subarray(0, need);
58
+ buffer = buffer.subarray(need);
59
+ run(bytes);
60
+ }
61
+ };
62
+ const onData = (chunk) => {
63
+ buffer = Buffer.concat([buffer, chunk]);
64
+ // While still parsing the handshake, a well-behaved client sends only a
65
+ // few hundred bytes; anything larger is abnormal, so refuse it rather than
66
+ // buffering without bound.
67
+ if (steps.length > 0 && buffer.length > MAX_HANDSHAKE_BYTES) {
68
+ socket.destroy();
69
+ return;
70
+ }
71
+ pump();
72
+ };
73
+ // Greeting: VER, NMETHODS, METHODS...
74
+ readBytes(2, header => {
75
+ if (header[0] !== SOCKS_VERSION)
76
+ return socket.destroy();
77
+ readBytes(header[1], methods => {
78
+ if (!methods.includes(AUTH_NONE)) {
79
+ socket.end(Buffer.from([SOCKS_VERSION, AUTH_NO_ACCEPTABLE]));
80
+ return;
81
+ }
82
+ socket.write(Buffer.from([SOCKS_VERSION, AUTH_NONE]));
83
+ // Request: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT
84
+ readBytes(4, head => {
85
+ const [ver, cmd, , atyp] = head;
86
+ if (ver !== SOCKS_VERSION)
87
+ return socket.destroy();
88
+ this._readAddress(atyp, readBytes, addr => {
89
+ if (addr === null) {
90
+ socket.end(reply(REP_GENERAL_FAILURE));
91
+ return;
92
+ }
93
+ readBytes(2, portBuf => {
94
+ const dstPort = portBuf.readUInt16BE(0);
95
+ if (cmd !== CMD_CONNECT) {
96
+ socket.end(reply(REP_CMD_NOT_SUPPORTED));
97
+ return;
98
+ }
99
+ const info = {
100
+ cmd,
101
+ dstAddr: addr,
102
+ dstPort,
103
+ srcAddr: socket.remoteAddress ?? '',
104
+ srcPort: socket.remotePort ?? 0,
105
+ };
106
+ const accept = () => {
107
+ if (settled || !socket.writable)
108
+ return null;
109
+ settled = true;
110
+ socket.write(reply(REP_SUCCESS));
111
+ // Stop intercepting: detach our reader and hand back any bytes
112
+ // that arrived after the request so piping sees a clean stream.
113
+ socket.removeListener('data', onData);
114
+ if (buffer.length > 0) {
115
+ socket.unshift(buffer);
116
+ buffer = Buffer.alloc(0);
117
+ }
118
+ return socket;
119
+ };
120
+ const deny = () => {
121
+ // Ignore a deny once the connection is accepted (or already
122
+ // denied): writing a SOCKS reply into a live tunnel corrupts it.
123
+ if (settled)
124
+ return;
125
+ settled = true;
126
+ if (socket.writable)
127
+ socket.end(reply(REP_GENERAL_FAILURE));
128
+ };
129
+ // The handshake is parsed: stop the timeout and pause reads so a
130
+ // client can't flood us while the handler establishes the upstream
131
+ // tunnel. accept() detaches the reader and piping resumes flow.
132
+ socket.setTimeout(0);
133
+ socket.pause();
134
+ this.handler(info, accept, deny);
135
+ });
136
+ });
137
+ });
138
+ });
139
+ });
140
+ socket.on('data', onData);
141
+ }
142
+ // DST.ADDR is length-prefixed for domains and fixed-width for IPs. Calls back
143
+ // with the decoded address, or null on an unsupported address type.
144
+ _readAddress(atyp, readBytes, done) {
145
+ switch (atyp) {
146
+ case ATYP_DOMAIN: {
147
+ readBytes(1, lenBuf => readBytes(lenBuf[0], nameBuf => done(nameBuf.toString('utf8'))));
148
+ break;
149
+ }
150
+ case ATYP_IPV4: {
151
+ readBytes(4, b => done(`${b[0]}.${b[1]}.${b[2]}.${b[3]}`));
152
+ break;
153
+ }
154
+ case ATYP_IPV6: {
155
+ readBytes(16, b => {
156
+ const groups = [];
157
+ for (let i = 0; i < 16; i += 2)
158
+ groups.push(b.readUInt16BE(i).toString(16));
159
+ done(groups.join(':'));
160
+ });
161
+ break;
162
+ }
163
+ default: {
164
+ done(null);
165
+ }
166
+ }
167
+ }
168
+ }
169
+ function createServer(handler) {
170
+ return new Socks5Server(handler);
171
+ }
172
+ export default { createServer };
@@ -12,4 +12,6 @@ export declare class HerokuSsh {
12
12
  private _logConnectionError;
13
13
  private _readData;
14
14
  private _readStdin;
15
+ private _reportProxyBindError;
16
+ private _stdinToRemote;
15
17
  }
@@ -1,5 +1,5 @@
1
1
  import * as color from '@heroku/heroku-cli-util/color';
2
- import socks from '@heroku/socksv5';
2
+ import { Errors } from '@oclif/core';
3
3
  import { ux } from '@oclif/core/ux';
4
4
  import cliProgress from 'cli-progress';
5
5
  import debug from 'debug';
@@ -11,7 +11,12 @@ import path from 'node:path';
11
11
  import stream from 'node:stream';
12
12
  import tty from 'node:tty';
13
13
  import { Client } from 'ssh2';
14
+ import socks5 from './socks5-server.js';
14
15
  const sshDebug = debug('cli:ps-exec:ssh');
16
+ // Upper bound on simultaneously-live SOCKS connections (each is its own SSH
17
+ // session). Generous enough for a browser fanning out, low enough to keep a
18
+ // runaway local client from exhausting fds/SSH sessions.
19
+ const MAX_SOCKS_CONNECTIONS = 128;
15
20
  export class HerokuSsh {
16
21
  connect(context, addonHost, dynoUser, privateKey, proxyKey, callback) {
17
22
  return new Promise((resolve, reject) => {
@@ -136,36 +141,76 @@ export class HerokuSsh {
136
141
  }
137
142
  socksv5(addonHost, dynoUser, privateKey, proxyKey, callback) {
138
143
  const socksPort = 1080;
139
- socks.createServer((info, accept, deny) => {
144
+ // Each accepted SOCKS request opens its own SSH connection, so cap how many
145
+ // can be live at once. Without this a client that fans out many parallel
146
+ // connections (a browser, or a hostile local process) can drive up fd/memory
147
+ // and SSH-session load on both the CLI and the dyno's sshd.
148
+ let activeConnections = 0;
149
+ socks5.createServer((info, accept, deny) => {
150
+ if (activeConnections >= MAX_SOCKS_CONNECTIONS) {
151
+ sshDebug(`refusing SOCKS request: ${activeConnections} connections already active (max ${MAX_SOCKS_CONNECTIONS})`);
152
+ return deny();
153
+ }
154
+ activeConnections++;
140
155
  const conn = new Client();
156
+ let released = false;
157
+ // Free the slot exactly once, whenever this SSH connection closes.
158
+ const release = () => {
159
+ if (released)
160
+ return;
161
+ released = true;
162
+ activeConnections--;
163
+ };
164
+ conn.on('close', release);
165
+ let clientSocket = null;
166
+ const teardown = () => {
167
+ if (clientSocket)
168
+ clientSocket.destroy();
169
+ conn.end();
170
+ };
141
171
  conn.on('ready', () => {
142
172
  conn.forwardOut(info.srcAddr, info.srcPort, info.dstAddr, info.dstPort, (err, stream) => {
143
173
  if (err) {
144
174
  conn.end();
145
175
  return deny();
146
176
  }
147
- const clientSocket = accept(true);
148
- if (clientSocket) {
149
- stream.pipe(clientSocket).pipe(stream).on('close', () => {
150
- conn.end();
151
- });
177
+ // Guard the forwardOut stream against an unhandled 'error' before the
178
+ // accept() null-check below: if accept() returns null we abandon this
179
+ // stream (conn.end() emits 'close', not 'error'), but a late error on
180
+ // the orphaned stream would otherwise go unhandled and crash the CLI.
181
+ stream.on('error', teardown);
182
+ clientSocket = accept();
183
+ if (!clientSocket) {
184
+ conn.end();
185
+ return;
152
186
  }
153
- else
187
+ // Tear the tunnel down if either side errors, rather than letting an
188
+ // unhandled 'error' event crash the CLI.
189
+ clientSocket.on('error', teardown);
190
+ stream.pipe(clientSocket).pipe(stream).on('close', () => {
154
191
  conn.end();
192
+ });
155
193
  });
156
194
  }).on('error', () => {
157
- deny();
195
+ // Before accept, reject the SOCKS request. After, the tunnel is live, so
196
+ // tear it down instead of injecting a late SOCKS reply into the stream.
197
+ if (clientSocket)
198
+ teardown();
199
+ else
200
+ deny();
158
201
  }).connect({
159
202
  ...this._connectionDefaults(proxyKey),
160
203
  host: addonHost,
161
204
  privateKey,
162
205
  username: dynoUser,
163
206
  });
207
+ }).on('error', (err) => {
208
+ this._reportProxyBindError(err, socksPort);
164
209
  }).listen(socksPort, '127.0.0.1', () => {
165
- console.log(`SOCKSv5 proxy server started on port ${color.info(socksPort.toString())}`);
210
+ ux.stdout(`SOCKSv5 proxy server started on port ${color.info(socksPort.toString())}`);
166
211
  if (callback)
167
212
  callback(socksPort);
168
- }).useAuth(socks.auth.None()); // eslint-disable-line new-cap
213
+ });
169
214
  }
170
215
  async ssh(context, addonHost, dynoUser, privateKey, proxyKey) {
171
216
  sshDebug('[cli-ssh] native');
@@ -218,11 +263,22 @@ export class HerokuSsh {
218
263
  return {
219
264
  hostHash: 'sha256',
220
265
  hostVerifier(hashedKey) {
266
+ // proxyKey is the API-provided "ssh-rsa <base64>" host key. If it's
267
+ // empty or missing the base64 blob, fail closed rather than throwing
268
+ // out of the verifier on `Buffer.from(undefined, ...)`.
269
+ const keyBlob = proxyKey?.split(' ')[1];
270
+ if (!keyBlob) {
271
+ sshDebug('proxy public key is missing or malformed; refusing host key');
272
+ return false;
273
+ }
221
274
  const hasher = crypto.createHash('sha256');
222
- hasher.update(Buffer.from(proxyKey.split(' ')[1], 'base64'));
275
+ hasher.update(Buffer.from(keyBlob, 'base64'));
223
276
  return hasher.digest('hex') === hashedKey;
224
277
  },
225
278
  port: 80,
279
+ // Bound how long a connecting client is held before we give up, so a
280
+ // stalled SSH endpoint can't pin a paused SOCKS client socket forever.
281
+ readyTimeout: 20_000,
226
282
  };
227
283
  }
228
284
  _logConnectionError() {
@@ -260,11 +316,38 @@ export class HerokuSsh {
260
316
  });
261
317
  }
262
318
  else {
263
- stdin.pipe(new stream.Transform({
264
- flush: done => c.write('\u0004', done),
265
- objectMode: true,
266
- transform: (chunk, _, next) => c.write(chunk, next),
267
- }));
319
+ stdin.pipe(this._stdinToRemote(c));
268
320
  }
269
321
  }
322
+ // A SOCKS-server bind failure fires asynchronously, outside run()'s
323
+ // error-handling chain, so throwing here (ux.error) would surface as an
324
+ // unhandled crash with a raw stack trace. Route it through oclif's handler so
325
+ // it prints cleanly and exits non-zero. (Extracted as a seam so tests can stub
326
+ // it — Errors.handle exits the process and can't be stubbed as an ESM export.)
327
+ _reportProxyBindError(err, socksPort) {
328
+ const detail = err.code === 'EADDRINUSE'
329
+ ? `port ${socksPort} is already in use — stop the process using it and try again`
330
+ : err.message;
331
+ Errors.handle(new Errors.CLIError(`Could not start the SOCKS proxy server: ${detail}`, { exit: 1 }));
332
+ }
333
+ // Bridges piped stdin to the remote exec stream, appending an EOF (^D) when
334
+ // stdin ends. For a non-interactive command the remote stream (`c`) closes as
335
+ // soon as the command exits — which can happen before stdin reaches EOF — so
336
+ // every write is guarded on `c.writable` (don't write the trailing EOF to an
337
+ // already-ended stream) and a late write-after-end is swallowed as the
338
+ // teardown race it is, rather than surfacing as an unhandled error that
339
+ // crashes the CLI.
340
+ _stdinToRemote(c) {
341
+ const transform = new stream.Transform({
342
+ // Pass the stream callbacks straight through so a genuine write error
343
+ // reaches the 'error' handler below instead of being silently dropped.
344
+ flush: done => (c.writable ? c.write('\u0004', done) : done()),
345
+ transform: (chunk, _, next) => (c.writable ? c.write(chunk, next) : next()),
346
+ });
347
+ transform.on('error', (error) => {
348
+ if (error.code !== 'ERR_STREAM_WRITE_AFTER_END')
349
+ sshDebug(error);
350
+ });
351
+ return transform;
352
+ }
270
353
  }
@@ -21161,5 +21161,5 @@
21161
21161
  ]
21162
21162
  }
21163
21163
  },
21164
- "version": "11.10.0"
21164
+ "version": "11.10.1-beta.0"
21165
21165
  }
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "heroku",
3
3
  "description": "CLI to interact with Heroku",
4
- "version": "11.10.0",
4
+ "version": "11.10.1-beta.0",
5
5
  "author": "Heroku",
6
6
  "bin": "./bin/run.js",
7
7
  "bugs": "https://github.com/heroku/cli/issues",
8
8
  "dependencies": {
9
- "@heroku-cli/command": "^13.0.0",
10
- "@heroku-cli/notifications": "^1.2.6",
9
+ "@heroku-cli/command": "^13.2.0",
10
+ "@heroku-cli/notifications": "^1.2.9",
11
11
  "@heroku-cli/schema": "^1.0.25",
12
12
  "@heroku/buildpack-registry": "^1.0.1",
13
13
  "@heroku/heroku-cli-util": "^10.10.0",
14
14
  "@heroku/http-call": "^5.5.1",
15
15
  "@heroku/mcp-server": "^1.2.5",
16
- "@heroku/socksv5": "^0.0.9",
17
- "@inquirer/prompts": "^7.0",
16
+ "@inquirer/prompts": "^8.7",
18
17
  "@oclif/core": "^4.8.4",
19
18
  "@oclif/plugin-commands": "^4.1.40",
20
19
  "@oclif/plugin-help": "^6.2.37",
@@ -29,10 +28,10 @@
29
28
  "@opentelemetry/api": "^1.9.0",
30
29
  "@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
31
30
  "@opentelemetry/instrumentation": "^0.52.1",
32
- "@opentelemetry/resources": "^1.25.1",
33
- "@opentelemetry/sdk-trace-base": "^1.25.1",
34
- "@opentelemetry/sdk-trace-node": "^1.25.1",
35
- "@opentelemetry/semantic-conventions": "^1.25.1",
31
+ "@opentelemetry/resources": "^1.30.1",
32
+ "@opentelemetry/sdk-trace-base": "^1.30.1",
33
+ "@opentelemetry/sdk-trace-node": "^1.30.1",
34
+ "@opentelemetry/semantic-conventions": "^1.30.1",
36
35
  "@sentry/node": "^10.27",
37
36
  "@sentry/opentelemetry": "^10.27.0",
38
37
  "@types/js-yaml": "^3.12.5",
@@ -42,14 +41,14 @@
42
41
  "chrono-node": "^2.7.6",
43
42
  "cli-progress": "^3.12.0",
44
43
  "commander": "^2.15.1",
45
- "date-fns": "^2.30.0",
44
+ "date-fns": "^4.4.0",
46
45
  "debug": "^4.4.3",
47
46
  "eventsource": "^4",
48
47
  "execa": "^9.6.1",
49
48
  "filesize": "^10.1",
50
49
  "fs-extra": "^11.3.0",
51
50
  "glob": "^13.0.2",
52
- "got": "^13.0.0",
51
+ "got": "^14.0.0",
53
52
  "https-proxy-agent": "^7.0.6",
54
53
  "inquirer": "^8.2.6",
55
54
  "keypair": "^1.0.4",
@@ -66,6 +65,7 @@
66
65
  "printf": "0.6.1",
67
66
  "redis-parser": "^3.0.0",
68
67
  "shell-quote": "^1.8.1",
68
+ "socks": "^2.8.8",
69
69
  "ssh2": "^1.16.0",
70
70
  "strftime": "^0.10.0",
71
71
  "term-img": "^7.1.0",
@@ -78,7 +78,7 @@
78
78
  "word-wrap": "^1.2.5",
79
79
  "ws": "^6.2.2",
80
80
  "yaml": "^2.0.1",
81
- "yargs-parser": "18.1.3"
81
+ "yargs-parser": "22.0.0"
82
82
  },
83
83
  "devDependencies": {
84
84
  "@actions/core": "^3.0.0",
@@ -374,7 +374,6 @@
374
374
  "node-notifier": {
375
375
  "uuid": "^11.1.1"
376
376
  },
377
- "socks": "^2.8.8",
378
377
  "express-rate-limit": "^8.5.1"
379
378
  },
380
379
  "repository": "heroku/cli",