Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ TORPROXY_API_PORT=8080
# Tor SOCKS5 listener inside the container and host port mapping.
TORPROXY_SOCKS_PORT=9050

# Optional: Restrict which source CIDRs may use the SOCKS port
# (Tor SocksPolicy, ISSUE-18c). Comma-separated CIDRs, e.g.
# 172.16.0.0/12. Loopback is always accepted internally.
# Tor's SocksPort is unauthenticated by design — this is
# source-IP access control, not username/password auth.
# TORPROXY_SOCKS_ALLOWED_CIDRS=172.16.0.0/12

# Optional: Specific exit country code (e.g. DE, AT, US, or comma-separated DE,AT).
# TORPROXY_COUNTRY=DE

Expand Down
1 change: 1 addition & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ This document defines the strict, non-negotiable backward compatibility contract
| `TORPROXY_GEOIP_AUTO_UPDATE` | `0` | Boolean (`0`, `1`, `true`, `false`, `yes`, `no`) | Optional additive: Periodically download Tor GeoIP DB from Tor Project GitLab. Default: `0` (bundled DB, unchanged behavior). |
| `TORPROXY_API_TOKEN` | *(unset)* | String | Optional: Bearer token / API key securing REST API (8080). Default: unset (unauthenticated). |
| `TORPROXY_API_ALLOW_ANONYMOUS_HEALTH` | `0` | Boolean (`0`, `1`, `true`, `false`) | Optional: Permits unauthenticated `/health` probe access when token auth is enabled. |
| `TORPROXY_SOCKS_ALLOWED_CIDRS` | *(unset)* | Comma-separated CIDRs | Optional: Restricts SOCKS port access by source CIDR (Tor `SocksPolicy`). Default unset = accept all (legacy behavior). |

---

Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ All settings have sensible defaults that allow running `torproxy` with zero conf
| `TORPROXY_GEOIP_URL` | Tor Project GitLab `geoip` | `https://` URL | Source URL for the IPv4 GeoIP database. |
| `TORPROXY_GEOIP6_URL` | Tor Project GitLab `geoip6` | `https://` URL | Source URL for the IPv6 GeoIP database. |
| `TORPROXY_GEOIP_MAX_BYTES` | `10485760` | Positive integer | Maximum accepted size per GeoIP file; larger payloads are rejected. |
| `TORPROXY_SOCKS_ALLOWED_CIDRS` | *(unset)* | Comma-separated CIDRs | Optional: Restrict which source networks may use the SOCKS port (Tor `SocksPolicy` — accept listed CIDRs plus loopback, reject all else). Default unset = accept all (legacy behavior). Note: Tor's SocksPort is unauthenticated by design; client username/password is only stream isolation, not a credential check. |

---

Expand Down
23 changes: 23 additions & 0 deletions src/torproxy/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ class Settings:
geoip_url: str = "https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip"
geoip6_url: str = "https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6"
geoip_max_bytes: int = 10 * 1024 * 1024
# ISSUE-18c (#108): optional comma-separated CIDRs allowed to
# use the SOCKS port (Tor SocksPolicy). Empty = accept all
# (legacy behavior). Tor's SocksPort is unauthenticated by
# design — username/password from clients is only stream
# isolation, never a credential check — so source-IP policy
# is the strongest access control Tor itself offers.
socks_allowed_cidrs: str = ""

def __post_init__(self) -> None:
# ISSUE-18b (#107): IPv4 and IPv6 listen addresses are
Expand Down Expand Up @@ -128,6 +135,14 @@ def __post_init__(self) -> None:
):
if not url.startswith("https://"):
raise ValueError(f"{name} must be an https:// URL")
if self.socks_allowed_cidrs:
for cidr in self.socks_allowed_cidrs.split(","):
try:
ipaddress.ip_network(cidr.strip(), strict=False)
except ValueError:
raise ValueError(
"TORPROXY_SOCKS_ALLOWED_CIDRS must be comma-separated CIDRs"
) from None

@classmethod
def from_mapping(cls, env: Mapping[str, str]) -> "Settings":
Expand Down Expand Up @@ -210,6 +225,7 @@ def _int_setting(name: str, default: int) -> int:
"https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6",
),
geoip_max_bytes=_int_setting("TORPROXY_GEOIP_MAX_BYTES", 10 * 1024 * 1024),
socks_allowed_cidrs=env.get("TORPROXY_SOCKS_ALLOWED_CIDRS", "").strip(),
)


Expand All @@ -227,6 +243,13 @@ def render_torrc(settings: Settings, template: str, geoip_active: bool = False)
"GeoIPv6File /usr/share/tor/geoip6",
f"GeoIPv6File {settings.geoip_dir}/geoip6",
)
if settings.socks_allowed_cidrs:
# Loopback is always accepted: the internal SOCKS
# readiness probe and Privoxy connect from 127.0.0.1.
# Tor's SocksPolicy accepts anything not matched, so a
# final "reject *" is required to actually restrict.
cidrs = ", ".join(c.strip() for c in settings.socks_allowed_cidrs.split(","))
result += f"\nSocksPolicy accept 127.0.0.0/8, accept ::1/128, accept {cidrs}, reject *\n"
if settings.gateway_mode:
result += f"\nTransPort 127.0.0.1:{settings.trans_port}\n"
result += f"DNSPort 127.0.0.1:{settings.dns_port}\n"
Expand Down
45 changes: 45 additions & 0 deletions tests/unit/test_socks_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""ISSUE-18c (#108): source-IP access control for the SOCKS port.

Tor's SocksPort is unauthenticated by design (client-supplied
username/password is only stream isolation, never a credential
check), so the implementable control is Tor's SocksPolicy:
accept listed CIDRs, reject everything else. Loopback must
always stay accepted for the internal probes and Privoxy.
"""

import pytest

from torproxy.configuration import Settings, render_torrc


@pytest.mark.unit
class TestSocksPolicySettings:
def test_default_no_policy(self):
assert Settings().socks_allowed_cidrs == ""

def test_cidrs_from_env(self):
settings = Settings.from_mapping(
{"TORPROXY_SOCKS_ALLOWED_CIDRS": "172.16.0.0/12,10.0.0.0/8"}
)
assert settings.socks_allowed_cidrs == "172.16.0.0/12,10.0.0.0/8"

def test_invalid_cidr_rejected(self):
with pytest.raises(ValueError, match="TORPROXY_SOCKS_ALLOWED_CIDRS"):
Settings(socks_allowed_cidrs="not-a-cidr")

def test_ipv6_cidr_accepted(self):
settings = Settings(socks_allowed_cidrs="fd00::/8")
assert settings.socks_allowed_cidrs == "fd00::/8"


@pytest.mark.unit
class TestSocksPolicyRender:
def test_no_policy_line_by_default(self):
torrc = render_torrc(Settings(), "SocksPort 0.0.0.0:9050\n")
assert "SocksPolicy" not in torrc

def test_policy_accepts_listed_and_loopback_rejects_rest(self):
settings = Settings(socks_allowed_cidrs="172.16.0.0/12")
torrc = render_torrc(settings, "SocksPort 0.0.0.0:9050\n")
expected = "SocksPolicy accept 127.0.0.0/8, accept ::1/128, accept 172.16.0.0/12, reject *"
assert expected in torrc
Loading