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):
|
||||
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
|
||||
HEADER_REVEAL_PATTERN = r'(X-Forwarded-For|Via|X-Real-Ip|Forwarded)'
|
||||
|
||||
@@ -393,10 +407,20 @@ class ProxyTestState(object):
|
||||
self.evaluated = True
|
||||
self.checktime = int(time.time())
|
||||
|
||||
successes = [r for r in self.results if r['success']]
|
||||
failures = [r for r in self.results if not r['success']]
|
||||
# Filter out judge_block results (inconclusive, neither pass nor fail)
|
||||
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)
|
||||
_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
|
||||
fail_category = None
|
||||
@@ -547,6 +571,12 @@ class TargetTestJob(object):
|
||||
recv = sock.recv(-1)
|
||||
_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)
|
||||
if 'check.torproject.org' in srv:
|
||||
# Tor API fallback (judge using torproject.org)
|
||||
@@ -571,7 +601,7 @@ class TargetTestJob(object):
|
||||
reveals_headers = None
|
||||
if self.checktype == 'judges' or 'check.torproject.org' in srv:
|
||||
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)
|
||||
if self.checktype == 'judges' and 'check.torproject.org' not in srv:
|
||||
# Check for header echo judge (elite detection)
|
||||
@@ -590,17 +620,14 @@ class TargetTestJob(object):
|
||||
# Check if judge is blocking us (not a proxy failure)
|
||||
if self.checktype == 'judges' and JUDGE_BLOCK_RE.search(recv):
|
||||
judge_stats.record_block(srv)
|
||||
# Judge block = proxy worked, we got HTTP response, just no IP
|
||||
# Count as success without exit_ip
|
||||
block_elapsed = time.time() - duration
|
||||
_dbg('judge BLOCK detected, counting as success', self.proxy_state.proxy)
|
||||
# Judge block = inconclusive, not a pass or fail
|
||||
_dbg('judge BLOCK detected, skipping (neutral)', self.proxy_state.proxy)
|
||||
self.proxy_state.record_result(
|
||||
True, proto=proto, duration=block_elapsed,
|
||||
srv=srv, tor=tor, ssl=is_ssl, exit_ip=None,
|
||||
reveals_headers=None
|
||||
False, category='judge_block', proto=proto,
|
||||
srv=srv, tor=tor, ssl=is_ssl
|
||||
)
|
||||
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')
|
||||
else:
|
||||
_dbg('FAIL: no match, no block', self.proxy_state.proxy)
|
||||
@@ -675,15 +702,14 @@ class TargetTestJob(object):
|
||||
protos = self._build_proto_order()
|
||||
pool = connection_pool.get_pool()
|
||||
|
||||
# Phase 1: SSL handshake (if ssl_first enabled)
|
||||
if config.watchd.ssl_first:
|
||||
# Phase 1: SSL handshake (if ssl_first enabled or SSL-only mode)
|
||||
if config.watchd.ssl_first or self.checktype == 'none':
|
||||
result = self._try_ssl_handshake(protos, pool)
|
||||
if result is not None:
|
||||
return result # SSL succeeded or MITM detected
|
||||
# SSL failed for all protocols
|
||||
if config.watchd.ssl_only:
|
||||
# ssl_only mode: skip secondary check, mark as failed
|
||||
_dbg('SSL failed, ssl_only mode, skipping secondary check', ps.proxy)
|
||||
if config.watchd.ssl_only or self.checktype == 'none':
|
||||
_dbg('SSL failed, no secondary check', ps.proxy)
|
||||
return (None, None, 0, None, None, 1, 0, 'ssl_only')
|
||||
_dbg('SSL failed, trying secondary check: %s' % self.checktype, ps.proxy)
|
||||
|
||||
@@ -1357,7 +1383,11 @@ class Proxywatchd():
|
||||
# Build target pools for each checktype
|
||||
target_pools = {}
|
||||
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
|
||||
_dbg('target_pool[irc]: %d servers' % len(config.servers))
|
||||
elif ct == 'judges':
|
||||
|
||||
5
stats.py
5
stats.py
@@ -107,11 +107,9 @@ regexes = {
|
||||
'www.twitter.com': 'x-connection-hash',
|
||||
't.co': 'x-connection-hash',
|
||||
'www.msn.com': 'x-aspnetmvc-version',
|
||||
'www.bing.com': 'p3p',
|
||||
'www.ask.com': 'x-served-by',
|
||||
'www.hotmail.com': 'x-msedge-ref',
|
||||
'www.bbc.co.uk': 'x-bbc-edge-cache-status',
|
||||
'www.skype.com': 'X-XSS-Protection',
|
||||
'www.alibaba.com': 'object-status',
|
||||
'www.mozilla.org': 'cf-ray',
|
||||
'www.cloudflare.com': 'cf-ray',
|
||||
@@ -121,7 +119,6 @@ regexes = {
|
||||
'www.netflix.com': 'X-Netflix.proxy.execution-time',
|
||||
'www.amazon.de': 'x-amz-cf-id',
|
||||
'www.reuters.com': 'x-amz-cf-id',
|
||||
'www.ikea.com': 'x-frame-options',
|
||||
'www.twitpic.com': 'timing-allow-origin',
|
||||
'www.digg.com': 'cf-request-id',
|
||||
'www.wikia.com': 'x-served-by',
|
||||
@@ -133,8 +130,6 @@ regexes = {
|
||||
'www.yelp.com': 'x-timer',
|
||||
'www.ebay.com': 'x-envoy-upstream-service-time',
|
||||
'www.wikihow.com': 'x-c',
|
||||
'www.archive.org': 'referrer-policy',
|
||||
'www.pandora.tv': 'X-UA-Compatible',
|
||||
'www.w3.org': 'x-backend',
|
||||
'www.time.com': 'x-amz-cf-pop'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user