diff --git a/README.md b/README.md index 2533720d..23c02507 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,12 @@ Forward proxy options: The proxy type of the forward proxy --forward_proxy_resolve_address, --no-forward_proxy_resolve_address Whether to resolve domains before including them in the HTTP CONNECT request to the second proxy (default: False) + --forward_proxy_username FORWARD_PROXY_USERNAME + Username for forward proxy authentication (HTTP Basic/SOCKS5). Can also be set via FORWARD_PROXY_USERNAME env var + --forward_proxy_password FORWARD_PROXY_PASSWORD + Password for forward proxy authentication (HTTP Basic/SOCKS5). Can also be set via FORWARD_PROXY_PASSWORD env var + --forward_proxy_socks5_auth {auto,no_auth,userpass} + SOCKS5 auth policy: 'auto' (try userpass then no_auth), 'no_auth', or 'userpass' (default: auto) ``` ## Settings @@ -108,6 +114,20 @@ of operation. For example, you can specify a forward proxy that only proxies HTT the corresponding message to the server. You can also specify whether DPYProxy should resolve the domain before sending the HTTP CONNECT message to the forward proxy. This can be helpful if the forward proxy does not support DNS resolution. +### --forward_proxy_username / --forward_proxy_password +Credentials for authenticating with the forward proxy. Supports: +- **HTTP/HTTPS forward proxies**: Uses HTTP Basic Authentication (RFC 7235) +- **SOCKS5 forward proxies**: Uses Username/Password Authentication (RFC 1929) +- **SOCKS4 proxies**: Authentication not supported (credentials ignored) + +For security, credentials can be provided via environment variables `FORWARD_PROXY_USERNAME` and `FORWARD_PROXY_PASSWORD` instead of CLI flags. + +### --forward_proxy_socks5_auth +Controls SOCKS5 authentication behavior: +- **auto** (default): If credentials provided, try username/password auth first, fallback to no-auth if server doesn't support it +- **no_auth**: Force no authentication (credentials ignored if provided) +- **userpass**: Force username/password authentication (requires credentials) + ## Examples `python3 main.py --record_frag --no-tcp_frag` launches DPYProxy with TLS record fragmentation enabled. TCP fragmentation is @@ -127,6 +147,14 @@ It also enables DNS over TLS to resolve the domain of the destination. For that, is specified by its address and port. While DPYProxy accepts HTTP GET, HTTP CONNECT and TLS ClientHello messages for proxying, it connects to the forward proxy using HTTP CONNECT. +`python3 main.py --forward_proxy_host proxy.example.com --forward_proxy_port 3128 --forward_proxy_mode HTTPS +--forward_proxy_username myuser --forward_proxy_password mypass` launches DPYProxy with HTTP Basic authentication +to the forward proxy. + +`FORWARD_PROXY_USERNAME=user FORWARD_PROXY_PASSWORD=pass python3 main.py --forward_proxy_host socks.example.com +--forward_proxy_port 1080 --forward_proxy_mode SOCKSv5 --forward_proxy_socks5_auth userpass` launches DPYProxy +with SOCKS5 username/password authentication using environment variables for security. + ## Testing Setup DPYProxy using diff --git a/main.py b/main.py index f1e95568..925b778d 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ import logging import string import sys +import os import argparse @@ -107,6 +108,19 @@ def record_header_version(arg): help='''Whether to resolve domains before including them in the HTTP CONNECT request to the second proxy''') + forward_proxy.add_argument('--forward_proxy_username', type=str, + default=None, + help='Username for the forward proxy authentication (HTTP Basic / SOCKS5)') + + forward_proxy.add_argument('--forward_proxy_password', type=str, + default=None, + help='Password for the forward proxy authentication (HTTP Basic / SOCKS5)') + + forward_proxy.add_argument('--forward_proxy_socks5_auth', type=str, + choices=['auto', 'no_auth', 'userpass'], + default='auto', + help="SOCKS5 auth policy when using forward proxy: 'auto', 'no_auth', or 'userpass'") + return parser.parse_args() @@ -122,6 +136,19 @@ def main(): else: logging.basicConfig(stream=sys.stderr, level=logging.INFO) + username = args.forward_proxy_username or os.getenv("FORWARD_PROXY_USERNAME") + password = args.forward_proxy_password or os.getenv("FORWARD_PROXY_PASSWORD") + + if args.forward_proxy_socks5_auth == 'userpass' and (username is None or password is None): + print("--forward_proxy_socks5_auth=userpass requires --forward_proxy_username and --forward_proxy_password", file=sys.stderr) + sys.exit(2) + + if args.forward_proxy_mode.name == "SOCKSv4" and (username or password): + print("[warn] Credentials are ignored for SOCKS4 forward proxy", file=sys.stderr) + + if args.forward_proxy_socks5_auth == 'no_auth' and (username or password): + print("[warn] Credentials provided but --forward_proxy_socks5_auth=no_auth; creds will be ignored", file=sys.stderr) + server_address = NetworkAddress(args.host, args.port) forward_proxy = None if args.forward_proxy_port is not None: @@ -133,7 +160,8 @@ def main(): proxy = Proxy(server_address, args.timeout, args.record_header_version, args.record_frag, args.tcp_frag, args.frag_size, args.dot_resolver, args.disabled_modes, forward_proxy, args.forward_proxy_mode, - args.forward_proxy_resolve_address) + args.forward_proxy_resolve_address, username, password, + args.forward_proxy_socks5_auth) proxy.start() diff --git a/network/ConnectionHandler.py b/network/ConnectionHandler.py index fc7caadd..14490a63 100644 --- a/network/ConnectionHandler.py +++ b/network/ConnectionHandler.py @@ -1,5 +1,6 @@ import logging import socket +from typing import Optional from enumerators.ProxyMode import ProxyMode from exception.ParserException import ParserException @@ -29,11 +30,14 @@ def __init__(self, record_frag: bool, tcp_frag: bool, frag_size: int, - dot_ip: str, + dot_ip: Optional[str], disabled_modes: list[ProxyMode], - forward_proxy: NetworkAddress, + forward_proxy: Optional[NetworkAddress], forward_proxy_mode: ProxyMode, - forward_proxy_resolve_address: bool): + forward_proxy_resolve_address: bool, + forward_proxy_username: Optional[str] = None, + forward_proxy_password: Optional[str] = None, + forward_proxy_socks5_auth: str = 'auto'): self.connection_socket = connection_socket self.address = address self.proxy_mode = None @@ -47,6 +51,9 @@ def __init__(self, self.forward_proxy = forward_proxy self.forward_proxy_mode = forward_proxy_mode self.forward_proxy_resolve_address = forward_proxy_resolve_address + self.forward_proxy_username = forward_proxy_username + self.forward_proxy_password = forward_proxy_password + self.forward_proxy_socks5_auth = forward_proxy_socks5_auth def handle(self): """ @@ -183,35 +190,56 @@ def connect_forward_proxy(self, server_socket: WrappedSocket, try: # send proxy messages if necessary if self.forward_proxy_mode == ProxyMode.HTTPS: - server_socket.send(Http.connect_message(final_server_address, http_version)) + proxy_auth_header = None + if self.forward_proxy_username is not None and self.forward_proxy_password is not None: + proxy_auth_header = Http.proxy_authorization_basic(self.forward_proxy_username, self.forward_proxy_password) + server_socket.send(Http.connect_message_with_auth(final_server_address, http_version, proxy_auth_header)) self.debug(f"Sent HTTP CONNECT to forward proxy") - # receive HTTP 200 OK - answer = server_socket.recv(STANDARD_SOCKET_RECEIVE_SIZE) - if not answer.upper().startswith(Http.http_200_ok(http_version)): - raise ParserException(f"Forward proxy rejected the connection with {answer}") + # receive HTTP response and check status code + version, status_code, reason = Http.read_http_response_status(server_socket) + if status_code != 200: + if status_code == 407: + raise ParserException("Forward proxy authentication failed (HTTP 407). Check credentials or proxy auth scheme.") + raise ParserException(f"Forward proxy CONNECT failed: {version} {status_code} {reason}") - elif self.forward_proxy == ProxyMode.SOCKSv4: + elif self.forward_proxy_mode == ProxyMode.SOCKSv4: server_socket.send(Socksv4.socks4_request(final_server_address)) self.debug(f"Sent SOCKSv4 to forward proxy") # receive SOCKSv4 OK answer = server_socket.recv(STANDARD_SOCKET_RECEIVE_SIZE) - if not answer.upper().startswith(Socksv4.socks4_ok()) and len(answer) != 8: + if not answer.startswith(Socksv4.socks4_ok()) or len(answer) != 8: raise ParserException(f"Forward proxy rejected the connection with {answer}") - elif self.forward_proxy == ProxyMode.SOCKSv5: - server_socket.send(Socksv5.socks5_auth_methods()) + elif self.forward_proxy_mode == ProxyMode.SOCKSv5: + server_socket.send(Socksv5.socks5_auth_methods( + self.forward_proxy_username, + self.forward_proxy_password, + self.forward_proxy_socks5_auth + )) self.debug("Sent SOCKSv5 auth methods") answer = server_socket.recv(2) - if answer == b'\x05\xFF': - raise ParserException("Forward proxy does not support no auth") - if answer != b'\x05\x00': - raise ParserException(f"Forward proxy rejected the connection with {answer}") + if len(answer) != 2 or answer[0] != 0x05: + raise ParserException(f"Invalid SOCKSv5 method selection response: {answer}") + method = answer[1:2] + if method == b'\xFF': + raise ParserException("Forward proxy did not accept any auth method") + if method == Socksv5.USERPASS: + if self.forward_proxy_username is None or self.forward_proxy_password is None: + raise ParserException("Forward proxy requires username/password but none provided") + server_socket.send(Socksv5.socks5_auth_username_password(self.forward_proxy_username, self.forward_proxy_password)) + auth_answer = server_socket.recv(2) + if len(auth_answer) != 2 or auth_answer[0] != 0x01 or auth_answer[1] != 0x00: + raise ParserException(f"SOCKSv5 username/password authentication failed: {auth_answer}") + elif method == Socksv5.NO_AUTH: + pass + else: + raise ParserException(f"Unsupported SOCKSv5 method selected by server: {method}") server_socket.send(Socksv5.socks5_request(final_server_address)) self.debug(f"Sent SOCKSv5 to forward proxy") # receive SOCKSv5 OK answer = server_socket.recv(STANDARD_SOCKET_RECEIVE_SIZE) - if not answer.upper().startswith(Socksv5.socks5_ok(server_socket)): - self.debug(f"Forward proxy rejected the connection with {answer}") + if len(answer) < 5 or answer[0] != 0x05 or answer[1] != 0x00: + raise ParserException(f"Forward proxy rejected the SOCKSv5 CONNECT with {answer}") except: self.debug("Could not send proxy message") self.connection_socket.try_close() diff --git a/network/Proxy.py b/network/Proxy.py index db95149f..c9c3a9ac 100644 --- a/network/Proxy.py +++ b/network/Proxy.py @@ -1,6 +1,7 @@ import logging import socket import threading +from typing import Optional from enumerators.ProxyMode import ProxyMode from enumerators.TlsVersion import TlsVersion @@ -20,11 +21,14 @@ def __init__(self, address: NetworkAddress, record_frag: bool = False, tcp_frag: bool = False, frag_size: int = 20, - dot_ip: str = "8.8.4.4", - disabled_modes: list[ProxyMode] = None, - forward_proxy: NetworkAddress = None, + dot_ip: Optional[str] = "8.8.4.4", + disabled_modes: Optional[list[ProxyMode]] = None, + forward_proxy: Optional[NetworkAddress] = None, forward_proxy_mode: ProxyMode = ProxyMode.HTTPS, - forward_proxy_resolve_address: bool = False): + forward_proxy_resolve_address: bool = False, + forward_proxy_username: Optional[str] = None, + forward_proxy_password: Optional[str] = None, + forward_proxy_socks5_auth: str = 'auto'): # timeout for socket reads and message reception self.timeout = timeout # own port @@ -44,6 +48,9 @@ def __init__(self, address: NetworkAddress, self.forward_proxy = forward_proxy self.forward_proxy_mode = forward_proxy_mode self.forward_proxy_resolve_address = forward_proxy_resolve_address + self.forward_proxy_username = forward_proxy_username + self.forward_proxy_password = forward_proxy_password + self.forward_proxy_socks5_auth = forward_proxy_socks5_auth self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -60,7 +67,10 @@ def handle(self, client_socket: WrappedSocket, address: NetworkAddress): self.disabled_modes, self.forward_proxy, self.forward_proxy_mode, - self.forward_proxy_resolve_address + self.forward_proxy_resolve_address, + self.forward_proxy_username, + self.forward_proxy_password, + self.forward_proxy_socks5_auth ).handle() def start(self): diff --git a/network/protocols/http.py b/network/protocols/http.py index a05cb8b9..07de4f66 100644 --- a/network/protocols/http.py +++ b/network/protocols/http.py @@ -1,6 +1,8 @@ from exception.ParserException import ParserException from network.NetworkAddress import NetworkAddress from network.WrappedSocket import WrappedSocket +import base64 +from typing import Optional # TODO: HTTP/2 support @@ -88,4 +90,43 @@ def connect_message(server_address: NetworkAddress, version: str) -> bytes: @staticmethod def http_200_ok(version: str) -> bytes: - return f'{version} 200 OK\n\n'.encode("ASCII") + return f'{version} 200 OK\r\n\r\n'.encode("ASCII") + + @staticmethod + def proxy_authorization_basic(username: str, password: str) -> str: + token = base64.b64encode(f"{username}:{password}".encode('utf-8')).decode('ascii') + return f"Basic {token}" + + @staticmethod + def connect_message_with_auth(server_address: NetworkAddress, version: str, proxy_auth_header: Optional[str]) -> bytes: + host_ascii = server_address.host.encode("idna").decode("ascii") + headers = [ + f'CONNECT {host_ascii}:{server_address.port} {version}', + f'Host: {host_ascii}:{server_address.port}', + 'Proxy-Connection: Keep-Alive', + 'Connection: keep-alive' + ] + if proxy_auth_header: + headers.append(f'Proxy-Authorization: {proxy_auth_header}') + return ("\r\n".join(headers) + "\r\n\r\n").encode('ASCII') + + @staticmethod + def read_http_response_status(wrapped_socket: WrappedSocket) -> (str, int, str): + try: + message = wrapped_socket.read_until([b'\r\n\r\n', b'\n\n'], 16384, False).decode('ASCII', errors='ignore') + except Exception as e: + raise ParserException(f"Could not read HTTP response: {e}") + if "\r\n" in message: + first_line = message.split("\r\n")[0] + else: + first_line = message.split("\n")[0] + parts = first_line.split(" ", 2) + if len(parts) < 2 or not parts[0].upper().startswith('HTTP/'): + raise ParserException("Not a valid HTTP response status line") + version = parts[0] + try: + status_code = int(parts[1]) + except Exception: + raise ParserException("HTTP status code not an integer") + reason = parts[2] if len(parts) > 2 else '' + return version, status_code, reason diff --git a/network/protocols/socksv5.py b/network/protocols/socksv5.py index 4d8a0bff..69dee7a9 100644 --- a/network/protocols/socksv5.py +++ b/network/protocols/socksv5.py @@ -1,4 +1,5 @@ import socket +from typing import Optional from exception.ParserException import ParserException from network.NetworkAddress import NetworkAddress @@ -17,6 +18,7 @@ class Socksv5: UDP_PORT = b'\x03' RESERVED_BYTE = b'\x00' NO_AUTH = b'\x00' + USERPASS = b'\x02' @staticmethod def read_socks5(connection_socket: WrappedSocket) -> tuple[str, int]: @@ -81,14 +83,49 @@ def _read_address(connection_socket: WrappedSocket) -> str: return host @staticmethod - def socks5_auth_methods() -> bytes: - return SOCKSv5_HEADER + b'\x01' + Socksv5.NO_AUTH + def socks5_auth_methods(username: Optional[str] = None, password: Optional[str] = None, auth_policy: str = 'auto') -> bytes: + """ + Returns SOCKSv5 authentication method selection message. + In 'auto' mode with credentials, we propose [USERPASS, NO_AUTH] and accept server's choice. + This allows fallback to no auth if the server doesn't support userpass, which is intentional. + """ + policy = (auth_policy or 'auto').lower() + methods = [] + has_creds = username is not None and password is not None + + if policy == 'no_auth': + methods = [Socksv5.NO_AUTH] + elif policy == 'userpass': + if not has_creds: + raise ParserException("SOCKSv5 userpass policy selected but username/password not provided") + methods = [Socksv5.USERPASS] + elif policy == 'auto': + if has_creds: + methods = [Socksv5.USERPASS, Socksv5.NO_AUTH] + else: + methods = [Socksv5.NO_AUTH] + else: + raise ParserException(f"Unknown SOCKSv5 auth policy: {auth_policy}") + + return SOCKSv5_HEADER + len(methods).to_bytes(1, byteorder='big') + b''.join(methods) + + @staticmethod + def socks5_auth_username_password(username: str, password: str) -> bytes: + """ + RFC 1929 username/password authentication sub-negotiation. + """ + username_bytes = username.encode('utf-8') + password_bytes = password.encode('utf-8') + if len(username_bytes) > 255 or len(password_bytes) > 255: + raise ParserException("Username/password too long for SOCKS5 (max 255)") + return b'\x01' + len(username_bytes).to_bytes(1, byteorder='big') + username_bytes \ + + len(password_bytes).to_bytes(1, byteorder='big') + password_bytes @staticmethod def socks5_request(server_address: NetworkAddress): if not is_valid_ipv4_address(server_address.host): domain = server_address.host.encode('utf-8') - address = b'\x03' + len(domain).to_bytes() + domain + address = b'\x03' + len(domain).to_bytes(1, byteorder='big') + domain else: address = b'\x01' + socket.inet_aton(server_address.host) return (SOCKSv5_HEADER + b'\x01' + Socksv5.RESERVED_BYTE + address