diff --git a/paramiko/common.py b/paramiko/common.py index 0b0cc2a..50355f6 100644 --- a/paramiko/common.py +++ b/paramiko/common.py @@ -32,6 +32,7 @@ MSG_USERAUTH_INFO_REQUEST, MSG_USERAUTH_INFO_RESPONSE = range(60, 62) MSG_USERAUTH_GSSAPI_RESPONSE, MSG_USERAUTH_GSSAPI_TOKEN = range(60, 62) MSG_USERAUTH_GSSAPI_EXCHANGE_COMPLETE, MSG_USERAUTH_GSSAPI_ERROR,\ MSG_USERAUTH_GSSAPI_ERRTOK, MSG_USERAUTH_GSSAPI_MIC = range(63, 67) +HIGHEST_USERAUTH_MESSAGE_ID = 79 MSG_GLOBAL_REQUEST, MSG_REQUEST_SUCCESS, MSG_REQUEST_FAILURE = range(80, 83) MSG_CHANNEL_OPEN, MSG_CHANNEL_OPEN_SUCCESS, MSG_CHANNEL_OPEN_FAILURE, \ MSG_CHANNEL_WINDOW_ADJUST, MSG_CHANNEL_DATA, MSG_CHANNEL_EXTENDED_DATA, \ diff --git a/paramiko/transport.py b/paramiko/transport.py index 7906c9f..31df82a 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -49,7 +49,8 @@ from paramiko.common import xffffffff, cMSG_CHANNEL_OPEN, cMSG_IGNORE, \ MSG_CHANNEL_SUCCESS, MSG_CHANNEL_FAILURE, MSG_CHANNEL_DATA, \ MSG_CHANNEL_EXTENDED_DATA, MSG_CHANNEL_WINDOW_ADJUST, MSG_CHANNEL_REQUEST, \ MSG_CHANNEL_EOF, MSG_CHANNEL_CLOSE, MIN_WINDOW_SIZE, MIN_PACKET_SIZE, \ - MAX_WINDOW_SIZE, DEFAULT_WINDOW_SIZE, DEFAULT_MAX_PACKET_SIZE + MAX_WINDOW_SIZE, DEFAULT_WINDOW_SIZE, DEFAULT_MAX_PACKET_SIZE, \ + HIGHEST_USERAUTH_MESSAGE_ID from paramiko.compress import ZlibCompressor, ZlibDecompressor from paramiko.dsskey import DSSKey from paramiko.kex_gex import KexGex, KexGexSHA256 @@ -1720,6 +1721,43 @@ class Transport (threading.Thread, ClosingContextManager): max_packet_size = self.default_max_packet_size return clamp_value(MIN_PACKET_SIZE, max_packet_size, MAX_WINDOW_SIZE) + def _ensure_authed(self, ptype, message): + """ + Checks message type against current auth state. + + If server mode, and auth has not succeeded, and the message is of a + post-auth type (channel open or global request) an appropriate error + response Message is crafted and returned to caller for sending. + + Otherwise (client mode, authed, or pre-auth message) returns None. + """ + if ( + not self.server_mode + or ptype <= HIGHEST_USERAUTH_MESSAGE_ID + or self.is_authenticated() + ): + return None + # WELP. We must be dealing with someone trying to do non-auth things + # without being authed. Tell them off, based on message class. + reply = Message() + # Global requests have no details, just failure. + if ptype == MSG_GLOBAL_REQUEST: + reply.add_byte(cMSG_REQUEST_FAILURE) + # Channel opens let us reject w/ a specific type + message. + elif ptype == MSG_CHANNEL_OPEN: + kind = message.get_text() + chanid = message.get_int() + reply.add_byte(cMSG_CHANNEL_OPEN_FAILURE) + reply.add_int(chanid) + reply.add_int(OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED) + reply.add_string('') + reply.add_string('en') + # NOTE: Post-open channel messages do not need checking; the above will + # reject attemps to open channels, meaning that even if a malicious + # user tries to send a MSG_CHANNEL_REQUEST, it will simply fall under + # the logic that handles unknown channel IDs (as the channel list will + # be empty.) + return reply def run(self): # (use the exposed "run" method, because if we specify a thread target @@ -1779,7 +1817,11 @@ class Transport (threading.Thread, ClosingContextManager): continue if ptype in self._handler_table: - self._handler_table[ptype](self, m) + error_msg = self._ensure_authed(ptype, m) + if error_msg: + self._send_message(error_msg) + else: + self._handler_table[ptype](self, m) elif ptype in self._channel_handler_table: chanid = m.get_int() chan = self._channels.get(chanid) diff --git a/tests/test_transport.py b/tests/test_transport.py index d81ad8f..1305cd5 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -32,7 +32,7 @@ from hashlib import sha1 import unittest from paramiko import Transport, SecurityOptions, ServerInterface, RSAKey, DSSKey, \ - SSHException, ChannelException, Packetizer + SSHException, ChannelException, Packetizer, Channel from paramiko import AUTH_FAILED, AUTH_SUCCESSFUL from paramiko import OPEN_SUCCEEDED, OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED from paramiko.common import MSG_KEXINIT, cMSG_CHANNEL_WINDOW_ADJUST, \ @@ -87,7 +87,11 @@ class NullServer (ServerInterface): def check_global_request(self, kind, msg): self._global_request = kind - return False + # NOTE: for w/e reason, older impl of this returned False always, even + # tho that's only supposed to occur if the request cannot be served. + # For now, leaving that the default unless test supplies specific + # 'acceptable' request kind + return kind == 'acceptable' def check_channel_x11_request(self, channel, single_connection, auth_protocol, auth_cookie, screen_number): self._x11_single_connection = single_connection @@ -125,7 +129,9 @@ class TransportTest(unittest.TestCase): self.socks.close() self.sockc.close() - def setup_test_server(self, client_options=None, server_options=None): + def setup_test_server( + self, client_options=None, server_options=None, connect_kwargs=None, + ): host_key = RSAKey.from_private_key_file(test_path('test_rsa.key')) public_host_key = RSAKey(data=host_key.asbytes()) self.ts.add_server_key(host_key) @@ -139,8 +145,13 @@ class TransportTest(unittest.TestCase): self.server = NullServer() self.assertTrue(not event.is_set()) self.ts.start_server(event, self.server) - self.tc.connect(hostkey=public_host_key, - username='slowdive', password='pygmalion') + if connect_kwargs is None: + connect_kwargs = dict( + hostkey=public_host_key, + username='slowdive', + password='pygmalion', + ) + self.tc.connect(**connect_kwargs) event.wait(1.0) self.assertTrue(event.is_set()) self.assertTrue(self.ts.is_active()) @@ -846,3 +857,37 @@ class TransportTest(unittest.TestCase): self.assertEqual([chan], r) self.assertEqual([], w) self.assertEqual([], e) + + def test_server_rejects_open_channel_without_auth(self): + try: + self.setup_test_server(connect_kwargs={}) + self.tc.open_session() + except ChannelException as e: + assert e.code == OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + else: + assert False, "Did not raise ChannelException!" + + def test_server_rejects_arbitrary_global_request_without_auth(self): + self.setup_test_server(connect_kwargs={}) + # NOTE: this dummy global request kind would normally pass muster + # from the test server. + self.tc.global_request('acceptable') + # Global requests never raise exceptions, even on failure (not sure why + # this was the original design...ugh.) Best we can do to tell failure + # happened is that the client transport's global_response was set back + # to None; if it had succeeded, it would be the response Message. + err = "Unauthed global response incorrectly succeeded!" + assert self.tc.global_response is None, err + + def test_server_rejects_port_forward_without_auth(self): + # NOTE: at protocol level port forward requests are treated same as a + # regular global request, but Paramiko server implements a special-case + # method for it, so it gets its own test. (plus, THAT actually raises + # an exception on the client side, unlike the general case...) + self.setup_test_server(connect_kwargs={}) + try: + self.tc.request_port_forward('localhost', 1234) + except SSHException as e: + assert "forwarding request denied" in str(e) + else: + assert False, "Did not raise SSHException!"