mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-17 19:35:40 +02:00
* Add bounded virtual-host discovery * Document virtual host probe boundary
1235 lines
44 KiB
Python
1235 lines
44 KiB
Python
import asyncio
|
|
import logging
|
|
import math
|
|
import ssl
|
|
import subprocess
|
|
import time
|
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from theHarvester.lib import virtual_host as virtual_host_module
|
|
from theHarvester.lib.virtual_host import (
|
|
VHOST_BODY_LIMIT,
|
|
ProbeObservation,
|
|
VirtualHostDiscoveryCancelled,
|
|
VirtualHostDiscoveryResult,
|
|
VirtualHostLimits,
|
|
VirtualHostObservation,
|
|
VirtualHostRequest,
|
|
classify_virtual_host,
|
|
discover_harvested_virtual_hosts,
|
|
discover_virtual_hosts,
|
|
)
|
|
|
|
|
|
async def read_host(reader: asyncio.StreamReader) -> str:
|
|
request = await reader.readuntil(b'\r\n\r\n')
|
|
host_line = next(line for line in request.split(b'\r\n') if line.lower().startswith(b'host:'))
|
|
return host_line.split(b':', 1)[1].strip().decode()
|
|
|
|
|
|
async def write_response(
|
|
writer: asyncio.StreamWriter,
|
|
*,
|
|
status: bytes = b'200 OK',
|
|
body: bytes = b'default page',
|
|
headers: tuple[bytes, ...] = (),
|
|
content_length: int | None = None,
|
|
) -> None:
|
|
header_bytes = b''.join(header + b'\r\n' for header in headers)
|
|
length = len(body) if content_length is None else content_length
|
|
writer.write(
|
|
b'HTTP/1.1 '
|
|
+ status
|
|
+ b'\r\n'
|
|
+ header_bytes
|
|
+ b'Content-Length: '
|
|
+ str(length).encode()
|
|
+ b'\r\nConnection: close\r\n\r\n'
|
|
+ body
|
|
)
|
|
await writer.drain()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def local_server(
|
|
handler: Callable[[asyncio.StreamReader, asyncio.StreamWriter], Awaitable[None]],
|
|
*,
|
|
host: str = '127.0.0.1',
|
|
ssl_context: ssl.SSLContext | None = None,
|
|
) -> AsyncIterator[int]:
|
|
server = await asyncio.start_server(handler, host, 0, ssl=ssl_context)
|
|
try:
|
|
yield server.sockets[0].getsockname()[1]
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def tls_cert_chain(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]:
|
|
tls_directory = tmp_path_factory.mktemp('vhost-tls')
|
|
certificate = tls_directory / 'certificate.pem'
|
|
private_key = tls_directory / 'private-key.pem'
|
|
subprocess.run(
|
|
[
|
|
'openssl',
|
|
'req',
|
|
'-x509',
|
|
'-newkey',
|
|
'rsa:2048',
|
|
'-nodes',
|
|
'-sha256',
|
|
'-days',
|
|
'1',
|
|
'-subj',
|
|
'/CN=example.com',
|
|
'-keyout',
|
|
str(private_key),
|
|
'-out',
|
|
str(certificate),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
return certificate, private_key
|
|
|
|
|
|
def response(
|
|
hostname: str,
|
|
*,
|
|
status: int = 200,
|
|
body: bytes = b'default page',
|
|
location: str | None = None,
|
|
) -> ProbeObservation:
|
|
return ProbeObservation(
|
|
hostname=hostname,
|
|
http_host=hostname,
|
|
tls_server_name=hostname,
|
|
phase='body',
|
|
status=status,
|
|
location=location,
|
|
body=body,
|
|
)
|
|
|
|
|
|
def discovery_result(
|
|
*,
|
|
observations: tuple[VirtualHostObservation, ...] = (),
|
|
request_count: int,
|
|
attempted_candidate_count: int,
|
|
stop_reason: str = 'completed',
|
|
request_error_count: int = 0,
|
|
request_error_types: tuple[str, ...] = (),
|
|
scan_error_type: str | None = None,
|
|
) -> VirtualHostDiscoveryResult:
|
|
return VirtualHostDiscoveryResult(
|
|
context=response('192.0.2.20'),
|
|
controls=(),
|
|
observations=observations,
|
|
request_count=request_count,
|
|
attempted_candidate_count=attempted_candidate_count,
|
|
stop_reason=stop_reason,
|
|
request_error_count=request_error_count,
|
|
request_error_types=request_error_types,
|
|
scan_error_type=scan_error_type,
|
|
)
|
|
|
|
|
|
def distinct_observation(endpoint: str, *, hostname: str = 'admin.example.com') -> VirtualHostObservation:
|
|
controls = tuple(response(f'unknown-{index}.example.com') for index in range(3))
|
|
return classify_virtual_host(
|
|
endpoint,
|
|
response('192.0.2.20'),
|
|
response(hostname, status=401),
|
|
controls,
|
|
)
|
|
|
|
|
|
async def test_harvested_virtual_host_sweep_carries_unused_budget_forward(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
requests: list[VirtualHostRequest] = []
|
|
|
|
async def fake_discover(request: VirtualHostRequest, **_kwargs):
|
|
requests.append(request)
|
|
return discovery_result(
|
|
request_count=8,
|
|
attempted_candidate_count=len(request.candidates),
|
|
)
|
|
|
|
monkeypatch.setattr(virtual_host_module, 'discover_virtual_hosts', fake_discover)
|
|
|
|
with caplog.at_level(logging.INFO, logger=virtual_host_module.__name__):
|
|
result = await discover_harvested_virtual_hosts(
|
|
scope='Example.COM.',
|
|
addresses=('2001:db8::20', '192.0.2.20'),
|
|
candidates=('admin.example.com', 'panel.example.com'),
|
|
limits=VirtualHostLimits(request_limit=40, runtime_seconds=20),
|
|
)
|
|
|
|
assert [request.endpoint for request in requests] == [
|
|
'https://192.0.2.20:443/',
|
|
'https://[2001:db8::20]:443/',
|
|
'http://192.0.2.20:80/',
|
|
'http://[2001:db8::20]:80/',
|
|
]
|
|
assert [request.limits.request_limit for request in requests] == [10, 10, 12, 16]
|
|
assert all(request.scope == 'example.com' for request in requests)
|
|
assert all(request.candidates == ('admin.example.com', 'panel.example.com') for request in requests)
|
|
assert result.request_count == 32
|
|
assert result.endpoint_count == 4
|
|
assert result.total_endpoint_count == 4
|
|
assert result.candidate_endpoint_count == 8
|
|
assert result.total_candidate_endpoint_count == 8
|
|
assert result.stop_reason == 'completed'
|
|
assert 'Virtual-host endpoint 1/4 started: candidates=2; request-limit=10' in caplog.text
|
|
assert 'Virtual-host endpoint 4/4 finished: stop=completed; requests=8; candidates=2/2; errors=0' in caplog.text
|
|
|
|
|
|
async def test_harvested_virtual_host_sweep_reports_every_budget_truncation(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
requests: list[VirtualHostRequest] = []
|
|
|
|
async def fake_discover(request: VirtualHostRequest, **_kwargs):
|
|
requests.append(request)
|
|
return discovery_result(
|
|
request_count=5,
|
|
attempted_candidate_count=len(request.candidates),
|
|
)
|
|
|
|
monkeypatch.setattr(virtual_host_module, 'discover_virtual_hosts', fake_discover)
|
|
|
|
result = await discover_harvested_virtual_hosts(
|
|
scope='Example.COM.',
|
|
addresses=('192.0.2.20', '2001:db8::20'),
|
|
candidates=('a.example.com', 'preview.example.com'),
|
|
limits=VirtualHostLimits(request_limit=10, runtime_seconds=20),
|
|
)
|
|
|
|
assert [request.endpoint for request in requests] == [
|
|
'https://192.0.2.20:443/',
|
|
'https://[2001:db8::20]:443/',
|
|
]
|
|
assert all(request.candidates == ('a.example.com',) for request in requests)
|
|
assert result.request_count == 10
|
|
assert result.endpoint_count == 2
|
|
assert result.total_endpoint_count == 4
|
|
assert result.candidate_endpoint_count == 2
|
|
assert result.total_candidate_endpoint_count == 8
|
|
assert result.stop_reason == 'request-limit'
|
|
|
|
|
|
async def test_harvested_virtual_host_sweep_has_one_hard_runtime_bound(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_discover(
|
|
request: VirtualHostRequest,
|
|
*,
|
|
_preserve_partial_on_cancel: bool = False,
|
|
):
|
|
assert _preserve_partial_on_cancel is True
|
|
try:
|
|
await asyncio.sleep(1)
|
|
except asyncio.CancelledError:
|
|
partial = classify_virtual_host(
|
|
request.endpoint,
|
|
response('192.0.2.20'),
|
|
response('admin.example.com', status=401),
|
|
tuple(response(f'unknown-{index}.example.com') for index in range(3)),
|
|
)
|
|
return discovery_result(
|
|
observations=(partial,),
|
|
request_count=5,
|
|
attempted_candidate_count=1,
|
|
stop_reason='runtime-limit',
|
|
)
|
|
|
|
monkeypatch.setattr(virtual_host_module, 'discover_virtual_hosts', fake_discover)
|
|
started = time.perf_counter()
|
|
|
|
result = await discover_harvested_virtual_hosts(
|
|
scope='example.com',
|
|
addresses=('192.0.2.20',),
|
|
candidates=('admin.example.com',),
|
|
limits=VirtualHostLimits(request_limit=10, runtime_seconds=0.02),
|
|
)
|
|
|
|
assert time.perf_counter() - started < 0.2
|
|
assert tuple(observation.hostname for observation in result.observations) == ('admin.example.com',)
|
|
assert result.request_count == 5
|
|
assert result.candidate_endpoint_count == 1
|
|
assert result.total_candidate_endpoint_count == 2
|
|
assert result.stop_reason == 'runtime-limit'
|
|
|
|
|
|
async def test_harvested_virtual_host_sweep_retains_completed_endpoints_after_a_later_scan_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
call_count = 0
|
|
|
|
async def fake_discover(
|
|
request: VirtualHostRequest,
|
|
*,
|
|
_preserve_partial_on_cancel: bool = False,
|
|
):
|
|
nonlocal call_count
|
|
assert _preserve_partial_on_cancel is True
|
|
call_count += 1
|
|
if call_count == 2:
|
|
raise RuntimeError('endpoint failed')
|
|
return discovery_result(
|
|
observations=(distinct_observation(request.endpoint),),
|
|
request_count=5,
|
|
attempted_candidate_count=1,
|
|
)
|
|
|
|
monkeypatch.setattr(virtual_host_module, 'discover_virtual_hosts', fake_discover)
|
|
result = await discover_harvested_virtual_hosts(
|
|
scope='example.com',
|
|
addresses=('192.0.2.20',),
|
|
candidates=('admin.example.com',),
|
|
limits=VirtualHostLimits(request_limit=10, runtime_seconds=10),
|
|
)
|
|
|
|
assert tuple(observation.endpoint for observation in result.observations) == ('https://192.0.2.20:443/',)
|
|
assert result.request_count == 5
|
|
assert result.candidate_endpoint_count == 1
|
|
assert result.stop_reason == 'scan-error'
|
|
assert result.scan_error_type == 'RuntimeError'
|
|
|
|
|
|
async def test_discovery_returns_an_earlier_batch_when_a_later_probe_crashes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_probe(
|
|
_session: object,
|
|
_request: VirtualHostRequest,
|
|
hostname: str | None,
|
|
) -> ProbeObservation:
|
|
if hostname == 'login.example.com':
|
|
raise RuntimeError('probe crashed')
|
|
if hostname == 'admin.example.com':
|
|
return response(hostname, status=401)
|
|
return response(hostname or '192.0.2.20')
|
|
|
|
monkeypatch.setattr(virtual_host_module, '_probe', fake_probe)
|
|
|
|
result = await discover_virtual_hosts(
|
|
VirtualHostRequest(
|
|
endpoint='http://192.0.2.20/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com', 'login.example.com'),
|
|
limits=VirtualHostLimits(concurrency=1),
|
|
)
|
|
)
|
|
|
|
assert tuple(observation.hostname for observation in result.observations) == ('admin.example.com',)
|
|
assert result.observations[0].classification == 'distinct'
|
|
assert result.request_count == 6
|
|
assert result.attempted_candidate_count == 1
|
|
assert result.stop_reason == 'scan-error'
|
|
assert result.scan_error_type == 'RuntimeError'
|
|
|
|
|
|
async def test_harvested_virtual_host_sweep_keeps_same_endpoint_evidence_after_a_scan_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[str] = []
|
|
|
|
async def fake_discover(request: VirtualHostRequest, **_kwargs: object) -> VirtualHostDiscoveryResult:
|
|
calls.append(request.endpoint)
|
|
return discovery_result(
|
|
observations=(distinct_observation(request.endpoint),),
|
|
request_count=6,
|
|
attempted_candidate_count=1,
|
|
stop_reason='scan-error',
|
|
scan_error_type='RuntimeError',
|
|
)
|
|
|
|
monkeypatch.setattr(virtual_host_module, 'discover_virtual_hosts', fake_discover)
|
|
|
|
result = await discover_harvested_virtual_hosts(
|
|
scope='example.com',
|
|
addresses=('192.0.2.20',),
|
|
candidates=('admin.example.com', 'portal.example.com'),
|
|
limits=VirtualHostLimits(request_limit=20, runtime_seconds=10),
|
|
)
|
|
|
|
assert calls == ['https://192.0.2.20:443/']
|
|
assert tuple(observation.hostname for observation in result.observations) == ('admin.example.com',)
|
|
assert result.request_count == 6
|
|
assert result.candidate_endpoint_count == 1
|
|
assert result.stop_reason == 'scan-error'
|
|
assert result.scan_error_type == 'RuntimeError'
|
|
|
|
|
|
async def test_harvested_virtual_host_sweep_cancellation_carries_partial_evidence(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
second_endpoint_started = asyncio.Event()
|
|
call_count = 0
|
|
|
|
async def fake_discover(
|
|
request: VirtualHostRequest,
|
|
*,
|
|
_preserve_partial_on_cancel: bool = False,
|
|
):
|
|
nonlocal call_count
|
|
assert _preserve_partial_on_cancel is True
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return discovery_result(
|
|
observations=(distinct_observation(request.endpoint),),
|
|
request_count=5,
|
|
attempted_candidate_count=1,
|
|
)
|
|
second_endpoint_started.set()
|
|
try:
|
|
await asyncio.sleep(1)
|
|
except asyncio.CancelledError:
|
|
return discovery_result(
|
|
observations=(distinct_observation(request.endpoint),),
|
|
request_count=5,
|
|
attempted_candidate_count=1,
|
|
stop_reason='runtime-limit',
|
|
)
|
|
|
|
monkeypatch.setattr(virtual_host_module, 'discover_virtual_hosts', fake_discover)
|
|
task = asyncio.create_task(
|
|
discover_harvested_virtual_hosts(
|
|
scope='example.com',
|
|
addresses=('192.0.2.20',),
|
|
candidates=('admin.example.com',),
|
|
limits=VirtualHostLimits(request_limit=10, runtime_seconds=10),
|
|
)
|
|
)
|
|
await second_endpoint_started.wait()
|
|
task.cancel()
|
|
|
|
with pytest.raises(VirtualHostDiscoveryCancelled) as cancelled:
|
|
await task
|
|
|
|
result = cancelled.value.result
|
|
assert tuple(observation.endpoint for observation in result.observations) == (
|
|
'https://192.0.2.20:443/',
|
|
'http://192.0.2.20:80/',
|
|
)
|
|
assert result.request_count == 10
|
|
assert result.candidate_endpoint_count == 2
|
|
assert result.stop_reason == 'cancelled'
|
|
assert result.scan_error_type == 'CancelledError'
|
|
|
|
|
|
async def test_harvested_virtual_host_sweep_propagates_cancellation_during_deadline_cleanup(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
deadline_cancelled = asyncio.Event()
|
|
|
|
async def fake_discover(
|
|
_request: VirtualHostRequest,
|
|
*,
|
|
_preserve_partial_on_cancel: bool = False,
|
|
):
|
|
assert _preserve_partial_on_cancel is True
|
|
try:
|
|
await asyncio.sleep(1)
|
|
except asyncio.CancelledError:
|
|
deadline_cancelled.set()
|
|
try:
|
|
await asyncio.sleep(1)
|
|
except asyncio.CancelledError:
|
|
return discovery_result(
|
|
request_count=1,
|
|
attempted_candidate_count=0,
|
|
stop_reason='runtime-limit',
|
|
)
|
|
|
|
monkeypatch.setattr(virtual_host_module, 'discover_virtual_hosts', fake_discover)
|
|
task = asyncio.create_task(
|
|
discover_harvested_virtual_hosts(
|
|
scope='example.com',
|
|
addresses=('192.0.2.20',),
|
|
candidates=('admin.example.com',),
|
|
limits=VirtualHostLimits(request_limit=10, runtime_seconds=0.02),
|
|
)
|
|
)
|
|
await deadline_cancelled.wait()
|
|
task.cancel()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
def test_classifier_marks_a_candidate_matching_stable_controls_as_default() -> None:
|
|
controls = tuple(response(f'unknown-{index}.example.com') for index in range(3))
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
response('admin.example.com'),
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'default'
|
|
assert observation.distinct_signals == ()
|
|
|
|
|
|
def test_classifier_marks_a_candidate_matching_the_raw_ip_context_as_default() -> None:
|
|
controls = tuple(response(f'unknown-{index}.example.com') for index in range(3))
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10', status=401, body=b'raw endpoint'),
|
|
response('admin.example.com', status=401, body=b'raw endpoint'),
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'default'
|
|
assert observation.distinct_signals == ()
|
|
|
|
|
|
def test_classifier_keeps_a_candidate_indeterminate_when_the_raw_ip_context_fails() -> None:
|
|
context = ProbeObservation(
|
|
hostname='192.0.2.10',
|
|
http_host='192.0.2.10',
|
|
tls_server_name=None,
|
|
phase='connect',
|
|
error_type='TimeoutError',
|
|
)
|
|
controls = tuple(response(f'unknown-{index}.example.com') for index in range(3))
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
context,
|
|
response('admin.example.com', status=401),
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'indeterminate'
|
|
assert observation.distinct_signals == ()
|
|
|
|
|
|
def test_classifier_marks_a_stable_status_difference_as_distinct() -> None:
|
|
controls = tuple(response(f'unknown-{index}.example.com') for index in range(3))
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
response('admin.example.com', status=401),
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'distinct'
|
|
assert observation.distinct_signals == ('status',)
|
|
assert observation.control_phase == 'body'
|
|
assert observation.control_status == 200
|
|
assert observation.control_location is None
|
|
assert observation.control_body_sha256 == 'de9adea2908417ad2b86d8812b598c24c80c3faf4b0bfa304c5530f391805894'
|
|
assert observation.control_body_size == 12
|
|
assert observation.control_body_truncated is False
|
|
|
|
|
|
def test_classifier_ignores_exact_candidate_reflection() -> None:
|
|
controls = tuple(
|
|
response(
|
|
hostname,
|
|
body=f'unknown host: {hostname}'.encode(),
|
|
location=f'https://errors.example.test/?host={hostname}',
|
|
)
|
|
for hostname in (f'unknown-{index}.example.com' for index in range(3))
|
|
)
|
|
candidate = response(
|
|
'admin.example.com',
|
|
body=b'unknown host: admin.example.com',
|
|
location='https://errors.example.test/?host=admin.example.com',
|
|
)
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
candidate,
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'default'
|
|
assert observation.reflection_normalized is True
|
|
|
|
|
|
def test_classifier_does_not_normalize_an_authority_inside_a_larger_hostname() -> None:
|
|
controls = tuple(
|
|
response(hostname, body=f'unknown host: not{hostname}'.encode())
|
|
for hostname in (f'unknown-{index}.example.com' for index in range(3))
|
|
)
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
response('admin.example.com', body=b'unknown host: notadmin.example.com'),
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'indeterminate'
|
|
assert observation.reflection_normalized is False
|
|
|
|
|
|
def test_classifier_keeps_inconsistent_controls_indeterminate() -> None:
|
|
controls = (
|
|
response('unknown-0.example.com', body=b'first default'),
|
|
response('unknown-1.example.com', body=b'second default'),
|
|
response('unknown-2.example.com', body=b'third default'),
|
|
)
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
response('admin.example.com', status=401, body=b'candidate'),
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'indeterminate'
|
|
|
|
|
|
def test_classifier_keeps_a_candidate_indeterminate_when_all_controls_fail() -> None:
|
|
controls = tuple(
|
|
ProbeObservation(
|
|
hostname=f'unknown-{index}.example.com',
|
|
http_host=f'unknown-{index}.example.com',
|
|
tls_server_name=f'unknown-{index}.example.com',
|
|
phase='connect',
|
|
error_type='TimeoutError',
|
|
)
|
|
for index in range(3)
|
|
)
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
response('admin.example.com', status=401),
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'indeterminate'
|
|
assert observation.distinct_signals == ()
|
|
|
|
|
|
def test_classifier_keeps_an_ambiguous_candidate_failure_indeterminate() -> None:
|
|
controls = tuple(response(f'unknown-{index}.example.com') for index in range(3))
|
|
candidate = ProbeObservation(
|
|
hostname='admin.example.com',
|
|
http_host='admin.example.com',
|
|
tls_server_name='admin.example.com',
|
|
phase='connect',
|
|
error_type='TimeoutError',
|
|
)
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
candidate,
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'indeterminate'
|
|
|
|
|
|
def test_classifier_requires_confirmation_for_a_body_only_difference() -> None:
|
|
controls = tuple(response(f'unknown-{index}.example.com') for index in range(3))
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
response('admin.example.com', body=b'private page'),
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'indeterminate'
|
|
assert observation.distinct_signals == ('body_sha256',)
|
|
assert observation.needs_confirmation is True
|
|
|
|
|
|
def test_classifier_accepts_a_repeatable_body_only_difference() -> None:
|
|
controls = tuple(response(f'unknown-{index}.example.com') for index in range(3))
|
|
candidate = response('admin.example.com', body=b'private page')
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
candidate,
|
|
controls,
|
|
confirmation=candidate,
|
|
)
|
|
|
|
assert observation.classification == 'distinct'
|
|
assert observation.distinct_signals == ('body_sha256',)
|
|
assert observation.needs_confirmation is False
|
|
|
|
|
|
def test_classifier_keeps_a_truncated_body_comparison_indeterminate() -> None:
|
|
controls = tuple(response(f'unknown-{index}.example.com') for index in range(3))
|
|
candidate = ProbeObservation(
|
|
hostname='admin.example.com',
|
|
http_host='admin.example.com',
|
|
tls_server_name='admin.example.com',
|
|
phase='body',
|
|
status=200,
|
|
body=b'default page',
|
|
body_truncated=True,
|
|
)
|
|
|
|
observation = classify_virtual_host(
|
|
'https://192.0.2.10:443/',
|
|
response('192.0.2.10'),
|
|
candidate,
|
|
controls,
|
|
)
|
|
|
|
assert observation.classification == 'indeterminate'
|
|
assert observation.body_truncated is True
|
|
|
|
|
|
def test_direct_request_construction_enforces_scope_and_literal_ip() -> None:
|
|
with pytest.raises(ValueError, match='literal IP'):
|
|
VirtualHostRequest(
|
|
endpoint='https://edge.example.com/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
)
|
|
with pytest.raises(ValueError, match='outside authorized scope'):
|
|
VirtualHostRequest(
|
|
endpoint='https://192.0.2.10/',
|
|
scope='example.com',
|
|
candidates=('admin.attacker.test',),
|
|
)
|
|
|
|
|
|
def test_request_rejects_explicit_port_zero() -> None:
|
|
with pytest.raises(ValueError, match='port must be between 1 and 65535'):
|
|
VirtualHostRequest(
|
|
endpoint='http://192.0.2.10:0/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
)
|
|
|
|
|
|
def test_request_accepts_the_authorized_scope_apex_for_conservative_classification() -> None:
|
|
request = VirtualHostRequest(
|
|
endpoint='http://192.0.2.10/',
|
|
scope='example.com',
|
|
candidates=('example.com',),
|
|
)
|
|
|
|
assert request.candidates == ('example.com',)
|
|
|
|
|
|
def test_request_rejects_a_shape_without_three_available_unknown_controls() -> None:
|
|
alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
|
|
with pytest.raises(ValueError, match='fewer than three available unknown controls'):
|
|
VirtualHostRequest(
|
|
endpoint='http://192.0.2.10/',
|
|
scope='example.com',
|
|
candidates=tuple(f'{character}.example.com' for character in alphabet[:-2]),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize('invalid_value', [math.inf, math.nan])
|
|
def test_limits_reject_non_finite_time_bounds(invalid_value: float) -> None:
|
|
with pytest.raises(ValueError, match='runtime seconds must be positive and finite'):
|
|
VirtualHostLimits(runtime_seconds=invalid_value)
|
|
with pytest.raises(ValueError, match='timeout seconds must be positive and finite'):
|
|
VirtualHostLimits(timeout_seconds=invalid_value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('field', 'invalid_value'),
|
|
[('request_limit', math.inf), ('request_limit', True), ('concurrency', math.inf), ('concurrency', True)],
|
|
)
|
|
def test_limits_require_finite_integer_counts(field: str, invalid_value: float) -> None:
|
|
with pytest.raises(ValueError, match='must be an integer'):
|
|
VirtualHostLimits(**{field: invalid_value}) # type: ignore[arg-type]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_connects_to_the_literal_ip_and_sends_the_candidate_host() -> None:
|
|
seen_hosts: list[str] = []
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = await read_host(reader)
|
|
seen_hosts.append(host)
|
|
status = b'401 Unauthorized' if host == f'admin.example.com:{port}' else b'200 OK'
|
|
await write_response(writer, status=status)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert result.request_count == 5
|
|
assert seen_hosts[0] == f'127.0.0.1:{port}'
|
|
assert f'admin.example.com:{port}' in seen_hosts
|
|
assert result.context.tls_verified is None
|
|
assert result.observations[0].tls_verified is None
|
|
assert result.observations[0].classification == 'distinct'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_keeps_the_authorized_scope_apex_indeterminate() -> None:
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = await read_host(reader)
|
|
status = b'401 Unauthorized' if host.startswith('example.com:') else b'200 OK'
|
|
await write_response(writer, status=status)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('example.com',),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert result.observations[0].status == 401
|
|
assert result.observations[0].classification == 'indeterminate'
|
|
assert result.observations[0].distinct_signals == ()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_records_the_ipv6_context_authority_exactly() -> None:
|
|
seen_hosts: list[str] = []
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = await read_host(reader)
|
|
seen_hosts.append(host)
|
|
status = b'401 Unauthorized' if host.startswith('admin.') else b'200 OK'
|
|
await write_response(writer, status=status)
|
|
|
|
try:
|
|
async with local_server(handle, host='::1') as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://[::1]:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
except OSError:
|
|
pytest.skip('IPv6 loopback is unavailable')
|
|
|
|
assert seen_hosts[0] == f'[::1]:{port}'
|
|
assert result.context.http_host == seen_hosts[0]
|
|
assert result.observations[0].classification == 'distinct'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_charges_body_confirmation_to_the_hard_request_budget() -> None:
|
|
request_count = 0
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
nonlocal request_count
|
|
host = await read_host(reader)
|
|
request_count += 1
|
|
body = b'private page' if host.startswith('one.') or host.startswith('two.') else b'default page'
|
|
await write_response(writer, body=body)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('one.example.com', 'two.example.com'),
|
|
limits=VirtualHostLimits(request_limit=6),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert request_count == result.request_count == 6
|
|
assert len(result.observations) == 1
|
|
assert result.observations[0].classification == 'distinct'
|
|
assert result.stop_reason == 'request-limit'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_reports_the_budget_stop_when_confirmation_cannot_run() -> None:
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = await read_host(reader)
|
|
body = b'private page' if host.startswith('admin.') else b'default page'
|
|
await write_response(writer, body=body)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
limits=VirtualHostLimits(request_limit=5),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert result.request_count == 5
|
|
assert result.observations[0].needs_confirmation is True
|
|
assert result.stop_reason == 'request-limit'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_respects_the_candidate_concurrency_limit() -> None:
|
|
active_candidates = 0
|
|
maximum_active_candidates = 0
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
nonlocal active_candidates, maximum_active_candidates
|
|
host = await read_host(reader)
|
|
is_candidate = host.startswith('candidate-')
|
|
if is_candidate:
|
|
active_candidates += 1
|
|
maximum_active_candidates = max(maximum_active_candidates, active_candidates)
|
|
await asyncio.sleep(0.02)
|
|
active_candidates -= 1
|
|
status = b'401 Unauthorized' if is_candidate else b'200 OK'
|
|
await write_response(writer, status=status)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=tuple(f'candidate-{index}.example.com' for index in range(4)),
|
|
limits=VirtualHostLimits(concurrency=2),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert maximum_active_candidates == 2
|
|
assert len(result.observations) == 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_stops_at_the_shared_runtime_limit() -> None:
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = await read_host(reader)
|
|
if host.startswith('slow.'):
|
|
await asyncio.sleep(0.2)
|
|
await write_response(writer)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('slow.example.com',),
|
|
limits=VirtualHostLimits(runtime_seconds=0.05, timeout_seconds=1),
|
|
)
|
|
started = time.monotonic()
|
|
result = await discover_virtual_hosts(request)
|
|
elapsed = time.monotonic() - started
|
|
|
|
assert elapsed < 0.15
|
|
assert result.request_count == 5
|
|
assert result.observations == ()
|
|
assert result.stop_reason == 'runtime-limit'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_retains_controls_completed_before_the_runtime_limit() -> None:
|
|
request_count = 0
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
nonlocal request_count
|
|
await read_host(reader)
|
|
request_count += 1
|
|
if request_count == 3:
|
|
await asyncio.sleep(0.2)
|
|
await write_response(writer)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
limits=VirtualHostLimits(runtime_seconds=0.05, timeout_seconds=1),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert result.request_count == 3
|
|
assert len(result.controls) == 1
|
|
assert result.stop_reason == 'runtime-limit'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_retains_completed_evidence_at_the_runtime_limit() -> None:
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = await read_host(reader)
|
|
if host.startswith('slow.'):
|
|
await asyncio.sleep(1)
|
|
status = b'401 Unauthorized' if host.startswith('fast.') else b'200 OK'
|
|
await write_response(writer, status=status)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('fast.example.com', 'slow.example.com'),
|
|
limits=VirtualHostLimits(runtime_seconds=0.5, timeout_seconds=2, concurrency=1),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert tuple(observation.hostname for observation in result.observations) == ('fast.example.com',)
|
|
assert result.attempted_candidate_count == 1
|
|
assert result.observations[0].classification == 'distinct'
|
|
assert result.stop_reason == 'runtime-limit'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_retains_a_completed_probe_from_a_timed_out_concurrent_batch() -> None:
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = await read_host(reader)
|
|
if host.startswith('slow.'):
|
|
await asyncio.sleep(1)
|
|
status = b'401 Unauthorized' if host.startswith('fast.') else b'200 OK'
|
|
await write_response(writer, status=status)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('fast.example.com', 'slow.example.com'),
|
|
limits=VirtualHostLimits(runtime_seconds=0.5, timeout_seconds=2, concurrency=2),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert tuple(observation.hostname for observation in result.observations) == ('fast.example.com',)
|
|
assert result.attempted_candidate_count == 1
|
|
assert result.observations[0].classification == 'distinct'
|
|
assert result.stop_reason == 'runtime-limit'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_uses_controls_matching_each_candidate_name_shape() -> None:
|
|
seen_hosts: list[str] = []
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = (await read_host(reader)).split(':', 1)[0]
|
|
seen_hosts.append(host)
|
|
relative_depth = len(host.removesuffix('.example.com').rstrip('.').split('.'))
|
|
status = b'404 Not Found' if relative_depth == 2 else b'200 OK'
|
|
await write_response(writer, status=status)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com', 'admin.dev.example.com'),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
control_depths = {len(control.hostname.removesuffix('.example.com').rstrip('.').split('.')) for control in result.controls}
|
|
assert control_depths == {1, 2}
|
|
assert len(result.controls) == 6
|
|
assert tuple(observation.classification for observation in result.observations) == ('default', 'default')
|
|
assert set(request.candidates) <= set(seen_hosts)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_records_a_per_request_timeout_as_bounded_evidence() -> None:
|
|
request_count = 0
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
nonlocal request_count
|
|
await read_host(reader)
|
|
request_count += 1
|
|
if request_count == 2:
|
|
await asyncio.sleep(0.1)
|
|
await write_response(writer)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
limits=VirtualHostLimits(runtime_seconds=1, timeout_seconds=0.02),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert request_count == result.request_count == 5
|
|
assert result.controls[0].error_type == 'TimeoutError'
|
|
assert result.observations[0].classification == 'indeterminate'
|
|
assert result.request_error_count == 1
|
|
assert result.request_error_types == ('TimeoutError',)
|
|
assert result.stop_reason == 'request-errors'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_preserves_headers_when_the_body_read_fails() -> None:
|
|
request_count = 0
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
nonlocal request_count
|
|
await read_host(reader)
|
|
request_count += 1
|
|
if request_count == 5:
|
|
await write_response(writer, body=b'short', content_length=20)
|
|
else:
|
|
await write_response(writer)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
observation = result.observations[0]
|
|
assert observation.phase == 'headers'
|
|
assert observation.status == 200
|
|
assert observation.error_type == 'ClientPayloadError'
|
|
assert observation.classification == 'indeterminate'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_caps_large_bodies_and_keeps_them_indeterminate() -> None:
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = await read_host(reader)
|
|
body = b'x' * (VHOST_BODY_LIMIT + 100) if host.startswith('admin.') else b'default page'
|
|
await write_response(writer, body=body)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
observation = result.observations[0]
|
|
assert observation.body_size == VHOST_BODY_LIMIT
|
|
assert observation.body_truncated is True
|
|
assert observation.classification == 'indeterminate'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_preserves_cancellation_and_closes_the_connection() -> None:
|
|
accepted = asyncio.Event()
|
|
connection_closed = asyncio.Event()
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
await reader.readuntil(b'\r\n\r\n')
|
|
accepted.set()
|
|
await reader.read()
|
|
connection_closed.set()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
)
|
|
task = asyncio.create_task(discover_virtual_hosts(request))
|
|
await asyncio.wait_for(accepted.wait(), timeout=1)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
await asyncio.wait_for(connection_closed.wait(), timeout=1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_records_redirects_without_following_them() -> None:
|
|
paths: list[str] = []
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
request = await reader.readuntil(b'\r\n\r\n')
|
|
request_line = request.split(b'\r\n', 1)[0].decode()
|
|
paths.append(request_line.split()[1])
|
|
host_line = next(line for line in request.split(b'\r\n') if line.lower().startswith(b'host:'))
|
|
host = host_line.split(b':', 1)[1].strip().decode()
|
|
if host.startswith('admin.'):
|
|
await write_response(writer, status=b'302 Found', body=b'', headers=(b'Location: /followed',))
|
|
else:
|
|
await write_response(writer)
|
|
|
|
async with local_server(handle) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'http://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert paths == ['/'] * 5
|
|
assert result.observations[0].status == 302
|
|
assert result.observations[0].location == '/followed'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_https_discovery_does_not_retry_with_verification_disabled(
|
|
tls_cert_chain: tuple[Path, Path],
|
|
) -> None:
|
|
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
server_context.load_cert_chain(*tls_cert_chain)
|
|
received_requests = 0
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
nonlocal received_requests
|
|
await read_host(reader)
|
|
received_requests += 1
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
async with local_server(handle, ssl_context=server_context) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'https://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com',),
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
assert received_requests == 0
|
|
assert result.request_count == 5
|
|
assert result.context.phase == 'tls'
|
|
assert all(control.phase == 'tls' for control in result.controls)
|
|
assert result.observations[0].phase == 'tls'
|
|
assert result.observations[0].tls_verified is True
|
|
assert result.observations[0].classification == 'indeterminate'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_https_discovery_aligns_each_candidate_sni_and_host(
|
|
tls_cert_chain: tuple[Path, Path],
|
|
) -> None:
|
|
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
server_context.load_cert_chain(*tls_cert_chain)
|
|
server_names: dict[int, str | None] = {}
|
|
received: list[tuple[str, str | None]] = []
|
|
|
|
def record_server_name(ssl_object: ssl.SSLObject, server_name: str | None, _context: ssl.SSLContext) -> None:
|
|
server_names[id(ssl_object)] = server_name
|
|
|
|
server_context.sni_callback = record_server_name
|
|
|
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host = await read_host(reader)
|
|
ssl_object = writer.get_extra_info('ssl_object')
|
|
received.append((host, server_names[id(ssl_object)]))
|
|
status = b'401 Unauthorized' if host.startswith('admin.') or host.startswith('portal.') else b'200 OK'
|
|
await write_response(writer, status=status)
|
|
|
|
async with local_server(handle, ssl_context=server_context) as port:
|
|
request = VirtualHostRequest(
|
|
endpoint=f'https://127.0.0.1:{port}/',
|
|
scope='example.com',
|
|
candidates=('admin.example.com', 'portal.example.com'),
|
|
insecure=True,
|
|
)
|
|
result = await discover_virtual_hosts(request)
|
|
|
|
candidate_pairs = [(host, sni) for host, sni in received if host.startswith(('admin.', 'portal.'))]
|
|
assert candidate_pairs == [
|
|
(f'admin.example.com:{port}', 'admin.example.com'),
|
|
(f'portal.example.com:{port}', 'portal.example.com'),
|
|
]
|
|
assert all(observation.tls_verified is False for observation in result.observations)
|