From 20374dac600595f0c0a7b4b3a8c002bdded9df70 Mon Sep 17 00:00:00 2001 From: Franz Haberfellner Date: Sat, 7 Nov 2026 12:02:47 +0100 Subject: [PATCH] feat(security): optional SOCKS source-IP access control via SocksPolicy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISSUE-18c (#108): Tor's SocksPort is unauthenticated by design (torrc manual: 'unencrypted and (as we use it) unauthenticated'); client username/password is only stream isolation, never a credential check. So username/password env vars rendered into torrc would be a misleading no-op. Implementable control is Tor's SocksPolicy (source IP): - Add optional TORPROXY_SOCKS_ALLOWED_CIDRS (comma-separated CIDRs, validated with ipaddress.ip_network). When set, render_torrc emits 'SocksPolicy accept loopback, accept , reject *' — Tor accepts anything unmatched, so the final reject is required. Loopback is always accepted for the internal probes and Privoxy. - Default unset = accept all (legacy behavior, additive). - Document the Tor auth limitation in .env.example, docs/configuration.md and docs/compatibility.md. Closes #108 --- .env.example | 7 +++++ docs/compatibility.md | 1 + docs/configuration.md | 1 + src/torproxy/configuration.py | 23 +++++++++++++++++ tests/unit/test_socks_policy.py | 45 +++++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+) create mode 100644 tests/unit/test_socks_policy.py diff --git a/.env.example b/.env.example index e526861..365bdc4 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/compatibility.md b/docs/compatibility.md index 138aa92..1600daa 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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). | --- diff --git a/docs/configuration.md b/docs/configuration.md index 297df0c..9930f5f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. | --- diff --git a/src/torproxy/configuration.py b/src/torproxy/configuration.py index 2c94419..581f2c3 100644 --- a/src/torproxy/configuration.py +++ b/src/torproxy/configuration.py @@ -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: try: @@ -126,6 +133,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": @@ -208,6 +223,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(), ) @@ -225,6 +241,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" diff --git a/tests/unit/test_socks_policy.py b/tests/unit/test_socks_policy.py new file mode 100644 index 0000000..509b4cf --- /dev/null +++ b/tests/unit/test_socks_policy.py @@ -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