From 9d31a665702b6b4fb53a984af4553f696fc9bf64 Mon Sep 17 00:00:00 2001 From: Jinjie Zhang Date: Sun, 22 Aug 2021 21:31:29 +0800 Subject: [PATCH 1/7] feat: add --socks5auth option to support socks5 userpassword authentication --- mitmproxy/options.py | 4 +++ mitmproxy/proxy/layers/modes.py | 62 +++++++++++++++++++++++++++------ mitmproxy/tools/cmdline.py | 1 + 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/mitmproxy/options.py b/mitmproxy/options.py index 14c90d220..f3a8532bb 100644 --- a/mitmproxy/options.py +++ b/mitmproxy/options.py @@ -96,6 +96,10 @@ class Options(optmanager.OptManager): is host specification in the form of "http[s]://host[:port]". """ ) + self.add_option( + "socks5auth", Optional[str], None, + "user:password, enabled when mode is socks5" + ) self.add_option( "upstream_cert", bool, True, "Connect to upstream server to look up certificate details." diff --git a/mitmproxy/proxy/layers/modes.py b/mitmproxy/proxy/layers/modes.py index e099da27e..a20a4de89 100644 --- a/mitmproxy/proxy/layers/modes.py +++ b/mitmproxy/proxy/layers/modes.py @@ -3,7 +3,7 @@ import struct from abc import ABCMeta from typing import Optional -from mitmproxy import platform +from mitmproxy import platform, ctx from mitmproxy.net import server_spec from mitmproxy.proxy import commands, events, layer from mitmproxy.proxy.layers import tls @@ -76,6 +76,7 @@ class TransparentProxy(DestinationKnown): SOCKS5_VERSION = 0x05 SOCKS5_METHOD_NO_AUTHENTICATION_REQUIRED = 0x00 +SOCKS5_METHOD_USER_PASSWORD_AUTHENTICATION = 0x02 SOCKS5_METHOD_NO_ACCEPTABLE_METHODS = 0xFF SOCKS5_ATYP_IPV4_ADDRESS = 0x01 @@ -90,11 +91,12 @@ SOCKS5_REP_ADDRESS_TYPE_NOT_SUPPORTED = 0x08 class Socks5Proxy(DestinationKnown): buf: bytes = b"" greeted: bool = False + need_auth: bool = True def socks_err( - self, - message: str, - reply_code: Optional[int] = None, + self, + message: str, + reply_code: Optional[int] = None, ) -> layer.CommandGenerator[None]: if reply_code is not None: yield commands.SendData( @@ -105,6 +107,16 @@ class Socks5Proxy(DestinationKnown): yield commands.Log(message) self._handle_event = self.done + def auth_enable(self): + return self.context.options.socks5auth is not None + + def check_auth(self, test_id, test_pwd): + if isinstance(test_id, bytes): + test_id = test_id.decode() + test_pwd = test_pwd.decode() + user_id, pwd = self.context.options.socks5auth.split(':') + return test_id == user_id and test_pwd == pwd + @expect(events.Start, events.DataReceived, events.ConnectionClosed) def _handle_event(self, event: events.Event) -> layer.CommandGenerator[None]: if isinstance(event, events.Start): @@ -128,17 +140,47 @@ class Socks5Proxy(DestinationKnown): n_methods = self.buf[1] if len(self.buf) < 2 + n_methods: return - if SOCKS5_METHOD_NO_AUTHENTICATION_REQUIRED not in self.buf[2:2 + n_methods]: - yield from self.socks_err("mitmproxy only supports SOCKS without authentication", - SOCKS5_METHOD_NO_ACCEPTABLE_METHODS) - return + + method = SOCKS5_METHOD_NO_AUTHENTICATION_REQUIRED + if self.auth_enable(): + if SOCKS5_METHOD_USER_PASSWORD_AUTHENTICATION not in self.buf[2:2+n_methods]: + yield from self.socks_err("mitmproxy only support user password authentication", + SOCKS5_METHOD_NO_ACCEPTABLE_METHODS) + return + method = SOCKS5_METHOD_USER_PASSWORD_AUTHENTICATION # Send Server Greeting - # Ver = SOCKS5, Auth = NO_AUTH - yield commands.SendData(self.context.client, b"\x05\x00") + if method == SOCKS5_METHOD_NO_AUTHENTICATION_REQUIRED: + # Ver = SOCKS5, Auth = NO_AUTH + yield commands.SendData(self.context.client, b"\x05\x00") + self.need_auth = False + else: + # Ver = SOCKS5, Auth = UserPassword + yield commands.SendData(self.context.client, b"\x05\x02") + self.need_auth = True self.buf = self.buf[2 + n_methods:] self.greeted = True + if self.need_auth: + # Parse client authentication request + if len(self.buf) < 2: + return + + id_len = self.buf[1] + if len(self.buf) < 3 + id_len: + return + pw_len = self.buf[2 + id_len] + if len(self.buf) < 3 + id_len + pw_len: + return + test_id, test_pw = self.buf[2:(2 + id_len)], self.buf[(3 + id_len):(3 + id_len + pw_len)] + if self.check_auth(test_id, test_pw): + yield commands.SendData(self.context.client, b"\x01\x00") + else: + yield from self.socks_err("authentication failed", 0x01) + return + self.buf = self.buf[3 + id_len + pw_len:] + self.need_auth = False + # Parse Connect Request if len(self.buf) < 4: return diff --git a/mitmproxy/tools/cmdline.py b/mitmproxy/tools/cmdline.py index 471e02670..9c257b78b 100644 --- a/mitmproxy/tools/cmdline.py +++ b/mitmproxy/tools/cmdline.py @@ -44,6 +44,7 @@ def common_options(parser, opts): # Basic options opts.make_parser(parser, "mode", short="m") + opts.make_parser(parser, "socks5auth") opts.make_parser(parser, "anticache") opts.make_parser(parser, "showhost") opts.make_parser(parser, "rfile", metavar="PATH", short="r") From 5876e431ce0089198c602a203efa9de6f77b716e Mon Sep 17 00:00:00 2001 From: Jinjie Zhang Date: Sun, 22 Aug 2021 21:36:08 +0800 Subject: [PATCH 2/7] fix: fix unused ctx import --- mitmproxy/proxy/layers/modes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mitmproxy/proxy/layers/modes.py b/mitmproxy/proxy/layers/modes.py index a20a4de89..334b183a4 100644 --- a/mitmproxy/proxy/layers/modes.py +++ b/mitmproxy/proxy/layers/modes.py @@ -3,7 +3,7 @@ import struct from abc import ABCMeta from typing import Optional -from mitmproxy import platform, ctx +from mitmproxy import platform from mitmproxy.net import server_spec from mitmproxy.proxy import commands, events, layer from mitmproxy.proxy.layers import tls From 0c366f64363817c30598addd4ff23e2bd24bcd3f Mon Sep 17 00:00:00 2001 From: Jinjie Zhang Date: Mon, 23 Aug 2021 16:55:57 +0800 Subject: [PATCH 3/7] feat: add test script for socks5auth --- mitmproxy/proxy/layers/modes.py | 5 +- test/mitmproxy/proxy/layers/test_modes.py | 330 +++++++++++++--------- 2 files changed, 200 insertions(+), 135 deletions(-) diff --git a/mitmproxy/proxy/layers/modes.py b/mitmproxy/proxy/layers/modes.py index 334b183a4..0baffa88e 100644 --- a/mitmproxy/proxy/layers/modes.py +++ b/mitmproxy/proxy/layers/modes.py @@ -97,11 +97,12 @@ class Socks5Proxy(DestinationKnown): self, message: str, reply_code: Optional[int] = None, + ver_code: int = SOCKS5_VERSION ) -> layer.CommandGenerator[None]: if reply_code is not None: yield commands.SendData( self.context.client, - bytes([SOCKS5_VERSION, reply_code]) + b"\x00\x01\x00\x00\x00\x00\x00\x00" + bytes([ver_code, reply_code]) + b"\x00\x01\x00\x00\x00\x00\x00\x00" ) yield commands.CloseConnection(self.context.client) yield commands.Log(message) @@ -176,7 +177,7 @@ class Socks5Proxy(DestinationKnown): if self.check_auth(test_id, test_pw): yield commands.SendData(self.context.client, b"\x01\x00") else: - yield from self.socks_err("authentication failed", 0x01) + yield from self.socks_err("authentication failed", 0x01, ver_code=0x01) return self.buf = self.buf[3 + id_len + pw_len:] self.need_auth = False diff --git a/test/mitmproxy/proxy/layers/test_modes.py b/test/mitmproxy/proxy/layers/test_modes.py index 927d2fb2f..39373778c 100644 --- a/test/mitmproxy/proxy/layers/test_modes.py +++ b/test/mitmproxy/proxy/layers/test_modes.py @@ -58,49 +58,49 @@ def test_upstream_https(tctx): response = Placeholder(bytes) assert ( - proxy1 - >> DataReceived(tctx1.client, b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n") - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.upstream)) - << OpenConnection(upstream) - >> reply(None) - << TlsStartServerHook(Placeholder()) - >> reply_tls_start_server(alpn=b"http/1.1") - << SendData(upstream, clienthello) + proxy1 + >> DataReceived(tctx1.client, b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n") + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.upstream)) + << OpenConnection(upstream) + >> reply(None) + << TlsStartServerHook(Placeholder()) + >> reply_tls_start_server(alpn=b"http/1.1") + << SendData(upstream, clienthello) ) assert upstream().address == ("example.mitmproxy.org", 8081) assert ( - proxy2 - >> DataReceived(tctx2.client, clienthello()) - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(ClientTLSLayer) - << TlsStartClientHook(Placeholder()) - >> reply_tls_start_client(alpn=b"http/1.1") - << SendData(tctx2.client, serverhello) + proxy2 + >> DataReceived(tctx2.client, clienthello()) + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(ClientTLSLayer) + << TlsStartClientHook(Placeholder()) + >> reply_tls_start_client(alpn=b"http/1.1") + << SendData(tctx2.client, serverhello) ) assert ( - proxy1 - >> DataReceived(upstream, serverhello()) - << SendData(upstream, request) + proxy1 + >> DataReceived(upstream, serverhello()) + << SendData(upstream, request) ) assert ( - proxy2 - >> DataReceived(tctx2.client, request()) - << SendData(tctx2.client, tls_finished) - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.regular)) - << OpenConnection(server) - >> reply(None) - << SendData(server, b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n') - >> DataReceived(server, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") - << SendData(tctx2.client, response) + proxy2 + >> DataReceived(tctx2.client, request()) + << SendData(tctx2.client, tls_finished) + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.regular)) + << OpenConnection(server) + >> reply(None) + << SendData(server, b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n') + >> DataReceived(server, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + << SendData(tctx2.client, response) ) assert server().address == ("example.com", 80) assert ( - proxy1 - >> DataReceived(upstream, tls_finished() + response()) - << SendData(tctx1.client, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + proxy1 + >> DataReceived(upstream, tls_finished() + response()) + << SendData(tctx1.client, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") ) @@ -117,17 +117,17 @@ def test_reverse_proxy(tctx, keep_host_header): tctx.options.connection_strategy = "lazy" tctx.options.keep_host_header = keep_host_header assert ( - Playbook(modes.ReverseProxy(tctx), hooks=False) - >> DataReceived(tctx.client, b"GET /foo HTTP/1.1\r\n" - b"Host: example.com\r\n\r\n") - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.transparent)) - << OpenConnection(server) - >> reply(None) - << SendData(server, b"GET /foo HTTP/1.1\r\n" - b"Host: " + (b"example.com" if keep_host_header else b"localhost:8000") + b"\r\n\r\n") - >> DataReceived(server, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") - << SendData(tctx.client, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + Playbook(modes.ReverseProxy(tctx), hooks=False) + >> DataReceived(tctx.client, b"GET /foo HTTP/1.1\r\n" + b"Host: example.com\r\n\r\n") + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.transparent)) + << OpenConnection(server) + >> reply(None) + << SendData(server, b"GET /foo HTTP/1.1\r\n" + b"Host: " + (b"example.com" if keep_host_header else b"localhost:8000") + b"\r\n\r\n") + >> DataReceived(server, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + << SendData(tctx.client, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") ) assert server().address == ("localhost", 8000) @@ -151,53 +151,53 @@ def test_reverse_proxy_tcp_over_tls(tctx: Context, monkeypatch, patch, connectio playbook = Playbook(modes.ReverseProxy(tctx)) if connection_strategy == "eager": ( - playbook - << OpenConnection(tctx.server) - >> DataReceived(tctx.client, b"\x01\x02\x03") - >> reply(None, to=OpenConnection(tctx.server)) + playbook + << OpenConnection(tctx.server) + >> DataReceived(tctx.client, b"\x01\x02\x03") + >> reply(None, to=OpenConnection(tctx.server)) ) else: ( - playbook - >> DataReceived(tctx.client, b"\x01\x02\x03") + playbook + >> DataReceived(tctx.client, b"\x01\x02\x03") ) if patch: ( - playbook - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(tcp.TCPLayer) - << TcpStartHook(flow) - >> reply() - ) - if connection_strategy == "lazy": - ( - playbook - << OpenConnection(tctx.server) - >> reply(None) - ) - assert ( - playbook - << TcpMessageHook(flow) - >> reply() - << SendData(tctx.server, data) - ) - assert data() == b"\x01\x02\x03" - else: - if connection_strategy == "lazy": - ( playbook << NextLayerHook(Placeholder(NextLayer)) >> reply_next_layer(tcp.TCPLayer) << TcpStartHook(flow) >> reply() - << OpenConnection(tctx.server) - >> reply(None) + ) + if connection_strategy == "lazy": + ( + playbook + << OpenConnection(tctx.server) + >> reply(None) ) assert ( - playbook - << TlsStartServerHook(Placeholder()) - >> reply_tls_start_server() - << SendData(tctx.server, data) + playbook + << TcpMessageHook(flow) + >> reply() + << SendData(tctx.server, data) + ) + assert data() == b"\x01\x02\x03" + else: + if connection_strategy == "lazy": + ( + playbook + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(tcp.TCPLayer) + << TcpStartHook(flow) + >> reply() + << OpenConnection(tctx.server) + >> reply(None) + ) + assert ( + playbook + << TlsStartServerHook(Placeholder()) + >> reply_tls_start_server() + << SendData(tctx.server, data) ) assert tls.parse_client_hello(data()).sni == "localhost" @@ -212,25 +212,25 @@ def test_transparent_tcp(tctx: Context, monkeypatch, connection_strategy): sock = object() playbook = Playbook(modes.TransparentProxy(tctx)) ( - playbook - << GetSocket(tctx.client) - >> reply(sock) + playbook + << GetSocket(tctx.client) + >> reply(sock) ) if connection_strategy == "lazy": assert playbook else: assert ( - playbook - << OpenConnection(tctx.server) - >> reply(None) - >> DataReceived(tctx.server, b"hello") - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(tcp.TCPLayer) - << TcpStartHook(flow) - >> reply() - << TcpMessageHook(flow) - >> reply() - << SendData(tctx.client, b"hello") + playbook + << OpenConnection(tctx.server) + >> reply(None) + >> DataReceived(tctx.server, b"hello") + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(tcp.TCPLayer) + << TcpStartHook(flow) + >> reply() + << TcpMessageHook(flow) + >> reply() + << SendData(tctx.client, b"hello") ) assert flow().messages[0].content == b"hello" assert not flow().messages[0].from_client @@ -246,10 +246,10 @@ def test_transparent_failure(tctx: Context, monkeypatch): monkeypatch.setattr(platform, "original_addr", raise_err) assert ( - Playbook(modes.TransparentProxy(tctx), logs=True) - << GetSocket(tctx.client) - >> reply(object()) - << Log("Transparent mode failure: RuntimeError('platform-specific error')", "info") + Playbook(modes.TransparentProxy(tctx), logs=True) + << GetSocket(tctx.client) + >> reply(object()) + << Log("Transparent mode failure: RuntimeError('platform-specific error')", "info") ) @@ -264,11 +264,11 @@ def test_reverse_eager_connect_failure(tctx: Context): tctx.options.connection_strategy = "eager" playbook = Playbook(modes.ReverseProxy(tctx)) assert ( - playbook - << OpenConnection(tctx.server) - >> reply("IPoAC unstable") - << CloseConnection(tctx.client) - >> ConnectionClosed(tctx.client) + playbook + << OpenConnection(tctx.server) + >> reply("IPoAC unstable") + << CloseConnection(tctx.client) + >> ConnectionClosed(tctx.client) ) @@ -278,13 +278,13 @@ def test_transparent_eager_connect_failure(tctx: Context, monkeypatch): monkeypatch.setattr(platform, "original_addr", lambda sock: ("address", 22)) assert ( - Playbook(modes.TransparentProxy(tctx), logs=True) - << GetSocket(tctx.client) - >> reply(object()) - << OpenConnection(tctx.server) - >> reply("something something") - << CloseConnection(tctx.client) - >> ConnectionClosed(tctx.client) + Playbook(modes.TransparentProxy(tctx), logs=True) + << GetSocket(tctx.client) + >> reply(object()) + << OpenConnection(tctx.server) + >> reply("something something") + << CloseConnection(tctx.client) + >> ConnectionClosed(tctx.client) ) @@ -303,14 +303,14 @@ def test_socks5_success(address: str, packed: bytes, tctx: Context): server = Placeholder(Server) nextlayer = Placeholder(NextLayer) assert ( - playbook - >> DataReceived(tctx.client, CLIENT_HELLO) - << SendData(tctx.client, SERVER_HELLO) - >> DataReceived(tctx.client, b"\x05\x01\x00" + packed + b"\x12\x34applicationdata") - << OpenConnection(server) - >> reply(None) - << SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") - << NextLayerHook(nextlayer) + playbook + >> DataReceived(tctx.client, CLIENT_HELLO) + << SendData(tctx.client, SERVER_HELLO) + >> DataReceived(tctx.client, b"\x05\x01\x00" + packed + b"\x12\x34applicationdata") + << OpenConnection(server) + >> reply(None) + << SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") + << NextLayerHook(nextlayer) ) assert server().address == (address, 0x1234) assert nextlayer().data_client() == b"applicationdata" @@ -334,9 +334,6 @@ def test_socks5_trickle(tctx: Context): (b"abcd", None, "Invalid SOCKS version. Expected 0x05, got 0x61"), - (b"\x05\x01\x02", - b"\x05\xFF\x00\x01\x00\x00\x00\x00\x00\x00", - "mitmproxy only supports SOCKS without authentication"), (CLIENT_HELLO + b"\x05\x02\x00\x01\x7f\x00\x00\x01\x12\x34", SERVER_HELLO + b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00", r"Unsupported SOCKS5 request: b'\x05\x02\x00\x01\x7f\x00\x00\x01\x124'"), @@ -346,8 +343,8 @@ def test_socks5_trickle(tctx: Context): ]) def test_socks5_err(data: bytes, err: bytes, msg: str, tctx: Context): playbook = ( - Playbook(modes.Socks5Proxy(tctx), logs=True) - >> DataReceived(tctx.client, data) + Playbook(modes.Socks5Proxy(tctx), logs=True) + >> DataReceived(tctx.client, data) ) if err: playbook << SendData(tctx.client, err) @@ -356,26 +353,93 @@ def test_socks5_err(data: bytes, err: bytes, msg: str, tctx: Context): assert playbook +@pytest.mark.parametrize("client_greeting,server_choice,client_auth,server_resp,address,packed", [ + (b"\x05\x01\x02", + b"\x05\x02", + b"\x01\x04" + b"user" + b"\x08" + b"password", + b"\x01\x00", + "127.0.0.1", + b"\x01\x7f\x00\x00\x01"), + (b"\x05\x02\x01\x02", + b"\x05\x02", + b"\x01\x04" + b"user" + b"\x08" + b"password", + b"\x01\x00", + "127.0.0.1", + b"\x01\x7f\x00\x00\x01"), +]) +def test_socks5_auth_success(client_greeting: bytes, server_choice: bytes, client_auth: bytes, server_resp: bytes, + address: bytes, packed: bytes, tctx: Context): + tctx.options.socks5auth = "user:password" + server = Placeholder(Server) + nextlayer = Placeholder(NextLayer) + playbook = ( + Playbook(modes.Socks5Proxy(tctx), logs=True) + >> DataReceived(tctx.client, client_greeting) + << SendData(tctx.client, server_choice) + >> DataReceived(tctx.client, client_auth) + << SendData(tctx.client, server_resp) + >> DataReceived(tctx.client, b"\x05\x01\x00" + packed + b"\x12\x34applicationdata") + << OpenConnection(server) + >> reply(None) + << SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") + << NextLayerHook(nextlayer) + ) + assert playbook + assert server().address == (address, 0x1234) + assert nextlayer().data_client() == b"applicationdata" + + +@pytest.mark.parametrize("client_greeting,server_choice,client_auth,err,msg", [ + (b"\x05\x01\x00", + None, + None, + b"\x05\xFF\x00\x01\x00\x00\x00\x00\x00\x00", + "mitmproxy only support user password authentication"), + (b"\x05\x02\x00\x02", + b"\x05\x02", + b"\x01\x04" + b"user" + b"\x07" + b"errcode", + b"\x01\x01\x00\x01\x00\x00\x00\x00\x00\x00", + "authentication failed"), +]) +def test_socks5_auth_fail(client_greeting: bytes, server_choice: bytes, client_auth: bytes, err: bytes, msg: str, + tctx: Context): + tctx.options.socks5auth = "user:password" + playbook = ( + Playbook(modes.Socks5Proxy(tctx), logs=True) + >> DataReceived(tctx.client, client_greeting) + ) + if server_choice is None: + playbook << SendData(tctx.client, err) + else: + playbook << SendData(tctx.client, server_choice) + playbook >> DataReceived(tctx.client, client_auth) + playbook << SendData(tctx.client, err) + + playbook << CloseConnection(tctx.client) + playbook << Log(msg) + assert playbook + + def test_socks5_eager_err(tctx: Context): tctx.options.connection_strategy = "eager" server = Placeholder(Server) assert ( - Playbook(modes.Socks5Proxy(tctx)) - >> DataReceived(tctx.client, CLIENT_HELLO) - << SendData(tctx.client, SERVER_HELLO) - >> DataReceived(tctx.client, b"\x05\x01\x00\x01\x7f\x00\x00\x01\x12\x34") - << OpenConnection(server) - >> reply("out of socks") - << SendData(tctx.client, b"\x05\x04\x00\x01\x00\x00\x00\x00\x00\x00") - << CloseConnection(tctx.client) + Playbook(modes.Socks5Proxy(tctx)) + >> DataReceived(tctx.client, CLIENT_HELLO) + << SendData(tctx.client, SERVER_HELLO) + >> DataReceived(tctx.client, b"\x05\x01\x00\x01\x7f\x00\x00\x01\x12\x34") + << OpenConnection(server) + >> reply("out of socks") + << SendData(tctx.client, b"\x05\x04\x00\x01\x00\x00\x00\x00\x00\x00") + << CloseConnection(tctx.client) ) def test_socks5_premature_close(tctx: Context): assert ( - Playbook(modes.Socks5Proxy(tctx), logs=True) - >> DataReceived(tctx.client, b"\x05") - >> ConnectionClosed(tctx.client) - << Log(r"Client closed connection before completing SOCKS5 handshake: b'\x05'") - << CloseConnection(tctx.client) + Playbook(modes.Socks5Proxy(tctx), logs=True) + >> DataReceived(tctx.client, b"\x05") + >> ConnectionClosed(tctx.client) + << Log(r"Client closed connection before completing SOCKS5 handshake: b'\x05'") + << CloseConnection(tctx.client) ) From a3eca0b859261b00b4dcdde53816577d1314960a Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Wed, 25 Aug 2021 15:46:22 +0200 Subject: [PATCH 4/7] socks5 upstream auth: use proxyauth option --- CHANGELOG.md | 1 + docs/scripts/api-events.py | 10 +- mitmproxy/addons/proxyauth.py | 6 - mitmproxy/options.py | 4 - mitmproxy/proxy/layers/modes.py | 271 ++++++++++--------- mitmproxy/tools/cmdline.py | 1 - test/mitmproxy/proxy/layers/test_modes.py | 312 ++++++++++++---------- 7 files changed, 316 insertions(+), 289 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acf76819b..be2c61eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased: mitmproxy next +* Support proxy authentication for SOCKS v5 mode (@starplanet) * fix some responses not being decoded properly if the encoding was uppercase #4735 (@Mattwmaster58) * Windows: Switch to Python's default asyncio event loop, which increases the number of sockets that can be processed simultaneously. diff --git a/docs/scripts/api-events.py b/docs/scripts/api-events.py index 2429c751f..80d91dae9 100644 --- a/docs/scripts/api-events.py +++ b/docs/scripts/api-events.py @@ -8,7 +8,7 @@ from typing import List, Type import mitmproxy.addons.next_layer # noqa from mitmproxy import hooks, log, addonmanager from mitmproxy.proxy import server_hooks, layer -from mitmproxy.proxy.layers import http, tcp, tls, websocket +from mitmproxy.proxy.layers import http, modes, tcp, tls, websocket known = set() @@ -137,6 +137,14 @@ with outfile.open("w") as f, contextlib.redirect_stdout(f): ] ) + category( + "SOCKSv5", + "", + [ + modes.Socks5AuthHook, + ] + ) + category( "AdvancedLifecycle", "", diff --git a/mitmproxy/addons/proxyauth.py b/mitmproxy/addons/proxyauth.py index 9d5ca46e0..31005f20b 100644 --- a/mitmproxy/addons/proxyauth.py +++ b/mitmproxy/addons/proxyauth.py @@ -201,12 +201,6 @@ class ProxyAuth: raise exceptions.OptionsError( "Proxy Authentication not supported in transparent mode." ) - if ctx.options.mode == "socks5": - raise exceptions.OptionsError( - "Proxy Authentication not supported in SOCKS mode. " - "https://github.com/mitmproxy/mitmproxy/issues/738" - ) - # TODO: check for multiple auth options def http_connect(self, f: http.HTTPFlow) -> None: if self.enabled(): diff --git a/mitmproxy/options.py b/mitmproxy/options.py index f3a8532bb..14c90d220 100644 --- a/mitmproxy/options.py +++ b/mitmproxy/options.py @@ -96,10 +96,6 @@ class Options(optmanager.OptManager): is host specification in the form of "http[s]://host[:port]". """ ) - self.add_option( - "socks5auth", Optional[str], None, - "user:password, enabled when mode is socks5" - ) self.add_option( "upstream_cert", bool, True, "Connect to upstream server to look up certificate details." diff --git a/mitmproxy/proxy/layers/modes.py b/mitmproxy/proxy/layers/modes.py index 0baffa88e..5c7f4797b 100644 --- a/mitmproxy/proxy/layers/modes.py +++ b/mitmproxy/proxy/layers/modes.py @@ -1,11 +1,13 @@ import socket import struct from abc import ABCMeta +from dataclasses import dataclass from typing import Optional from mitmproxy import platform from mitmproxy.net import server_spec from mitmproxy.proxy import commands, events, layer +from mitmproxy.proxy.commands import StartHook from mitmproxy.proxy.layers import tls from mitmproxy.proxy.utils import expect @@ -88,158 +90,167 @@ SOCKS5_REP_COMMAND_NOT_SUPPORTED = 0x07 SOCKS5_REP_ADDRESS_TYPE_NOT_SUPPORTED = 0x08 +@dataclass +class Socks5AuthData: + username: str + password: str + valid: bool = False + + +@dataclass +class Socks5AuthHook(StartHook): + """ + Mitmproxy has received username/password SOCKS5 credentials. + + This hook decides whether they are valid by setting `data.valid`. + """ + data: Socks5AuthData + + class Socks5Proxy(DestinationKnown): buf: bytes = b"" - greeted: bool = False - need_auth: bool = True def socks_err( - self, - message: str, - reply_code: Optional[int] = None, - ver_code: int = SOCKS5_VERSION + self, + message: str, + reply_code: Optional[int] = None, ) -> layer.CommandGenerator[None]: if reply_code is not None: yield commands.SendData( self.context.client, - bytes([ver_code, reply_code]) + b"\x00\x01\x00\x00\x00\x00\x00\x00" + bytes([SOCKS5_VERSION, reply_code]) + b"\x00\x01\x00\x00\x00\x00\x00\x00" ) yield commands.CloseConnection(self.context.client) yield commands.Log(message) self._handle_event = self.done - def auth_enable(self): - return self.context.options.socks5auth is not None - - def check_auth(self, test_id, test_pwd): - if isinstance(test_id, bytes): - test_id = test_id.decode() - test_pwd = test_pwd.decode() - user_id, pwd = self.context.options.socks5auth.split(':') - return test_id == user_id and test_pwd == pwd - @expect(events.Start, events.DataReceived, events.ConnectionClosed) def _handle_event(self, event: events.Event) -> layer.CommandGenerator[None]: if isinstance(event, events.Start): pass elif isinstance(event, events.DataReceived): self.buf += event.data - - if not self.greeted: - # Parse Client Greeting - if len(self.buf) < 2: - return - - if self.buf[0] != SOCKS5_VERSION: - if self.buf[:3].isupper(): - guess = "Probably not a SOCKS request but a regular HTTP request. " - else: - guess = "" - yield from self.socks_err(guess + "Invalid SOCKS version. Expected 0x05, got 0x%x" % self.buf[0]) - return - - n_methods = self.buf[1] - if len(self.buf) < 2 + n_methods: - return - - method = SOCKS5_METHOD_NO_AUTHENTICATION_REQUIRED - if self.auth_enable(): - if SOCKS5_METHOD_USER_PASSWORD_AUTHENTICATION not in self.buf[2:2+n_methods]: - yield from self.socks_err("mitmproxy only support user password authentication", - SOCKS5_METHOD_NO_ACCEPTABLE_METHODS) - return - method = SOCKS5_METHOD_USER_PASSWORD_AUTHENTICATION - - # Send Server Greeting - if method == SOCKS5_METHOD_NO_AUTHENTICATION_REQUIRED: - # Ver = SOCKS5, Auth = NO_AUTH - yield commands.SendData(self.context.client, b"\x05\x00") - self.need_auth = False - else: - # Ver = SOCKS5, Auth = UserPassword - yield commands.SendData(self.context.client, b"\x05\x02") - self.need_auth = True - self.buf = self.buf[2 + n_methods:] - self.greeted = True - - if self.need_auth: - # Parse client authentication request - if len(self.buf) < 2: - return - - id_len = self.buf[1] - if len(self.buf) < 3 + id_len: - return - pw_len = self.buf[2 + id_len] - if len(self.buf) < 3 + id_len + pw_len: - return - test_id, test_pw = self.buf[2:(2 + id_len)], self.buf[(3 + id_len):(3 + id_len + pw_len)] - if self.check_auth(test_id, test_pw): - yield commands.SendData(self.context.client, b"\x01\x00") - else: - yield from self.socks_err("authentication failed", 0x01, ver_code=0x01) - return - self.buf = self.buf[3 + id_len + pw_len:] - self.need_auth = False - - # Parse Connect Request - if len(self.buf) < 4: - return - - if self.buf[:3] != b"\x05\x01\x00": - yield from self.socks_err(f"Unsupported SOCKS5 request: {self.buf!r}", SOCKS5_REP_COMMAND_NOT_SUPPORTED) - return - - # Determine message length - atyp = self.buf[3] - message_len: int - if atyp == SOCKS5_ATYP_IPV4_ADDRESS: - message_len = 4 + 4 + 2 - elif atyp == SOCKS5_ATYP_IPV6_ADDRESS: - message_len = 4 + 16 + 2 - elif atyp == SOCKS5_ATYP_DOMAINNAME: - message_len = 4 + 1 + self.buf[4] + 2 - else: - yield from self.socks_err(f"Unknown address type: {atyp}", SOCKS5_REP_ADDRESS_TYPE_NOT_SUPPORTED) - return - - # Do we have enough bytes yet? - if len(self.buf) < message_len: - return - - # Parse host and port - msg, self.buf = self.buf[:message_len], self.buf[message_len:] - - host: str - if atyp == SOCKS5_ATYP_IPV4_ADDRESS: - host = socket.inet_ntop(socket.AF_INET, msg[4:-2]) - elif atyp == SOCKS5_ATYP_IPV6_ADDRESS: - host = socket.inet_ntop(socket.AF_INET6, msg[4:-2]) - else: - host_bytes = msg[5:-2] - host = host_bytes.decode("ascii", "replace") - - port, = struct.unpack("!H", msg[-2:]) - - # We now have all we need, let's get going. - self.context.server.address = (host, port) - self.child_layer = layer.NextLayer(self.context) - - # this already triggers the child layer's Start event, - # but that's not a problem in practice... - err = yield from self.finish_start() - if err: - yield commands.SendData(self.context.client, b"\x05\x04\x00\x01\x00\x00\x00\x00\x00\x00") - yield commands.CloseConnection(self.context.client) - else: - yield commands.SendData(self.context.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") - if self.buf: - yield from self.child_layer.handle_event(events.DataReceived(self.context.client, self.buf)) - del self.buf - + yield from self.state() elif isinstance(event, events.ConnectionClosed): if self.buf: yield commands.Log(f"Client closed connection before completing SOCKS5 handshake: {self.buf!r}") yield commands.CloseConnection(event.connection) else: raise AssertionError(f"Unknown event: {event}") + + def state_greet(self): + if len(self.buf) < 2: + return + + if self.buf[0] != SOCKS5_VERSION: + if self.buf[:3].isupper(): + guess = "Probably not a SOCKS request but a regular HTTP request. " + else: + guess = "" + yield from self.socks_err(guess + "Invalid SOCKS version. Expected 0x05, got 0x%x" % self.buf[0]) + return + + n_methods = self.buf[1] + if len(self.buf) < 2 + n_methods: + return + + if "proxyauth" in self.context.options and self.context.options.proxyauth: + method = SOCKS5_METHOD_USER_PASSWORD_AUTHENTICATION + self.state = self.state_auth + else: + method = SOCKS5_METHOD_NO_AUTHENTICATION_REQUIRED + self.state = self.state_connect + + if method not in self.buf[2:2 + n_methods]: + method_str = "user/password" if method == SOCKS5_METHOD_USER_PASSWORD_AUTHENTICATION else "no" + yield from self.socks_err( + f"Client does not support SOCKS5 with {method_str} authentication.", + SOCKS5_METHOD_NO_ACCEPTABLE_METHODS + ) + return + yield commands.SendData(self.context.client, bytes([SOCKS5_VERSION, method])) + self.buf = self.buf[2 + n_methods:] + yield from self.state() + + state = state_greet + + def state_auth(self): + if len(self.buf) < 3: + return + + # Parsing username and password, which is somewhat atrocious + user_len = self.buf[1] + if len(self.buf) < 3 + user_len: + return + pass_len = self.buf[2 + user_len] + if len(self.buf) < 3 + user_len + pass_len: + return + user = self.buf[2:(2 + user_len)].decode("utf-8", "backslashreplace") + password = self.buf[(3 + user_len):(3 + user_len + pass_len)].decode("utf-8", "backslashreplace") + + data = Socks5AuthData(user, password) + yield Socks5AuthHook(data) + if not data.valid: + yield from self.socks_err("authentication failed", 0x01) + return + + yield commands.SendData(self.context.client, b"\x05\x00") + self.buf = self.buf[3 + user_len + pass_len:] + self.state = self.state_connect + yield from self.state() + + def state_connect(self): + # Parse Connect Request + if len(self.buf) < 4: + return + + if self.buf[:3] != b"\x05\x01\x00": + yield from self.socks_err(f"Unsupported SOCKS5 request: {self.buf!r}", SOCKS5_REP_COMMAND_NOT_SUPPORTED) + return + + # Determine message length + atyp = self.buf[3] + message_len: int + if atyp == SOCKS5_ATYP_IPV4_ADDRESS: + message_len = 4 + 4 + 2 + elif atyp == SOCKS5_ATYP_IPV6_ADDRESS: + message_len = 4 + 16 + 2 + elif atyp == SOCKS5_ATYP_DOMAINNAME: + message_len = 4 + 1 + self.buf[4] + 2 + else: + yield from self.socks_err(f"Unknown address type: {atyp}", SOCKS5_REP_ADDRESS_TYPE_NOT_SUPPORTED) + return + + # Do we have enough bytes yet? + if len(self.buf) < message_len: + return + + # Parse host and port + msg, self.buf = self.buf[:message_len], self.buf[message_len:] + + host: str + if atyp == SOCKS5_ATYP_IPV4_ADDRESS: + host = socket.inet_ntop(socket.AF_INET, msg[4:-2]) + elif atyp == SOCKS5_ATYP_IPV6_ADDRESS: + host = socket.inet_ntop(socket.AF_INET6, msg[4:-2]) + else: + host_bytes = msg[5:-2] + host = host_bytes.decode("ascii", "replace") + + port, = struct.unpack("!H", msg[-2:]) + + # We now have all we need, let's get going. + self.context.server.address = (host, port) + self.child_layer = layer.NextLayer(self.context) + + # this already triggers the child layer's Start event, + # but that's not a problem in practice... + err = yield from self.finish_start() + if err: + yield commands.SendData(self.context.client, b"\x05\x04\x00\x01\x00\x00\x00\x00\x00\x00") + yield commands.CloseConnection(self.context.client) + else: + yield commands.SendData(self.context.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") + if self.buf: + yield from self.child_layer.handle_event(events.DataReceived(self.context.client, self.buf)) + del self.buf diff --git a/mitmproxy/tools/cmdline.py b/mitmproxy/tools/cmdline.py index 9c257b78b..471e02670 100644 --- a/mitmproxy/tools/cmdline.py +++ b/mitmproxy/tools/cmdline.py @@ -44,7 +44,6 @@ def common_options(parser, opts): # Basic options opts.make_parser(parser, "mode", short="m") - opts.make_parser(parser, "socks5auth") opts.make_parser(parser, "anticache") opts.make_parser(parser, "showhost") opts.make_parser(parser, "rfile", metavar="PATH", short="r") diff --git a/test/mitmproxy/proxy/layers/test_modes.py b/test/mitmproxy/proxy/layers/test_modes.py index 39373778c..cea12f29a 100644 --- a/test/mitmproxy/proxy/layers/test_modes.py +++ b/test/mitmproxy/proxy/layers/test_modes.py @@ -3,6 +3,7 @@ import copy import pytest from mitmproxy import platform +from mitmproxy.addons.proxyauth import ProxyAuth from mitmproxy.connection import Client, Server from mitmproxy.proxy.commands import CloseConnection, GetSocket, Log, OpenConnection, SendData from mitmproxy.proxy.context import Context @@ -58,49 +59,49 @@ def test_upstream_https(tctx): response = Placeholder(bytes) assert ( - proxy1 - >> DataReceived(tctx1.client, b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n") - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.upstream)) - << OpenConnection(upstream) - >> reply(None) - << TlsStartServerHook(Placeholder()) - >> reply_tls_start_server(alpn=b"http/1.1") - << SendData(upstream, clienthello) + proxy1 + >> DataReceived(tctx1.client, b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n") + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.upstream)) + << OpenConnection(upstream) + >> reply(None) + << TlsStartServerHook(Placeholder()) + >> reply_tls_start_server(alpn=b"http/1.1") + << SendData(upstream, clienthello) ) assert upstream().address == ("example.mitmproxy.org", 8081) assert ( - proxy2 - >> DataReceived(tctx2.client, clienthello()) - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(ClientTLSLayer) - << TlsStartClientHook(Placeholder()) - >> reply_tls_start_client(alpn=b"http/1.1") - << SendData(tctx2.client, serverhello) + proxy2 + >> DataReceived(tctx2.client, clienthello()) + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(ClientTLSLayer) + << TlsStartClientHook(Placeholder()) + >> reply_tls_start_client(alpn=b"http/1.1") + << SendData(tctx2.client, serverhello) ) assert ( - proxy1 - >> DataReceived(upstream, serverhello()) - << SendData(upstream, request) + proxy1 + >> DataReceived(upstream, serverhello()) + << SendData(upstream, request) ) assert ( - proxy2 - >> DataReceived(tctx2.client, request()) - << SendData(tctx2.client, tls_finished) - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.regular)) - << OpenConnection(server) - >> reply(None) - << SendData(server, b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n') - >> DataReceived(server, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") - << SendData(tctx2.client, response) + proxy2 + >> DataReceived(tctx2.client, request()) + << SendData(tctx2.client, tls_finished) + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.regular)) + << OpenConnection(server) + >> reply(None) + << SendData(server, b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n') + >> DataReceived(server, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + << SendData(tctx2.client, response) ) assert server().address == ("example.com", 80) assert ( - proxy1 - >> DataReceived(upstream, tls_finished() + response()) - << SendData(tctx1.client, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + proxy1 + >> DataReceived(upstream, tls_finished() + response()) + << SendData(tctx1.client, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") ) @@ -117,17 +118,17 @@ def test_reverse_proxy(tctx, keep_host_header): tctx.options.connection_strategy = "lazy" tctx.options.keep_host_header = keep_host_header assert ( - Playbook(modes.ReverseProxy(tctx), hooks=False) - >> DataReceived(tctx.client, b"GET /foo HTTP/1.1\r\n" - b"Host: example.com\r\n\r\n") - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.transparent)) - << OpenConnection(server) - >> reply(None) - << SendData(server, b"GET /foo HTTP/1.1\r\n" - b"Host: " + (b"example.com" if keep_host_header else b"localhost:8000") + b"\r\n\r\n") - >> DataReceived(server, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") - << SendData(tctx.client, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + Playbook(modes.ReverseProxy(tctx), hooks=False) + >> DataReceived(tctx.client, b"GET /foo HTTP/1.1\r\n" + b"Host: example.com\r\n\r\n") + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(lambda ctx: http.HttpLayer(ctx, HTTPMode.transparent)) + << OpenConnection(server) + >> reply(None) + << SendData(server, b"GET /foo HTTP/1.1\r\n" + b"Host: " + (b"example.com" if keep_host_header else b"localhost:8000") + b"\r\n\r\n") + >> DataReceived(server, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + << SendData(tctx.client, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") ) assert server().address == ("localhost", 8000) @@ -151,53 +152,53 @@ def test_reverse_proxy_tcp_over_tls(tctx: Context, monkeypatch, patch, connectio playbook = Playbook(modes.ReverseProxy(tctx)) if connection_strategy == "eager": ( - playbook - << OpenConnection(tctx.server) - >> DataReceived(tctx.client, b"\x01\x02\x03") - >> reply(None, to=OpenConnection(tctx.server)) + playbook + << OpenConnection(tctx.server) + >> DataReceived(tctx.client, b"\x01\x02\x03") + >> reply(None, to=OpenConnection(tctx.server)) ) else: ( - playbook - >> DataReceived(tctx.client, b"\x01\x02\x03") + playbook + >> DataReceived(tctx.client, b"\x01\x02\x03") ) if patch: ( - playbook - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(tcp.TCPLayer) - << TcpStartHook(flow) - >> reply() + playbook + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(tcp.TCPLayer) + << TcpStartHook(flow) + >> reply() ) if connection_strategy == "lazy": ( - playbook - << OpenConnection(tctx.server) - >> reply(None) + playbook + << OpenConnection(tctx.server) + >> reply(None) ) assert ( - playbook - << TcpMessageHook(flow) - >> reply() - << SendData(tctx.server, data) + playbook + << TcpMessageHook(flow) + >> reply() + << SendData(tctx.server, data) ) assert data() == b"\x01\x02\x03" else: if connection_strategy == "lazy": ( - playbook - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(tcp.TCPLayer) - << TcpStartHook(flow) - >> reply() - << OpenConnection(tctx.server) - >> reply(None) + playbook + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(tcp.TCPLayer) + << TcpStartHook(flow) + >> reply() + << OpenConnection(tctx.server) + >> reply(None) ) assert ( - playbook - << TlsStartServerHook(Placeholder()) - >> reply_tls_start_server() - << SendData(tctx.server, data) + playbook + << TlsStartServerHook(Placeholder()) + >> reply_tls_start_server() + << SendData(tctx.server, data) ) assert tls.parse_client_hello(data()).sni == "localhost" @@ -212,25 +213,25 @@ def test_transparent_tcp(tctx: Context, monkeypatch, connection_strategy): sock = object() playbook = Playbook(modes.TransparentProxy(tctx)) ( - playbook - << GetSocket(tctx.client) - >> reply(sock) + playbook + << GetSocket(tctx.client) + >> reply(sock) ) if connection_strategy == "lazy": assert playbook else: assert ( - playbook - << OpenConnection(tctx.server) - >> reply(None) - >> DataReceived(tctx.server, b"hello") - << NextLayerHook(Placeholder(NextLayer)) - >> reply_next_layer(tcp.TCPLayer) - << TcpStartHook(flow) - >> reply() - << TcpMessageHook(flow) - >> reply() - << SendData(tctx.client, b"hello") + playbook + << OpenConnection(tctx.server) + >> reply(None) + >> DataReceived(tctx.server, b"hello") + << NextLayerHook(Placeholder(NextLayer)) + >> reply_next_layer(tcp.TCPLayer) + << TcpStartHook(flow) + >> reply() + << TcpMessageHook(flow) + >> reply() + << SendData(tctx.client, b"hello") ) assert flow().messages[0].content == b"hello" assert not flow().messages[0].from_client @@ -246,10 +247,10 @@ def test_transparent_failure(tctx: Context, monkeypatch): monkeypatch.setattr(platform, "original_addr", raise_err) assert ( - Playbook(modes.TransparentProxy(tctx), logs=True) - << GetSocket(tctx.client) - >> reply(object()) - << Log("Transparent mode failure: RuntimeError('platform-specific error')", "info") + Playbook(modes.TransparentProxy(tctx), logs=True) + << GetSocket(tctx.client) + >> reply(object()) + << Log("Transparent mode failure: RuntimeError('platform-specific error')", "info") ) @@ -264,11 +265,11 @@ def test_reverse_eager_connect_failure(tctx: Context): tctx.options.connection_strategy = "eager" playbook = Playbook(modes.ReverseProxy(tctx)) assert ( - playbook - << OpenConnection(tctx.server) - >> reply("IPoAC unstable") - << CloseConnection(tctx.client) - >> ConnectionClosed(tctx.client) + playbook + << OpenConnection(tctx.server) + >> reply("IPoAC unstable") + << CloseConnection(tctx.client) + >> ConnectionClosed(tctx.client) ) @@ -278,13 +279,13 @@ def test_transparent_eager_connect_failure(tctx: Context, monkeypatch): monkeypatch.setattr(platform, "original_addr", lambda sock: ("address", 22)) assert ( - Playbook(modes.TransparentProxy(tctx), logs=True) - << GetSocket(tctx.client) - >> reply(object()) - << OpenConnection(tctx.server) - >> reply("something something") - << CloseConnection(tctx.client) - >> ConnectionClosed(tctx.client) + Playbook(modes.TransparentProxy(tctx), logs=True) + << GetSocket(tctx.client) + >> reply(object()) + << OpenConnection(tctx.server) + >> reply("something something") + << CloseConnection(tctx.client) + >> ConnectionClosed(tctx.client) ) @@ -303,24 +304,35 @@ def test_socks5_success(address: str, packed: bytes, tctx: Context): server = Placeholder(Server) nextlayer = Placeholder(NextLayer) assert ( - playbook - >> DataReceived(tctx.client, CLIENT_HELLO) - << SendData(tctx.client, SERVER_HELLO) - >> DataReceived(tctx.client, b"\x05\x01\x00" + packed + b"\x12\x34applicationdata") - << OpenConnection(server) - >> reply(None) - << SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") - << NextLayerHook(nextlayer) + playbook + >> DataReceived(tctx.client, CLIENT_HELLO) + << SendData(tctx.client, SERVER_HELLO) + >> DataReceived(tctx.client, b"\x05\x01\x00" + packed + b"\x12\x34applicationdata") + << OpenConnection(server) + >> reply(None) + << SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") + << NextLayerHook(nextlayer) ) assert server().address == (address, 0x1234) assert nextlayer().data_client() == b"applicationdata" +def _valid_socks_auth(data: modes.Socks5AuthData): + data.valid = True + + def test_socks5_trickle(tctx: Context): + ProxyAuth().load(tctx.options) + tctx.options.proxyauth = "user:password" tctx.options.connection_strategy = "lazy" playbook = Playbook(modes.Socks5Proxy(tctx)) - for x in CLIENT_HELLO: + for x in b"\x05\x01\x02": playbook >> DataReceived(tctx.client, bytes([x])) + playbook << SendData(tctx.client, b"\x05\x02") + for x in b"\x05\x04user\x08password": + playbook >> DataReceived(tctx.client, bytes([x])) + playbook << modes.Socks5AuthHook(Placeholder()) + playbook >> reply(side_effect=_valid_socks_auth) playbook << SendData(tctx.client, b"\x05\x00") for x in b"\x05\x01\x00\x01\x7f\x00\x00\x01\x12\x34": playbook >> DataReceived(tctx.client, bytes([x])) @@ -343,8 +355,8 @@ def test_socks5_trickle(tctx: Context): ]) def test_socks5_err(data: bytes, err: bytes, msg: str, tctx: Context): playbook = ( - Playbook(modes.Socks5Proxy(tctx), logs=True) - >> DataReceived(tctx.client, data) + Playbook(modes.Socks5Proxy(tctx), logs=True) + >> DataReceived(tctx.client, data) ) if err: playbook << SendData(tctx.client, err) @@ -356,33 +368,36 @@ def test_socks5_err(data: bytes, err: bytes, msg: str, tctx: Context): @pytest.mark.parametrize("client_greeting,server_choice,client_auth,server_resp,address,packed", [ (b"\x05\x01\x02", b"\x05\x02", - b"\x01\x04" + b"user" + b"\x08" + b"password", - b"\x01\x00", + b"\x05\x04user\x08password", + b"\x05\x00", "127.0.0.1", b"\x01\x7f\x00\x00\x01"), (b"\x05\x02\x01\x02", b"\x05\x02", - b"\x01\x04" + b"user" + b"\x08" + b"password", - b"\x01\x00", + b"\x05\x04user\x08password", + b"\x05\x00", "127.0.0.1", b"\x01\x7f\x00\x00\x01"), ]) def test_socks5_auth_success(client_greeting: bytes, server_choice: bytes, client_auth: bytes, server_resp: bytes, address: bytes, packed: bytes, tctx: Context): - tctx.options.socks5auth = "user:password" + ProxyAuth().load(tctx.options) + tctx.options.proxyauth = "user:password" server = Placeholder(Server) nextlayer = Placeholder(NextLayer) playbook = ( - Playbook(modes.Socks5Proxy(tctx), logs=True) - >> DataReceived(tctx.client, client_greeting) - << SendData(tctx.client, server_choice) - >> DataReceived(tctx.client, client_auth) - << SendData(tctx.client, server_resp) - >> DataReceived(tctx.client, b"\x05\x01\x00" + packed + b"\x12\x34applicationdata") - << OpenConnection(server) - >> reply(None) - << SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") - << NextLayerHook(nextlayer) + Playbook(modes.Socks5Proxy(tctx), logs=True) + >> DataReceived(tctx.client, client_greeting) + << SendData(tctx.client, server_choice) + >> DataReceived(tctx.client, client_auth) + << modes.Socks5AuthHook(Placeholder(modes.Socks5AuthData)) + >> reply(side_effect=_valid_socks_auth) + << SendData(tctx.client, server_resp) + >> DataReceived(tctx.client, b"\x05\x01\x00" + packed + b"\x12\x34applicationdata") + << OpenConnection(server) + >> reply(None) + << SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") + << NextLayerHook(nextlayer) ) assert playbook assert server().address == (address, 0x1234) @@ -394,25 +409,28 @@ def test_socks5_auth_success(client_greeting: bytes, server_choice: bytes, clien None, None, b"\x05\xFF\x00\x01\x00\x00\x00\x00\x00\x00", - "mitmproxy only support user password authentication"), + "Client does not support SOCKS5 with user/password authentication."), (b"\x05\x02\x00\x02", b"\x05\x02", - b"\x01\x04" + b"user" + b"\x07" + b"errcode", - b"\x01\x01\x00\x01\x00\x00\x00\x00\x00\x00", + b"\x05\x04" + b"user" + b"\x07" + b"errcode", + b"\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00", "authentication failed"), ]) def test_socks5_auth_fail(client_greeting: bytes, server_choice: bytes, client_auth: bytes, err: bytes, msg: str, tctx: Context): - tctx.options.socks5auth = "user:password" + ProxyAuth().load(tctx.options) + tctx.options.proxyauth = "user:password" playbook = ( - Playbook(modes.Socks5Proxy(tctx), logs=True) - >> DataReceived(tctx.client, client_greeting) + Playbook(modes.Socks5Proxy(tctx), logs=True) + >> DataReceived(tctx.client, client_greeting) ) if server_choice is None: playbook << SendData(tctx.client, err) else: playbook << SendData(tctx.client, server_choice) playbook >> DataReceived(tctx.client, client_auth) + playbook << modes.Socks5AuthHook(Placeholder(modes.Socks5AuthData)) + playbook >> reply() playbook << SendData(tctx.client, err) playbook << CloseConnection(tctx.client) @@ -424,22 +442,22 @@ def test_socks5_eager_err(tctx: Context): tctx.options.connection_strategy = "eager" server = Placeholder(Server) assert ( - Playbook(modes.Socks5Proxy(tctx)) - >> DataReceived(tctx.client, CLIENT_HELLO) - << SendData(tctx.client, SERVER_HELLO) - >> DataReceived(tctx.client, b"\x05\x01\x00\x01\x7f\x00\x00\x01\x12\x34") - << OpenConnection(server) - >> reply("out of socks") - << SendData(tctx.client, b"\x05\x04\x00\x01\x00\x00\x00\x00\x00\x00") - << CloseConnection(tctx.client) + Playbook(modes.Socks5Proxy(tctx)) + >> DataReceived(tctx.client, CLIENT_HELLO) + << SendData(tctx.client, SERVER_HELLO) + >> DataReceived(tctx.client, b"\x05\x01\x00\x01\x7f\x00\x00\x01\x12\x34") + << OpenConnection(server) + >> reply("out of socks") + << SendData(tctx.client, b"\x05\x04\x00\x01\x00\x00\x00\x00\x00\x00") + << CloseConnection(tctx.client) ) def test_socks5_premature_close(tctx: Context): assert ( - Playbook(modes.Socks5Proxy(tctx), logs=True) - >> DataReceived(tctx.client, b"\x05") - >> ConnectionClosed(tctx.client) - << Log(r"Client closed connection before completing SOCKS5 handshake: b'\x05'") - << CloseConnection(tctx.client) + Playbook(modes.Socks5Proxy(tctx), logs=True) + >> DataReceived(tctx.client, b"\x05") + >> ConnectionClosed(tctx.client) + << Log(r"Client closed connection before completing SOCKS5 handshake: b'\x05'") + << CloseConnection(tctx.client) ) From 73d809a4c729814de75c962fa385dead69c4a1ff Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Wed, 25 Aug 2021 17:18:31 +0200 Subject: [PATCH 5/7] refactor proxyauth addon the previous version was difficult to read, this is hopefully better now. --- mitmproxy/addons/proxyauth.py | 354 +++++++++++++----------- test/mitmproxy/addons/test_proxyauth.py | 157 ++++------- 2 files changed, 243 insertions(+), 268 deletions(-) diff --git a/mitmproxy/addons/proxyauth.py b/mitmproxy/addons/proxyauth.py index 31005f20b..8dd6a1786 100644 --- a/mitmproxy/addons/proxyauth.py +++ b/mitmproxy/addons/proxyauth.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import binascii import weakref +from abc import ABC, abstractmethod from typing import MutableMapping from typing import Optional from typing import Tuple @@ -7,14 +10,136 @@ from typing import Tuple import ldap3 import passlib.apache -from mitmproxy import ctx, connection +from mitmproxy import connection, ctx from mitmproxy import exceptions from mitmproxy import http from mitmproxy.net.http import status_codes +from mitmproxy.proxy.layers import modes REALM = "mitmproxy" +class ProxyAuth: + validator: Optional[Validator] = None + + def __init__(self): + self.authenticated: MutableMapping[connection.Client, Tuple[str, str]] = weakref.WeakKeyDictionary() + """Contains all connections that are permanently authenticated after an HTTP CONNECT""" + + def load(self, loader): + loader.add_option( + "proxyauth", Optional[str], None, + """ + Require proxy authentication. Format: + "username:pass", + "any" to accept any user/pass combination, + "@path" to use an Apache htpasswd file, + or "ldap[s]:url_server_ldap:dn_auth:password:dn_subtree" for LDAP authentication. + """ + ) + + def configure(self, updated): + if "proxyauth" not in updated: + return + auth = ctx.options.proxyauth + if auth: + if ctx.options.mode == "transparent": + raise exceptions.OptionsError("Proxy Authentication not supported in transparent mode.") + + if auth == "any": + self.validator = AcceptAll() + elif auth.startswith("@"): + self.validator = Htpasswd(auth) + elif ctx.options.proxyauth.startswith("ldap"): + self.validator = Ldap(auth) + elif ":" in ctx.options.proxyauth: + self.validator = SingleUser(auth) + else: + raise exceptions.OptionsError("Invalid proxyauth specification.") + else: + self.validator = None + + def socks5_auth(self, data: modes.Socks5AuthData) -> None: + if self.validator and self.validator(data.username, data.password): + data.valid = True + + def http_connect(self, f: http.HTTPFlow) -> None: + if self.validator: + if self.authenticate_http(f): + # Make a note that all further requests over this connection are ok. + self.authenticated[f.client_conn] = f.metadata["proxyauth"] + + def requestheaders(self, f: http.HTTPFlow) -> None: + if self.validator: + # Is this connection authenticated by a previous HTTP CONNECT? + if f.client_conn in self.authenticated: + f.metadata["proxyauth"] = self.authenticated[f.client_conn] + else: + self.authenticate_http(f) + + def authenticate_http(self, f: http.HTTPFlow) -> bool: + """ + Authenticate an HTTP request, returns if authentication was successful. + + If valid credentials are found, the matching authentication header is removed. + In no or invalid credentials are found, flow.response is set to an error page. + """ + assert self.validator + username = None + password = None + is_valid = False + try: + auth_value = f.request.headers.get(self.http_auth_header, "") + scheme, username, password = parse_http_basic_auth(auth_value) + is_valid = self.validator(username, password) + except Exception: + pass + + if is_valid: + f.metadata["proxyauth"] = (username, password) + del f.request.headers[self.http_auth_header] + return True + else: + f.response = self.make_auth_required_response() + return False + + def make_auth_required_response(self) -> http.Response: + if self.is_http_proxy: + status_code = status_codes.PROXY_AUTH_REQUIRED + headers = {"Proxy-Authenticate": f'Basic realm="{REALM}"'} + else: + status_code = status_codes.UNAUTHORIZED + headers = {"WWW-Authenticate": f'Basic realm="{REALM}"'} + + reason = http.status_codes.RESPONSES[status_code] + return http.Response.make( + status_code, + ( + f"" + f"{status_code} {reason}" + f"

{status_code} {reason}

" + f"" + ), + headers + ) + + @property + def http_auth_header(self) -> str: + if self.is_http_proxy: + return "Proxy-Authorization" + else: + return "Authorization" + + @property + def is_http_proxy(self) -> bool: + """ + Returns: + - True, if authentication is done as if mitmproxy is a proxy + - False, if authentication is done as if mitmproxy is an HTTP server + """ + return ctx.options.mode == "regular" or ctx.options.mode.startswith("upstream:") + + def mkauth(username: str, password: str, scheme: str = "basic") -> str: """ Craft a basic auth string @@ -40,177 +165,78 @@ def parse_http_basic_auth(s: str) -> Tuple[str, str, str]: return scheme, user, password -class ProxyAuth: - def __init__(self): - self.nonanonymous = False - self.htpasswd = None - self.singleuser = None - self.ldapconn = None - self.ldapserver = None - self.authenticated: MutableMapping[connection.Client, Tuple[str, str]] = weakref.WeakKeyDictionary() - """Contains all connections that are permanently authenticated after an HTTP CONNECT""" +class Validator(ABC): + """Base class for all username/password validators.""" - def load(self, loader): - loader.add_option( - "proxyauth", Optional[str], None, - """ - Require proxy authentication. Format: - "username:pass", - "any" to accept any user/pass combination, - "@path" to use an Apache htpasswd file, - or "ldap[s]:url_server_ldap:dn_auth:password:dn_subtree" for LDAP authentication. - """ - ) + @abstractmethod + def __call__(self, username: str, password: str) -> bool: + raise NotImplementedError - def enabled(self) -> bool: - return any([self.nonanonymous, self.htpasswd, self.singleuser, self.ldapconn, self.ldapserver]) - def is_proxy_auth(self) -> bool: - """ - Returns: - - True, if authentication is done as if mitmproxy is a proxy - - False, if authentication is done as if mitmproxy is a HTTP server - """ - return ctx.options.mode == "regular" or ctx.options.mode.startswith("upstream:") +class AcceptAll(Validator): + def __call__(self, username: str, password: str) -> bool: + return True - def which_auth_header(self) -> str: - if self.is_proxy_auth(): - return 'Proxy-Authorization' - else: - return 'Authorization' - def auth_required_response(self) -> http.Response: - if self.is_proxy_auth(): - status_code = status_codes.PROXY_AUTH_REQUIRED - headers = {"Proxy-Authenticate": f'Basic realm="{REALM}"'} - else: - status_code = status_codes.UNAUTHORIZED - headers = {"WWW-Authenticate": f'Basic realm="{REALM}"'} - - reason = http.status_codes.RESPONSES[status_code] - return http.Response.make( - status_code, - ( - f"" - f"{status_code} {reason}" - f"

{status_code} {reason}

" - f"" - ), - headers - ) - - def check(self, f: http.HTTPFlow) -> Optional[Tuple[str, str]]: - """ - Check if a request is correctly authenticated. - Returns: - - a (username, password) tuple if successful, - - None, otherwise. - """ - auth_value = f.request.headers.get(self.which_auth_header(), "") +class SingleUser(Validator): + def __init__(self, proxyauth: str): try: - scheme, username, password = parse_http_basic_auth(auth_value) + self.username, self.password = proxyauth.split(':') except ValueError: - return None + raise exceptions.OptionsError("Invalid single-user auth specification.") - if self.nonanonymous: - return username, password - elif self.singleuser: - if self.singleuser == [username, password]: - return username, password - elif self.htpasswd: - if self.htpasswd.check_password(username, password): - return username, password - elif self.ldapconn: - if not username or not password: - return None - self.ldapconn.search(ctx.options.proxyauth.split(':')[4], '(cn=' + username + ')') - if self.ldapconn.response: - conn = ldap3.Connection( - self.ldapserver, - self.ldapconn.response[0]['dn'], - password, - auto_bind=True) - if conn: - return username, password - return None + def __call__(self, username: str, password: str) -> bool: + return self.username == username and self.password == password - def authenticate(self, f: http.HTTPFlow) -> bool: - valid_credentials = self.check(f) - if valid_credentials: - f.metadata["proxyauth"] = valid_credentials - del f.request.headers[self.which_auth_header()] - return True + +class Htpasswd(Validator): + def __init__(self, proxyauth: str): + path = proxyauth[1:] + try: + self.htpasswd = passlib.apache.HtpasswdFile(path) + except (ValueError, OSError): + raise exceptions.OptionsError(f"Could not open htpasswd file: {path}") + + def __call__(self, username: str, password: str) -> bool: + return self.htpasswd.check_password(username, password) + + +class Ldap(Validator): + conn: ldap3.Connection + server: ldap3.Server + dn_subtree: str + + def __init__(self, proxyauth: str): + try: + security, url, ldap_user, ldap_pass, self.dn_subtree = proxyauth.split(":") + except ValueError: + raise exceptions.OptionsError("Invalid ldap specification") + if security == "ldaps": + server = ldap3.Server(url, use_ssl=True) + elif security == "ldap": + server = ldap3.Server(url) else: - f.response = self.auth_required_response() + raise exceptions.OptionsError("Invalid ldap specification on the first part") + conn = ldap3.Connection( + server, + ldap_user, + ldap_pass, + auto_bind=True + ) + self.conn = conn + self.server = server + + def __call__(self, username: str, password: str) -> bool: + if not username or not password: return False - - # Handlers - def configure(self, updated): - if "proxyauth" in updated: - self.nonanonymous = False - self.singleuser = None - self.htpasswd = None - self.ldapserver = None - if ctx.options.proxyauth: - if ctx.options.proxyauth == "any": - self.nonanonymous = True - elif ctx.options.proxyauth.startswith("@"): - p = ctx.options.proxyauth[1:] - try: - self.htpasswd = passlib.apache.HtpasswdFile(p) - except (ValueError, OSError): - raise exceptions.OptionsError( - "Could not open htpasswd file: %s" % p - ) - elif ctx.options.proxyauth.startswith("ldap"): - parts = ctx.options.proxyauth.split(':') - if len(parts) != 5: - raise exceptions.OptionsError( - "Invalid ldap specification" - ) - security = parts[0] - ldap_server = parts[1] - dn_baseauth = parts[2] - password_baseauth = parts[3] - if security == "ldaps": - server = ldap3.Server(ldap_server, use_ssl=True) - elif security == "ldap": - server = ldap3.Server(ldap_server) - else: - raise exceptions.OptionsError( - "Invalid ldap specification on the first part" - ) - conn = ldap3.Connection( - server, - dn_baseauth, - password_baseauth, - auto_bind=True) - self.ldapconn = conn - self.ldapserver = server - elif ":" in ctx.options.proxyauth: - parts = ctx.options.proxyauth.split(':') - if len(parts) != 2: - raise exceptions.OptionsError( - "Invalid single-user auth specification." - ) - self.singleuser = parts - else: - raise exceptions.OptionsError("Invalid proxyauth specification.") - if self.enabled(): - if ctx.options.mode == "transparent": - raise exceptions.OptionsError( - "Proxy Authentication not supported in transparent mode." - ) - - def http_connect(self, f: http.HTTPFlow) -> None: - if self.enabled(): - if self.authenticate(f): - self.authenticated[f.client_conn] = f.metadata["proxyauth"] - - def requestheaders(self, f: http.HTTPFlow) -> None: - if self.enabled(): - # Is this connection authenticated by a previous HTTP CONNECT? - if f.client_conn in self.authenticated: - f.metadata["proxyauth"] = self.authenticated[f.client_conn] - return - self.authenticate(f) + self.conn.search(self.dn_subtree, f"(cn={username})") + if self.conn.response: + c = ldap3.Connection( + self.server, + self.conn.response[0]["dn"], + password, + auto_bind=True + ) + if c: + return True + return False diff --git a/test/mitmproxy/addons/test_proxyauth.py b/test/mitmproxy/addons/test_proxyauth.py index 2e6cde4d0..eaa489509 100644 --- a/test/mitmproxy/addons/test_proxyauth.py +++ b/test/mitmproxy/addons/test_proxyauth.py @@ -6,6 +6,7 @@ import pytest from mitmproxy import exceptions from mitmproxy.addons import proxyauth +from mitmproxy.proxy.layers import modes from mitmproxy.test import taddons from mitmproxy.test import tflow @@ -47,89 +48,42 @@ class TestProxyAuth: ('upstream:', True), ('upstream:foobar', True), ]) - def test_is_proxy_auth(self, mode, expected): + def test_is_http_proxy(self, mode, expected): up = proxyauth.ProxyAuth() with taddons.context(up, loadcore=False) as ctx: ctx.options.mode = mode - assert up.is_proxy_auth() is expected + assert up.is_http_proxy is expected - @pytest.mark.parametrize('is_proxy_auth, expected', [ + @pytest.mark.parametrize('is_http_proxy, expected', [ (True, 'Proxy-Authorization'), (False, 'Authorization'), ]) - def test_which_auth_header(self, is_proxy_auth, expected): + def test_which_auth_header(self, is_http_proxy, expected): up = proxyauth.ProxyAuth() - with mock.patch('mitmproxy.addons.proxyauth.ProxyAuth.is_proxy_auth', return_value=is_proxy_auth): - assert up.which_auth_header() == expected + with mock.patch('mitmproxy.addons.proxyauth.ProxyAuth.is_http_proxy', new=is_http_proxy): + assert up.http_auth_header == expected - @pytest.mark.parametrize('is_proxy_auth, expected_status_code, expected_header', [ + @pytest.mark.parametrize('is_http_proxy, expected_status_code, expected_header', [ (True, 407, 'Proxy-Authenticate'), (False, 401, 'WWW-Authenticate'), ]) - def test_auth_required_response(self, is_proxy_auth, expected_status_code, expected_header): + def test_auth_required_response(self, is_http_proxy, expected_status_code, expected_header): up = proxyauth.ProxyAuth() - with mock.patch('mitmproxy.addons.proxyauth.ProxyAuth.is_proxy_auth', return_value=is_proxy_auth): - resp = up.auth_required_response() + with mock.patch('mitmproxy.addons.proxyauth.ProxyAuth.is_http_proxy', new=is_http_proxy): + resp = up.make_auth_required_response() assert resp.status_code == expected_status_code assert expected_header in resp.headers.keys() - def test_check(self, tdata): - up = proxyauth.ProxyAuth() - with taddons.context(up) as ctx: - ctx.configure(up, proxyauth="any", mode="regular") - f = tflow.tflow() - assert not up.check(f) - f.request.headers["Proxy-Authorization"] = proxyauth.mkauth( - "test", "test" - ) - assert up.check(f) - - f.request.headers["Proxy-Authorization"] = "invalid" - assert not up.check(f) - - f.request.headers["Proxy-Authorization"] = proxyauth.mkauth( - "test", "test", scheme="unknown" - ) - assert not up.check(f) - - ctx.configure(up, proxyauth="test:test") - f.request.headers["Proxy-Authorization"] = proxyauth.mkauth( - "test", "test" - ) - assert up.check(f) - ctx.configure(up, proxyauth="test:foo") - assert not up.check(f) - - ctx.configure( - up, - proxyauth="@" + tdata.path( - "mitmproxy/net/data/htpasswd" - ) - ) - f.request.headers["Proxy-Authorization"] = proxyauth.mkauth( - "test", "test" - ) - assert up.check(f) - f.request.headers["Proxy-Authorization"] = proxyauth.mkauth( - "test", "foo" - ) - assert not up.check(f) - - with mock.patch('ldap3.Server', return_value="ldap://fake_server:389 - cleartext"): - with mock.patch('ldap3.Connection', search="test"): - with mock.patch('ldap3.Connection.search', return_value="test"): - ctx.configure( - up, - proxyauth="ldap:localhost:cn=default,dc=cdhdt,dc=com:password:ou=application,dc=cdhdt,dc=com" - ) - f.request.headers["Proxy-Authorization"] = proxyauth.mkauth( - "test", "test" - ) - assert up.check(f) - f.request.headers["Proxy-Authorization"] = proxyauth.mkauth( - "", "" - ) - assert not up.check(f) + def test_socks5(self): + pa = proxyauth.ProxyAuth() + with taddons.context(pa, loadcore=False) as ctx: + ctx.configure(pa, proxyauth="foo:bar", mode="regular") + data = modes.Socks5AuthData("foo", "baz") + pa.socks5_auth(data) + assert not data.valid + data.password = "bar" + pa.socks5_auth(data) + assert data.valid def test_authenticate(self): up = proxyauth.ProxyAuth() @@ -138,64 +92,60 @@ class TestProxyAuth: f = tflow.tflow() assert not f.response - up.authenticate(f) + up.authenticate_http(f) assert f.response.status_code == 407 f = tflow.tflow() f.request.headers["Proxy-Authorization"] = proxyauth.mkauth( "test", "test" ) - up.authenticate(f) + up.authenticate_http(f) assert not f.response assert not f.request.headers.get("Proxy-Authorization") f = tflow.tflow() ctx.configure(up, mode="reverse") assert not f.response - up.authenticate(f) + up.authenticate_http(f) assert f.response.status_code == 401 f = tflow.tflow() f.request.headers["Authorization"] = proxyauth.mkauth( "test", "test" ) - up.authenticate(f) + up.authenticate_http(f) assert not f.response assert not f.request.headers.get("Authorization") def test_configure(self, monkeypatch, tdata): - monkeypatch.setattr(ldap3, "Server", lambda *_, **__: True) - monkeypatch.setattr(ldap3, "Connection", lambda *_, **__: True) + monkeypatch.setattr(ldap3, "Server", mock.MagicMock()) + monkeypatch.setattr(ldap3, "Connection", mock.MagicMock()) pa = proxyauth.ProxyAuth() with taddons.context(pa) as ctx: with pytest.raises(exceptions.OptionsError, match="Invalid proxyauth specification"): ctx.configure(pa, proxyauth="foo") + ctx.configure(pa, proxyauth="foo:bar") + assert isinstance(pa.validator, proxyauth.SingleUser) + assert pa.validator("foo", "bar") + assert not pa.validator("foo", "baz") + with pytest.raises(exceptions.OptionsError, match="Invalid single-user auth specification."): ctx.configure(pa, proxyauth="foo:bar:baz") - ctx.configure(pa, proxyauth="foo:bar") - assert pa.singleuser == ["foo", "bar"] - - ctx.configure(pa, proxyauth=None) - assert pa.singleuser is None - ctx.configure(pa, proxyauth="any") - assert pa.nonanonymous + assert isinstance(pa.validator, proxyauth.AcceptAll) + assert pa.validator("foo", "bar") + ctx.configure(pa, proxyauth=None) - assert not pa.nonanonymous + assert pa.validator is None ctx.configure( pa, proxyauth="ldap:localhost:cn=default,dc=cdhdt,dc=com:password:ou=application,dc=cdhdt,dc=com" ) - assert pa.ldapserver - ctx.configure( - pa, - proxyauth="ldaps:localhost:cn=default,dc=cdhdt,dc=com:password:ou=application,dc=cdhdt,dc=com" - ) - assert pa.ldapserver + assert isinstance(pa.validator, proxyauth.Ldap) with pytest.raises(exceptions.OptionsError, match="Invalid ldap specification"): ctx.configure(pa, proxyauth="ldap:test:test:test") @@ -207,30 +157,18 @@ class TestProxyAuth: ctx.configure(pa, proxyauth="ldapssssssss:fake_server:dn:password:tree") with pytest.raises(exceptions.OptionsError, match="Could not open htpasswd file"): - ctx.configure( - pa, - proxyauth="@" + tdata.path("mitmproxy/net/data/server.crt") - ) + ctx.configure(pa, proxyauth="@" + tdata.path("mitmproxy/net/data/server.crt")) with pytest.raises(exceptions.OptionsError, match="Could not open htpasswd file"): ctx.configure(pa, proxyauth="@nonexistent") - ctx.configure( - pa, - proxyauth="@" + tdata.path( - "mitmproxy/net/data/htpasswd" - ) - ) - assert pa.htpasswd - assert pa.htpasswd.check_password("test", "test") - assert not pa.htpasswd.check_password("test", "foo") - ctx.configure(pa, proxyauth=None) - assert not pa.htpasswd + ctx.configure(pa, proxyauth="@" + tdata.path("mitmproxy/net/data/htpasswd")) + assert isinstance(pa.validator, proxyauth.Htpasswd) + assert pa.validator("test", "test") + assert not pa.validator("test", "foo") with pytest.raises(exceptions.OptionsError, match="Proxy Authentication not supported in transparent mode."): ctx.configure(pa, proxyauth="any", mode="transparent") - with pytest.raises(exceptions.OptionsError, match="Proxy Authentication not supported in SOCKS mode."): - ctx.configure(pa, proxyauth="any", mode="socks5") def test_handlers(self): up = proxyauth.ProxyAuth() @@ -260,3 +198,14 @@ class TestProxyAuth: up.requestheaders(f2) assert not f2.response assert f2.metadata["proxyauth"] == ('test', 'test') + + +def test_ldap(monkeypatch): + monkeypatch.setattr(ldap3, "Server", mock.MagicMock()) + monkeypatch.setattr(ldap3, "Connection", mock.MagicMock()) + + validator = proxyauth.Ldap("ldaps:localhost:cn=default,dc=cdhdt,dc=com:password:ou=application,dc=cdhdt,dc=com") + assert not validator("", "") + assert validator("foo", "bar") + validator.conn.response = False + assert not validator("foo", "bar") From f8826b29a24b51ef8fe6b9dbf574477db007592b Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Wed, 25 Aug 2021 17:23:40 +0200 Subject: [PATCH 6/7] cache socks5 auth for entire connection --- mitmproxy/addons/proxyauth.py | 8 ++++---- mitmproxy/proxy/layers/modes.py | 5 +++-- test/mitmproxy/addons/test_proxyauth.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/mitmproxy/addons/proxyauth.py b/mitmproxy/addons/proxyauth.py index 8dd6a1786..e80295036 100644 --- a/mitmproxy/addons/proxyauth.py +++ b/mitmproxy/addons/proxyauth.py @@ -62,12 +62,12 @@ class ProxyAuth: def socks5_auth(self, data: modes.Socks5AuthData) -> None: if self.validator and self.validator(data.username, data.password): data.valid = True + self.authenticated[data.client_conn] = data.username, data.password def http_connect(self, f: http.HTTPFlow) -> None: - if self.validator: - if self.authenticate_http(f): - # Make a note that all further requests over this connection are ok. - self.authenticated[f.client_conn] = f.metadata["proxyauth"] + if self.validator and self.authenticate_http(f): + # Make a note that all further requests over this connection are ok. + self.authenticated[f.client_conn] = f.metadata["proxyauth"] def requestheaders(self, f: http.HTTPFlow) -> None: if self.validator: diff --git a/mitmproxy/proxy/layers/modes.py b/mitmproxy/proxy/layers/modes.py index 5c7f4797b..426c2e8c0 100644 --- a/mitmproxy/proxy/layers/modes.py +++ b/mitmproxy/proxy/layers/modes.py @@ -4,7 +4,7 @@ from abc import ABCMeta from dataclasses import dataclass from typing import Optional -from mitmproxy import platform +from mitmproxy import connection, platform from mitmproxy.net import server_spec from mitmproxy.proxy import commands, events, layer from mitmproxy.proxy.commands import StartHook @@ -92,6 +92,7 @@ SOCKS5_REP_ADDRESS_TYPE_NOT_SUPPORTED = 0x08 @dataclass class Socks5AuthData: + client_conn: connection.Client username: str password: str valid: bool = False @@ -188,7 +189,7 @@ class Socks5Proxy(DestinationKnown): user = self.buf[2:(2 + user_len)].decode("utf-8", "backslashreplace") password = self.buf[(3 + user_len):(3 + user_len + pass_len)].decode("utf-8", "backslashreplace") - data = Socks5AuthData(user, password) + data = Socks5AuthData(self.context.client, user, password) yield Socks5AuthHook(data) if not data.valid: yield from self.socks_err("authentication failed", 0x01) diff --git a/test/mitmproxy/addons/test_proxyauth.py b/test/mitmproxy/addons/test_proxyauth.py index eaa489509..6182cdf99 100644 --- a/test/mitmproxy/addons/test_proxyauth.py +++ b/test/mitmproxy/addons/test_proxyauth.py @@ -78,7 +78,7 @@ class TestProxyAuth: pa = proxyauth.ProxyAuth() with taddons.context(pa, loadcore=False) as ctx: ctx.configure(pa, proxyauth="foo:bar", mode="regular") - data = modes.Socks5AuthData("foo", "baz") + data = modes.Socks5AuthData(tflow.tclient_conn(), "foo", "baz") pa.socks5_auth(data) assert not data.valid data.password = "bar" From f9ffe8279d9f6f53513496f9d1ac12cc89091ccf Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Fri, 27 Aug 2021 10:25:31 +0200 Subject: [PATCH 7/7] socks5: use correct version for auth negotiation --- mitmproxy/proxy/layers/modes.py | 6 ++++-- test/mitmproxy/proxy/layers/test_modes.py | 16 ++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/mitmproxy/proxy/layers/modes.py b/mitmproxy/proxy/layers/modes.py index 426c2e8c0..40ebfda12 100644 --- a/mitmproxy/proxy/layers/modes.py +++ b/mitmproxy/proxy/layers/modes.py @@ -192,10 +192,12 @@ class Socks5Proxy(DestinationKnown): data = Socks5AuthData(self.context.client, user, password) yield Socks5AuthHook(data) if not data.valid: - yield from self.socks_err("authentication failed", 0x01) + # The VER field contains the current **version of the subnegotiation**, which is X'01'. + yield commands.SendData(self.context.client, b"\x01\x01") + yield from self.socks_err("authentication failed") return - yield commands.SendData(self.context.client, b"\x05\x00") + yield commands.SendData(self.context.client, b"\x01\x00") self.buf = self.buf[3 + user_len + pass_len:] self.state = self.state_connect yield from self.state() diff --git a/test/mitmproxy/proxy/layers/test_modes.py b/test/mitmproxy/proxy/layers/test_modes.py index cea12f29a..b04a1cf62 100644 --- a/test/mitmproxy/proxy/layers/test_modes.py +++ b/test/mitmproxy/proxy/layers/test_modes.py @@ -329,11 +329,11 @@ def test_socks5_trickle(tctx: Context): for x in b"\x05\x01\x02": playbook >> DataReceived(tctx.client, bytes([x])) playbook << SendData(tctx.client, b"\x05\x02") - for x in b"\x05\x04user\x08password": + for x in b"\x01\x04user\x08password": playbook >> DataReceived(tctx.client, bytes([x])) playbook << modes.Socks5AuthHook(Placeholder()) playbook >> reply(side_effect=_valid_socks_auth) - playbook << SendData(tctx.client, b"\x05\x00") + playbook << SendData(tctx.client, b"\x01\x00") for x in b"\x05\x01\x00\x01\x7f\x00\x00\x01\x12\x34": playbook >> DataReceived(tctx.client, bytes([x])) assert playbook << SendData(tctx.client, b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") @@ -368,14 +368,14 @@ def test_socks5_err(data: bytes, err: bytes, msg: str, tctx: Context): @pytest.mark.parametrize("client_greeting,server_choice,client_auth,server_resp,address,packed", [ (b"\x05\x01\x02", b"\x05\x02", - b"\x05\x04user\x08password", - b"\x05\x00", + b"\x01\x04user\x08password", + b"\x01\x00", "127.0.0.1", b"\x01\x7f\x00\x00\x01"), (b"\x05\x02\x01\x02", b"\x05\x02", - b"\x05\x04user\x08password", - b"\x05\x00", + b"\x01\x04user\x08password", + b"\x01\x00", "127.0.0.1", b"\x01\x7f\x00\x00\x01"), ]) @@ -412,8 +412,8 @@ def test_socks5_auth_success(client_greeting: bytes, server_choice: bytes, clien "Client does not support SOCKS5 with user/password authentication."), (b"\x05\x02\x00\x02", b"\x05\x02", - b"\x05\x04" + b"user" + b"\x07" + b"errcode", - b"\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00", + b"\x01\x04" + b"user" + b"\x07" + b"errcode", + b"\x01\x01", "authentication failed"), ]) def test_socks5_auth_fail(client_greeting: bytes, server_choice: bytes, client_auth: bytes, err: bytes, msg: str,