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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add warnings to the Readme, help command, and execution when any kind of authentication is used that passwords are transmitted in cleartext. Users should be aware, but I would still like to provide an explicit hint.

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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
30 changes: 29 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import string
import sys
import os

import argparse

Expand Down Expand Up @@ -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()


Expand All @@ -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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a warning for incomplete credentials for HTTPS proxy mode and a warning for a configured SOCKSv5 proxy auth when the mode is HTTPS

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed I should add sanity checks to the rest of the parameters as well... thanks for the implicit heads up!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JonSnowWhite I think cli is starting to bloat significantly due to the large number of command flags. Maybe migrate to some sort of config? Probably as part of a separate issue.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely an option for the future, yes

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:
Expand All @@ -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()


Expand Down
64 changes: 46 additions & 18 deletions network/ConnectionHandler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import socket
from typing import Optional

from enumerators.ProxyMode import ProxyMode
from exception.ParserException import ParserException
Expand Down Expand Up @@ -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
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add parantheses here

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()
Expand Down
20 changes: 15 additions & 5 deletions network/Proxy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import socket
import threading
from typing import Optional

from enumerators.ProxyMode import ProxyMode
from enumerators.TlsVersion import TlsVersion
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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):
Expand Down
43 changes: 42 additions & 1 deletion network/protocols/http.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
43 changes: 40 additions & 3 deletions network/protocols/socksv5.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import socket
from typing import Optional

from exception.ParserException import ParserException
from network.NetworkAddress import NetworkAddress
Expand All @@ -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]:
Expand Down Expand Up @@ -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
Expand Down