watchd: tighten secondary check validation
- judge blocks record as neutral (judge_block category), not success; evaluate() filters them out so they affect neither pass nor fail count - require HTTP/1.x response line for non-IRC checks; non-HTTP garbage (captive portals, proxy error pages) fails immediately - add is_public_ip() rejecting RFC 1918, loopback, link-local, and multicast ranges from judge exit IP extraction - remove 5 weak HEAD regex targets whose fingerprint headers appear on error pages and captive portals (p3p, X-XSS-Protection, x-frame-options, referrer-policy, X-UA-Compatible)
This commit is contained in:
@@ -142,6 +142,20 @@ def is_valid_ip(ip_str):
|
|||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def is_public_ip(ip_str):
|
||||||
|
"""Validate IP is a public, globally routable address."""
|
||||||
|
if not is_valid_ip(ip_str):
|
||||||
|
return False
|
||||||
|
parts = [int(p) for p in ip_str.split('.')]
|
||||||
|
if parts[0] == 0: return False # 0.0.0.0/8
|
||||||
|
if parts[0] == 10: return False # 10.0.0.0/8
|
||||||
|
if parts[0] == 127: return False # 127.0.0.0/8
|
||||||
|
if parts[0] == 169 and parts[1] == 254: return False # link-local
|
||||||
|
if parts[0] == 172 and 16 <= parts[1] <= 31: return False # 172.16/12
|
||||||
|
if parts[0] == 192 and parts[1] == 168: return False # 192.168/16
|
||||||
|
if parts[0] >= 224: return False # multicast + reserved
|
||||||
|
return True
|
||||||
|
|
||||||
# Pattern for header echo - if X-Forwarded-For or Via present, proxy reveals chain
|
# Pattern for header echo - if X-Forwarded-For or Via present, proxy reveals chain
|
||||||
HEADER_REVEAL_PATTERN = r'(X-Forwarded-For|Via|X-Real-Ip|Forwarded)'
|
HEADER_REVEAL_PATTERN = r'(X-Forwarded-For|Via|X-Real-Ip|Forwarded)'
|
||||||
|
|
||||||
@@ -393,10 +407,20 @@ class ProxyTestState(object):
|
|||||||
self.evaluated = True
|
self.evaluated = True
|
||||||
self.checktime = int(time.time())
|
self.checktime = int(time.time())
|
||||||
|
|
||||||
successes = [r for r in self.results if r['success']]
|
# Filter out judge_block results (inconclusive, neither pass nor fail)
|
||||||
failures = [r for r in self.results if not r['success']]
|
real_results = [r for r in self.results if r.get('category') != 'judge_block']
|
||||||
|
successes = [r for r in real_results if r['success']]
|
||||||
|
failures = [r for r in real_results if not r['success']]
|
||||||
num_success = len(successes)
|
num_success = len(successes)
|
||||||
_dbg('evaluate: %d success, %d fail, results=%d' % (num_success, len(failures), len(self.results)), self.proxy)
|
judge_blocks = len(self.results) - len(real_results)
|
||||||
|
_dbg('evaluate: %d success, %d fail, %d judge_block, results=%d' % (
|
||||||
|
num_success, len(failures), judge_blocks, len(self.results)), self.proxy)
|
||||||
|
|
||||||
|
# All results were judge blocks: inconclusive, preserve current state
|
||||||
|
if not real_results and self.results:
|
||||||
|
_dbg('all results inconclusive (judge_block), no state change', self.proxy)
|
||||||
|
self.failcount = self.original_failcount
|
||||||
|
return (self.original_failcount == 0, None)
|
||||||
|
|
||||||
# Determine dominant failure category
|
# Determine dominant failure category
|
||||||
fail_category = None
|
fail_category = None
|
||||||
@@ -547,6 +571,12 @@ class TargetTestJob(object):
|
|||||||
recv = sock.recv(-1)
|
recv = sock.recv(-1)
|
||||||
_sample_dbg('RECV: %d bytes from %s, first 80: %r' % (len(recv), srv, recv[:80]), self.proxy_state.proxy)
|
_sample_dbg('RECV: %d bytes from %s, first 80: %r' % (len(recv), srv, recv[:80]), self.proxy_state.proxy)
|
||||||
|
|
||||||
|
# Validate HTTP response for non-IRC checks
|
||||||
|
if self.checktype != 'irc' and not recv.startswith('HTTP/'):
|
||||||
|
_dbg('not an HTTP response, failing (first 40: %r)' % recv[:40], self.proxy_state.proxy)
|
||||||
|
self.proxy_state.record_result(False, category='bad_response')
|
||||||
|
return
|
||||||
|
|
||||||
# Select regex based on check type (or fallback target)
|
# Select regex based on check type (or fallback target)
|
||||||
if 'check.torproject.org' in srv:
|
if 'check.torproject.org' in srv:
|
||||||
# Tor API fallback (judge using torproject.org)
|
# Tor API fallback (judge using torproject.org)
|
||||||
@@ -571,7 +601,7 @@ class TargetTestJob(object):
|
|||||||
reveals_headers = None
|
reveals_headers = None
|
||||||
if self.checktype == 'judges' or 'check.torproject.org' in srv:
|
if self.checktype == 'judges' or 'check.torproject.org' in srv:
|
||||||
ip_match = re.search(IP_PATTERN, recv)
|
ip_match = re.search(IP_PATTERN, recv)
|
||||||
if ip_match and is_valid_ip(ip_match.group(0)):
|
if ip_match and is_public_ip(ip_match.group(0)):
|
||||||
exit_ip = ip_match.group(0)
|
exit_ip = ip_match.group(0)
|
||||||
if self.checktype == 'judges' and 'check.torproject.org' not in srv:
|
if self.checktype == 'judges' and 'check.torproject.org' not in srv:
|
||||||
# Check for header echo judge (elite detection)
|
# Check for header echo judge (elite detection)
|
||||||
@@ -590,17 +620,14 @@ class TargetTestJob(object):
|
|||||||
# Check if judge is blocking us (not a proxy failure)
|
# Check if judge is blocking us (not a proxy failure)
|
||||||
if self.checktype == 'judges' and JUDGE_BLOCK_RE.search(recv):
|
if self.checktype == 'judges' and JUDGE_BLOCK_RE.search(recv):
|
||||||
judge_stats.record_block(srv)
|
judge_stats.record_block(srv)
|
||||||
# Judge block = proxy worked, we got HTTP response, just no IP
|
# Judge block = inconclusive, not a pass or fail
|
||||||
# Count as success without exit_ip
|
_dbg('judge BLOCK detected, skipping (neutral)', self.proxy_state.proxy)
|
||||||
block_elapsed = time.time() - duration
|
|
||||||
_dbg('judge BLOCK detected, counting as success', self.proxy_state.proxy)
|
|
||||||
self.proxy_state.record_result(
|
self.proxy_state.record_result(
|
||||||
True, proto=proto, duration=block_elapsed,
|
False, category='judge_block', proto=proto,
|
||||||
srv=srv, tor=tor, ssl=is_ssl, exit_ip=None,
|
srv=srv, tor=tor, ssl=is_ssl
|
||||||
reveals_headers=None
|
|
||||||
)
|
)
|
||||||
if config.watchd.debug:
|
if config.watchd.debug:
|
||||||
_log('judge %s challenged proxy %s (counted as success)' % (
|
_log('judge %s challenged proxy %s (neutral, skipped)' % (
|
||||||
srv, self.proxy_state.proxy), 'debug')
|
srv, self.proxy_state.proxy), 'debug')
|
||||||
else:
|
else:
|
||||||
_dbg('FAIL: no match, no block', self.proxy_state.proxy)
|
_dbg('FAIL: no match, no block', self.proxy_state.proxy)
|
||||||
@@ -675,15 +702,14 @@ class TargetTestJob(object):
|
|||||||
protos = self._build_proto_order()
|
protos = self._build_proto_order()
|
||||||
pool = connection_pool.get_pool()
|
pool = connection_pool.get_pool()
|
||||||
|
|
||||||
# Phase 1: SSL handshake (if ssl_first enabled)
|
# Phase 1: SSL handshake (if ssl_first enabled or SSL-only mode)
|
||||||
if config.watchd.ssl_first:
|
if config.watchd.ssl_first or self.checktype == 'none':
|
||||||
result = self._try_ssl_handshake(protos, pool)
|
result = self._try_ssl_handshake(protos, pool)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result # SSL succeeded or MITM detected
|
return result # SSL succeeded or MITM detected
|
||||||
# SSL failed for all protocols
|
# SSL failed for all protocols
|
||||||
if config.watchd.ssl_only:
|
if config.watchd.ssl_only or self.checktype == 'none':
|
||||||
# ssl_only mode: skip secondary check, mark as failed
|
_dbg('SSL failed, no secondary check', ps.proxy)
|
||||||
_dbg('SSL failed, ssl_only mode, skipping secondary check', ps.proxy)
|
|
||||||
return (None, None, 0, None, None, 1, 0, 'ssl_only')
|
return (None, None, 0, None, None, 1, 0, 'ssl_only')
|
||||||
_dbg('SSL failed, trying secondary check: %s' % self.checktype, ps.proxy)
|
_dbg('SSL failed, trying secondary check: %s' % self.checktype, ps.proxy)
|
||||||
|
|
||||||
@@ -1357,7 +1383,11 @@ class Proxywatchd():
|
|||||||
# Build target pools for each checktype
|
# Build target pools for each checktype
|
||||||
target_pools = {}
|
target_pools = {}
|
||||||
for ct in checktypes:
|
for ct in checktypes:
|
||||||
if ct == 'irc':
|
if ct == 'none':
|
||||||
|
# SSL-only mode: use ssl_targets as placeholder
|
||||||
|
target_pools[ct] = ssl_targets
|
||||||
|
_dbg('target_pool[none]: SSL-only mode, %d ssl targets' % len(ssl_targets))
|
||||||
|
elif ct == 'irc':
|
||||||
target_pools[ct] = config.servers
|
target_pools[ct] = config.servers
|
||||||
_dbg('target_pool[irc]: %d servers' % len(config.servers))
|
_dbg('target_pool[irc]: %d servers' % len(config.servers))
|
||||||
elif ct == 'judges':
|
elif ct == 'judges':
|
||||||
|
|||||||
5
stats.py
5
stats.py
@@ -107,11 +107,9 @@ regexes = {
|
|||||||
'www.twitter.com': 'x-connection-hash',
|
'www.twitter.com': 'x-connection-hash',
|
||||||
't.co': 'x-connection-hash',
|
't.co': 'x-connection-hash',
|
||||||
'www.msn.com': 'x-aspnetmvc-version',
|
'www.msn.com': 'x-aspnetmvc-version',
|
||||||
'www.bing.com': 'p3p',
|
|
||||||
'www.ask.com': 'x-served-by',
|
'www.ask.com': 'x-served-by',
|
||||||
'www.hotmail.com': 'x-msedge-ref',
|
'www.hotmail.com': 'x-msedge-ref',
|
||||||
'www.bbc.co.uk': 'x-bbc-edge-cache-status',
|
'www.bbc.co.uk': 'x-bbc-edge-cache-status',
|
||||||
'www.skype.com': 'X-XSS-Protection',
|
|
||||||
'www.alibaba.com': 'object-status',
|
'www.alibaba.com': 'object-status',
|
||||||
'www.mozilla.org': 'cf-ray',
|
'www.mozilla.org': 'cf-ray',
|
||||||
'www.cloudflare.com': 'cf-ray',
|
'www.cloudflare.com': 'cf-ray',
|
||||||
@@ -121,7 +119,6 @@ regexes = {
|
|||||||
'www.netflix.com': 'X-Netflix.proxy.execution-time',
|
'www.netflix.com': 'X-Netflix.proxy.execution-time',
|
||||||
'www.amazon.de': 'x-amz-cf-id',
|
'www.amazon.de': 'x-amz-cf-id',
|
||||||
'www.reuters.com': 'x-amz-cf-id',
|
'www.reuters.com': 'x-amz-cf-id',
|
||||||
'www.ikea.com': 'x-frame-options',
|
|
||||||
'www.twitpic.com': 'timing-allow-origin',
|
'www.twitpic.com': 'timing-allow-origin',
|
||||||
'www.digg.com': 'cf-request-id',
|
'www.digg.com': 'cf-request-id',
|
||||||
'www.wikia.com': 'x-served-by',
|
'www.wikia.com': 'x-served-by',
|
||||||
@@ -133,8 +130,6 @@ regexes = {
|
|||||||
'www.yelp.com': 'x-timer',
|
'www.yelp.com': 'x-timer',
|
||||||
'www.ebay.com': 'x-envoy-upstream-service-time',
|
'www.ebay.com': 'x-envoy-upstream-service-time',
|
||||||
'www.wikihow.com': 'x-c',
|
'www.wikihow.com': 'x-c',
|
||||||
'www.archive.org': 'referrer-policy',
|
|
||||||
'www.pandora.tv': 'X-UA-Compatible',
|
|
||||||
'www.w3.org': 'x-backend',
|
'www.w3.org': 'x-backend',
|
||||||
'www.time.com': 'x-amz-cf-pop'
|
'www.time.com': 'x-amz-cf-pop'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user