Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions docker/coolify-realtime/terminal-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,53 @@ export function normalizeHostForAuthorization(host) {
return normalizedHost.toLowerCase();
}

// ssh(1) single-letter options that consume the following argument as their value.
// The value of such an option (e.g. `-o ProxyJump=user@jump`, `-i key@path`) must never
// be mistaken for the connection destination when authorizing a target host.
const SSH_VALUE_OPTION_FLAGS = new Set([
'-B', '-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l',
'-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w',
]);

export function extractTargetHost(sshArgs) {
const userAtHost = sshArgs.find(arg => {
if (arg.includes('storage/app/ssh/keys/')) {
return false;
if (!Array.isArray(sshArgs)) {
throw new TypeError('extractTargetHost expects an array of ssh arguments');
}

// The real ssh destination is the first positional `user@host` argument: not an
// option flag, not the value consumed by a preceding value-taking flag, and not an
// option assignment (`key=value`) or identity-file path. Scanning for the first
// `user@host` token anywhere is unsafe — a crafted command can smuggle an authorized
// host into an option value (`-o ProxyJump=user@authorized`) while connecting elsewhere.
let skipNextValue = false;

for (const arg of sshArgs) {
if (skipNextValue) {
skipNextValue = false;
continue;
}

return /^[^@]+@[^@]+$/.test(arg);
});
if (typeof arg !== 'string' || arg.length === 0) {
continue;
}

if (!userAtHost) {
return null;
if (arg.startsWith('-')) {
if (SSH_VALUE_OPTION_FLAGS.has(arg)) {
skipNextValue = true;
}
continue;
}

if (arg.includes('=') || arg.includes('storage/app/ssh/keys/')) {
continue;
}

if (/^[^@]+@[^@]+$/.test(arg)) {
return normalizeHostForAuthorization(arg.slice(arg.indexOf('@') + 1));
}
}

const atIndex = userAtHost.indexOf('@');
return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1));
return null;
}

export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) {
Expand Down
36 changes: 36 additions & 0 deletions docker/coolify-realtime/terminal-utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,39 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
});

test('extractTargetHost ignores an authorized host smuggled into a ProxyJump option value', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -o ProxyJump='root@10.0.0.5' -o StrictHostKeyChecking=no 'root'@'192.168.0.99' 'bash -se' << \\\\$abc\necho hi\nabc"
);

// The real connection target is the positional host, not the option value.
assert.equal(extractTargetHost(sshArgs), '192.168.0.99');
// Authorizing the smuggled ProxyJump host would bypass the allowlist.
assert.equal(isAuthorizedTargetHost(extractTargetHost(sshArgs), ['10.0.0.5']), false);
});

test('extractTargetHost ignores ProxyJump passed via the -J value flag', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -J 'root@10.0.0.5' -o StrictHostKeyChecking=no 'root'@'192.168.0.99' 'bash -se' << \\\\$abc\necho hi\nabc"
);

assert.equal(extractTargetHost(sshArgs), '192.168.0.99');
});

test('extractTargetHost still resolves the destination after a port and identity file', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -p '22' -i storage/app/ssh/keys/id@host -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
);

assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
});

test('extractTargetHost returns null when no positional host is present', () => {
assert.equal(extractTargetHost(['-o', 'StrictHostKeyChecking=no']), null);
assert.equal(extractTargetHost([]), null);
});

test('extractTargetHost throws a TypeError on non-array input', () => {
assert.throws(() => extractTargetHost('root@10.0.0.5'), TypeError);
});