Skip to content

Commit

Permalink
Merge pull request from GHSA-jvmr-5fpx-r76j
Browse files Browse the repository at this point in the history
* Enforce allowlist for query parameters

Also mitigate other argument injection

* Add allow-remote-command option
  • Loading branch information
Nils1729 committed Sep 16, 2023
1 parent 6f7f35d commit e68f209
Show file tree
Hide file tree
Showing 10 changed files with 85 additions and 74 deletions.
7 changes: 6 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ const opts = yargs(hideBin(process.argv))
})
.option('allow-remote-hosts', {
description:
'Allow WeTTY to use the `host` param in a url as ssh destination',
'Allow WeTTY to use the `host` and `port` params in a url as ssh destination',
type: 'boolean',
})
.option('allow-remote-command', {
description:
'Allow WeTTY to use the `command` and `path` params in a url as command and working directory on ssh host',
type: 'boolean',
})
.option('log-level', {
Expand Down
12 changes: 2 additions & 10 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ import express from 'express';
import gc from 'gc-stats';
import { Gauge, collectDefaultMetrics } from 'prom-client';
import { getCommand } from './server/command.js';
import { login } from './server/login.js';
import { gcMetrics } from './server/metrics.js';
import { escapeShell } from './server/shared/shell.js';
import { server } from './server/socketServer.js';
import { spawn } from './server/spawn.js';
import {
Expand Down Expand Up @@ -75,17 +73,11 @@ export async function decorateServerWithSsh(
* @name connection
*/
logger.info('Connection accepted.');
const [args, sshUser] = getCommand(socket, ssh, command, forcessh);
const cmd = args.join(' ');
logger.debug('Command Generated', { user: sshUser, cmd });
wettyConnections.inc();

try {
if (!sshUser) {
const username = await login(socket);
args[1] = `${escapeShell(username.trim())}@${args[1]}`;
logger.debug('Spawning term', { username: username.trim(), cmd });
}
const args = await getCommand(socket, ssh, command, forcessh);
logger.debug('Command Generated', { cmd: args.join(' ') });
await spawn(socket, args);
} catch (error) {
logger.info('Disconnect signal sent', { err: error });
Expand Down
53 changes: 30 additions & 23 deletions src/server/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,23 @@ const localhost = (host: string): boolean =>

const urlArgs = (
referer: string | undefined,
def: { [s: string]: string },
{
allowRemoteCommand,
allowRemoteHosts,
}: {
allowRemoteCommand: boolean;
allowRemoteHosts: boolean;
},
): { [s: string]: string } =>
Object.assign(def, url.parse(referer || '', true).query);
_.pick(
_.pickBy(url.parse(referer || '', true).query, _.isString),
['pass'],
allowRemoteCommand ? ['command', 'path'] : [],
allowRemoteHosts ? ['port', 'host'] : [],
);

export function getCommand(
{
request: { headers },
client: {
conn: { remoteAddress },
},
}: Socket,
export async function getCommand(
socket: Socket,
{
user,
host,
Expand All @@ -35,29 +41,30 @@ export function getCommand(
knownHosts,
config,
allowRemoteHosts,
allowRemoteCommand,
}: SSH,
command: string,
forcessh: boolean,
): [string[], boolean] {
const sshAddress = address(headers, user, host);
forcessh: boolean
): Promise<string[]> {
const {
request: { headers: { referer } },
client: { conn: { remoteAddress } },
} = socket;

if (!forcessh && localhost(host)) {
return [loginOptions(command, remoteAddress), true];
return loginOptions(command, remoteAddress);
}
const args = urlArgs(headers.referer, {

const sshAddress = await address(socket, user, host);
const args = {
host: sshAddress,
port: `${port}`,
pass: pass || '',
command,
auth,
knownHosts,
config: config || '',
});
if (!allowRemoteHosts) {
args.host = sshAddress;
}

return [
sshOptions(args, key),
user !== '' || user.includes('@') || sshAddress.includes('@'),
];
...urlArgs(referer, { allowRemoteHosts, allowRemoteCommand }),
};
return sshOptions(args, key);
}
37 changes: 23 additions & 14 deletions src/server/command/address.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
import _ from 'lodash';
import { Socket } from 'socket.io';
import { login } from '../login.js';
import { escapeShell } from '../shared/shell.js';
import type { IncomingHttpHeaders } from 'http';

export function address(
headers: IncomingHttpHeaders,
export async function address(
socket: Socket,
user: string,
host: string,
): string {
): Promise<string> {
// Check request-header for username
const remoteUser = headers['remote-user'];
if (!_.isUndefined(remoteUser) && !Array.isArray(remoteUser)) {
return `${escapeShell(remoteUser)}@${host}`;
}
if (!_.isUndefined(headers.referer)) {
const match = headers.referer.match('.+/ssh/([^/]+)$');
if (match) {
const username = escapeShell(match[1].split('?')[0]);
return `${username}@${host}`;
const { request: { headers: {
'remote-user': userFromHeader,
referer
} } } = socket;

let username: string | undefined;
if (!_.isUndefined(userFromHeader) && !Array.isArray(userFromHeader)) {
username = userFromHeader;
} else {
const userFromPathMatch = referer?.match('.+/ssh/([^/]+)$');
if (userFromPathMatch) {
// eslint-disable-next-line prefer-destructuring
username = userFromPathMatch[1].split('?')[0];
} else if (user) {
username = user;
} else {
username = await login(socket);
}
}
return user ? `${escapeShell(user)}@${host}` : host;
return `${escapeShell(username)}@${host}`;
}
40 changes: 15 additions & 25 deletions src/server/command/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,22 @@ export function sshOptions(
const cmd = parseCommand(command, path);
const hostChecking = knownHosts !== '/dev/null' ? 'yes' : 'no';
logger().info(`Authentication Type: ${auth}`);
let sshRemoteOptsBase = ['ssh', host, '-t'];
if (!isUndefined(config) && config !== '') {
sshRemoteOptsBase = sshRemoteOptsBase.concat(['-F', config]);
}
if (!isUndefined(port) && port !== '') {
sshRemoteOptsBase = sshRemoteOptsBase.concat(['-p', port]);
}
sshRemoteOptsBase = sshRemoteOptsBase.concat([
'-o',
`PreferredAuthentications=${auth}`,
'-o',
`UserKnownHostsFile=${knownHosts}`,
'-o',
`StrictHostKeyChecking=${hostChecking}`,
]);
if (!isUndefined(key)) {
return sshRemoteOptsBase.concat(['-i', key, cmd]);
}
if (pass !== '') {
return ['sshpass', '-p', pass].concat(sshRemoteOptsBase, [cmd]);
}
if (auth === 'none') {
sshRemoteOptsBase.splice(sshRemoteOptsBase.indexOf('-o'), 2);
}

return cmd === '' ? sshRemoteOptsBase : sshRemoteOptsBase.concat([cmd]);
return [
...pass ? ['sshpass', '-p', pass] : [],
'ssh',
'-t',
...config ? ['-F', config] : [],
...port ? ['-p', port] : [],
...key ? ['-i', key] : [],
...auth !== 'none' ? ['-o', `PreferredAuthentications=${auth}`] : [],
'-o', `UserKnownHostsFile=${knownHosts}`,
'-o', `StrictHostKeyChecking=${hostChecking}`,
'-o', 'EscapeChar=none',
'--',
host,
...cmd ? [cmd] : [],
];
}

function parseCommand(command: string, path?: string): string {
Expand Down
5 changes: 5 additions & 0 deletions src/server/shared/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ describe('Values passed to escapeShell should be safe to pass woth sub processes
const cmd = escapeShell("-oProxyCommand='bash' -c `wget localhost:2222`");
expect(cmd).to.equal('oProxyCommandbash-cwgetlocalhost2222');
});

it('should remove dashes even when there are illegal characters before them', () => {
const cmd = escapeShell("`-oProxyCommand='bash' -c `wget localhost:2222`");
expect(cmd).to.equal('oProxyCommandbash-cwgetlocalhost2222');
});
});
2 changes: 1 addition & 1 deletion src/server/shared/shell.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const escapeShell = (username: string): string =>
// eslint-disable-next-line no-useless-escape
username.replace(/^-|[^a-zA-Z0-9_\\\-\.\@-]/g, '');
username.replace(/[^a-zA-Z0-9_\\\-\.\@-]/g, '').replace(/^-+/g, '');
1 change: 1 addition & 0 deletions src/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export function mergeCliConf(opts: Arguments, config: Config): Config {
pass: opts['ssh-pass'],
key: opts['ssh-key'],
allowRemoteHosts: opts['allow-remote-hosts'],
allowRemoteCommand: opts['allow-remote-command'],
config: opts['ssh-config'],
knownHosts: opts['known-hosts'],
}) as SSH,
Expand Down
1 change: 1 addition & 0 deletions src/shared/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const sshDefault: SSH = {
port: parseInt(process.env.SSHPORT || '22', 10),
knownHosts: process.env.KNOWNHOSTS || '/dev/null',
allowRemoteHosts: false,
allowRemoteCommand: false,
config: process.env.SSHCONFIG || undefined,
};

Expand Down
1 change: 1 addition & 0 deletions src/shared/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface SSH {
port: number;
knownHosts: string;
allowRemoteHosts: boolean;
allowRemoteCommand: boolean;
pass?: string;
key?: string;
config?: string;
Expand Down

0 comments on commit e68f209

Please sign in to comment.