Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1a214f9
Fix: create httpx.AsyncClient off event loop (Python 3.13 / HA blocki…
danielbrunt57 Feb 19, 2026
97c33ae
Avoid creating httpx.AsyncClient in running event loop (Python 3.13 c…
danielbrunt57 Feb 19, 2026
0c80b78
Update lockfile: pin virtualenv to 20.38.0
danielbrunt57 Feb 19, 2026
83b9425
Format imports + black compliance
danielbrunt57 Feb 19, 2026
fe42437
Remove patchfile.patch
danielbrunt57 Feb 19, 2026
926e2c1
Avoid blocking httpx client creation in event loop; tighten typing an…
danielbrunt57 Feb 20, 2026
261d8e1
Fix CI: bandit + mypy issues in tests; align isort with black
danielbrunt57 Feb 20, 2026
8adf96a
Add session lock for safe AsyncClient creation; clean up event loop d…
danielbrunt57 Feb 20, 2026
2891d7d
Remove venv from repository and ignore virtual environments
danielbrunt57 Feb 20, 2026
6ea6098
Clenest version fix: Redundant in_event_loop = False
danielbrunt57 Feb 20, 2026
e986902
Return 500 on missing session; fix spacing
danielbrunt57 Feb 20, 2026
884b769
Remove Azure Oryx build artifact from repository
danielbrunt57 Feb 20, 2026
31c72a2
Removed Home Assistant–specific references
danielbrunt57 Feb 20, 2026
07e3e2c
fix: D202 No blank lines allowed after function docstring
danielbrunt57 Feb 20, 2026
a493a1c
Apply suggestion from @coderabbitai[bot]
danielbrunt57 Feb 28, 2026
f26080e
Specify exception types in URL parsing
danielbrunt57 Feb 28, 2026
d23b826
Fix syntax error in exception handling
danielbrunt57 Feb 28, 2026
86d3bc1
Refine regex for background-image URL matching
danielbrunt57 Mar 18, 2026
bdec6a8
Update modifiers.py
danielbrunt57 Mar 18, 2026
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ tmp/
*.so

# Python temp and build files
venv/
.venv/
env/

docs/html/
__pycache__/
__pypackages__/
Expand All @@ -44,6 +48,7 @@ cython_debug/
docs/autoapi
cov.xml
.coverage.*
oryx-build-commands.txt


# Hidden files that are definitely unwanted (redundant)
Expand Down
62 changes: 54 additions & 8 deletions authcaptureproxy/auth_capture_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,25 @@ def __init__(
self.session_factory: Callable[[], httpx.AsyncClient] = session_factory or (
lambda: httpx.AsyncClient(verify=ssl_context)
)
self.session: httpx.AsyncClient = session if session else self.session_factory()
# NOTE: Do not instantiate httpx.AsyncClient inside the event loop.
# Some SSL initialization (e.g., load_verify_locations) is blocking and will be flagged.
#
# Keep historical behavior when NOT running inside an event loop: create a session immediately.
# When running inside an event loop, defer and create lazily
# via _ensure_session() using asyncio.to_thread().
try:
asyncio.get_running_loop()
in_event_loop = True
except RuntimeError:
in_event_loop = False

if session is not None:
self.session: Optional[httpx.AsyncClient] = session
elif in_event_loop:
self.session = None
else:
self.session = self.session_factory()
self._session_lock = asyncio.Lock()
self._proxy_url: URL = proxy_url
self._host_url: URL = host_url
self._port: int = proxy_url.explicit_port if proxy_url.explicit_port else 0 # type: ignore
Expand Down Expand Up @@ -193,15 +211,34 @@ async def reset_data(self) -> None:
"""
if self.session:
await self.session.aclose()
self.session = self.session_factory()
self.session = None
# Reset data fields unconditionally so state is clean regardless of session outcome.
self.last_resp = None
self.init_query = {}
self.query = {}
self.data = {}
self._active = False
self._all_handler_active = True
await self._ensure_session()
if self.session is None: # pragma: no cover
_LOGGER.error("Internal error: HTTP session not initialized")
return

_LOGGER.debug("Proxy data reset.")

async def _ensure_session(self) -> None:
"""Ensure an httpx session exists.

httpx.AsyncClient() initialization may perform blocking SSL work
(e.g. SSLContext.load_verify_locations), so the client is created
in a background thread.
"""
if self.session is not None:
return
async with self._session_lock:
if self.session is None:
self.session = await asyncio.to_thread(self.session_factory)

def refresh_tests(self) -> None:
"""Refresh tests.

Expand Down Expand Up @@ -341,6 +378,14 @@ async def all_handler(self, request: web.Request, **kwargs) -> web.Response:
else:
host_url = self._host_url

# Ensure the HTTP session is created off the event loop thread.
await self._ensure_session()
session = self.session
if session is None: # pragma: no cover
return await self._build_response(
text="Internal error: HTTP session not initialized", status=500
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
async def _process_multipart(reader: MultipartReader, writer: MultipartWriter) -> None:
"""Process multipart.

Expand Down Expand Up @@ -530,15 +575,15 @@ async def _process_multipart(reader: MultipartReader, writer: MultipartWriter) -
method,
site,
req_headers,
self.session.cookies.jar,
session.cookies.jar,
)
try:
if mpwriter:
resp = await getattr(self.session, method)(
resp = await getattr(session, method)(
site, data=mpwriter, headers=req_headers, follow_redirects=True
)
elif data:
resp = await getattr(self.session, method)(
resp = await getattr(session, method)(
site, data=data, headers=req_headers, follow_redirects=True
)
elif raw_body is not None:
Expand All @@ -551,19 +596,19 @@ async def _process_multipart(reader: MultipartReader, writer: MultipartWriter) -
# Preserve the original Content-Type for raw body requests
if request.content_type and "Content-Type" not in req_headers:
req_headers["Content-Type"] = request.content_type
resp = await getattr(self.session, method)(
resp = await getattr(session, method)(
site, content=raw_body, headers=req_headers, follow_redirects=True
)
elif json_data:
for item in ["Host", "Origin", "User-Agent", "dnt", "Accept-Encoding"]:
# remove proxy headers
if req_headers.get(item):
req_headers.pop(item)
resp = await getattr(self.session, method)(
resp = await getattr(session, method)(
site, json=json_data, headers=req_headers, follow_redirects=True
)
else:
resp = await getattr(self.session, method)(
resp = await getattr(session, method)(
site, headers=req_headers, follow_redirects=True
)
except httpx.ConnectError as ex:
Expand Down Expand Up @@ -811,6 +856,7 @@ async def stop_proxy(self, delay: int = 0) -> None:
if self.session:
_LOGGER.debug("Closing session")
await self.session.aclose()
self.session = None
_LOGGER.debug("Session closed")
self._active = False
_LOGGER.debug("Proxy stopped")
Expand Down
4 changes: 2 additions & 2 deletions authcaptureproxy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def test_url(resp: httpx.Response, data: Dict[Text, Any], query: Dict[Text, Any]
asyncio.create_task(proxy_obj.stop_proxy(3)) # stop proxy in 3 seconds
if callback_url:
return URL(callback_url) # 302 redirect
return f"Successfully logged in {data.get('email')} and {data.get('password')}. Please close the window.<br /><b>Post data</b><br />{json.dumps(data)}<br /><b>Query Data:</b><br />{json.dumps(query)}<br /><b>Cookies:</b></br>{json.dumps(list(proxy_obj.session.cookies.items()))}"
return f"Successfully logged in {data.get('email')} and {data.get('password')}. Please close the window.<br /><b>Post data</b><br />{json.dumps(data)}<br /><b>Query Data:</b><br />{json.dumps(query)}<br /><b>Cookies:</b></br>{json.dumps(list(proxy_obj.session.cookies.items()) if proxy_obj.session else [])}"

await proxy_obj.start_proxy()
# add tests and modifiers after the proxy has started so that port data is available for self.access_url()
Expand All @@ -119,7 +119,7 @@ def test_url(resp: httpx.Response, data: Dict[Text, Any], query: Dict[Text, Any]
"autofill": partial(
autofill,
{
"password": "CHANGEME",
"password": "CHANGEME", # nosec
},
)
}
Expand Down
42 changes: 27 additions & 15 deletions authcaptureproxy/examples/modifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,38 +207,50 @@ async def find_urls_bs4(
# https://developer.mozilla.org/en-US/docs/Web/CSS/background-image
# this currently only handles background-image as the first attribute
# TODO: Rewrite regex to handle general case
pattern = r"(?<=style=[\"']background-image:url\([\"']).*(?=[\"']\))"
pattern = r"(?<=background-image:url\([\"']).*?(?=[\"']\))"
attribute_value = html_tag.get(attribute)
url: Optional[URL] = URL(str(re.search(pattern, attribute_value)))
if url is not None and url not in exceptions.get(tag, []):
new_value = re.sub(
pattern,
await run_func(modifier, name="", url=url),
attribute_value,
)
old_value = html_tag[attribute]
if not isinstance(attribute_value, str):
continue
match = re.search(pattern, attribute_value)
if not match:
continue
try:
url: Optional[URL] = URL(match.group(0))
except (TypeError, ValueError):
url = None
if url is not None and str(url) not in exceptions.get(tag, []):
replacement = await run_func(modifier, name="", url=url)
new_value = re.sub(pattern, str(replacement), attribute_value)
old_value = html_tag.get(attribute)
html_tag[attribute] = new_value
if str(old_value) != str(html_tag[attribute]):
if str(old_value) != str(html_tag.get(attribute)):
_LOGGER.debug(
"Modified url for style:background-image %s -> %s",
url,
html_tag[attribute],
html_tag.get(attribute),
)
else:
url = URL(html_tag.get(attribute)) if html_tag.get(attribute) is not None else None
raw_value = html_tag.get(attribute)
if not isinstance(raw_value, str):
url = None
else:
try:
url = URL(raw_value) # allow "" (URL(""))
except (TypeError, ValueError):
url = None
if (
url is not None
and not str(url).startswith("data:")
and str(url) not in exceptions.get(tag, [])
):
old_value = html_tag[attribute]
old_value = html_tag.get(attribute)
html_tag[attribute] = await run_func(modifier, name="", url=url)
if str(old_value) != str(html_tag[attribute]):
if str(old_value) != str(html_tag.get(attribute)):
_LOGGER.debug(
"Modified url for %s:%s %s -> %s",
tag,
attribute,
url,
html_tag[attribute],
html_tag.get(attribute),
)
return str(soup)
6 changes: 3 additions & 3 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ line-length = 100
target-version = ["py38"]

[tool.isort]
profile = "black"
line_length = 100

[tool.bandit]
Expand Down
7 changes: 4 additions & 3 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
Original source: https://github.com/dmyersturnbull/tyrannosaurus
Copyright 2020–2021 Douglas Myers-Turnbull
"""

# NOTE: If you modify this file, you should indicate your license and copyright as well.
from __future__ import annotations

import logging
import os
import random
import secrets
import shutil
import stat
import tempfile
Expand All @@ -20,7 +22,6 @@
from typing import Generator, Union
from warnings import warn


# Keeps created temp files; turn on for debugging
KEEP = False

Expand Down Expand Up @@ -84,7 +85,7 @@ def temp_dir(
Yields:
The created directory as a ``pathlib.Path``
"""
path = TestResources._temp_dir / ("%0x" % random.getrandbits(64))
path = TestResources._temp_dir / secrets.token_hex(8)
if path.exists():
cls._delete_tree(path, surefire=force_delete)
if copy_resource is None:
Expand Down
28 changes: 10 additions & 18 deletions tests/examples/test_amazon_waf.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ def _make_ctx(**kwargs):
"""Create an InterceptContext with Amazon-like defaults."""
proxy = MagicMock()
proxy._host_url = HOST_URL
proxy._build_response = MagicMock(
side_effect=lambda **kw: _async_response(kw.get("text", ""))
)
proxy._build_response = MagicMock(side_effect=lambda **kw: _async_response(kw.get("text", "")))
defaults = dict(
request=MagicMock(),
proxy=proxy,
Expand All @@ -47,9 +45,7 @@ async def test_on_request_amzn_host_routing():
"""__amzn_host__ marker routes to correct host."""
interceptor = AmazonWAFInterceptor()
req = MagicMock()
req.url = URL(
"https://192.168.1.100:8123/auth/proxy/__amzn_host__fls-eu.amazon.it/1/batch/1"
)
req.url = URL("https://192.168.1.100:8123/auth/proxy/__amzn_host__fls-eu.amazon.it/1/batch/1")
req.query_string = ""
ctx = _make_ctx(request=req)

Expand All @@ -63,9 +59,7 @@ async def test_on_request_amzn_host_with_query():
"""__amzn_host__ routing preserves query string."""
interceptor = AmazonWAFInterceptor()
req = MagicMock()
req.url = URL(
"https://192.168.1.100:8123/auth/proxy/__amzn_host__fls-eu.amazon.it/path"
)
req.url = URL("https://192.168.1.100:8123/auth/proxy/__amzn_host__fls-eu.amazon.it/path")
req.query_string = "key=value&other=1"
ctx = _make_ctx(request=req)

Expand All @@ -78,9 +72,7 @@ async def test_on_request_blocked_host():
"""Non-Amazon host returns short_circuit error."""
interceptor = AmazonWAFInterceptor()
req = MagicMock()
req.url = URL(
"https://192.168.1.100:8123/auth/proxy/__amzn_host__evil.example.com/steal"
)
req.url = URL("https://192.168.1.100:8123/auth/proxy/__amzn_host__evil.example.com/steal")
req.query_string = ""
ctx = _make_ctx(request=req)

Expand Down Expand Up @@ -157,7 +149,7 @@ async def test_on_request_data_invalid_aamation_with_totp():
proxy=proxy,
site="/ap/cvf/verify",
data={
"cvf_aamation_response_token": "invalid_not_base64",
"cvf_aamation_response_token": "invalid_not_base64", # nosec B105
"cvf_aamation_error_code": "NetworkError",
"cvf_captcha_captcha_action": "",
},
Expand All @@ -180,7 +172,7 @@ async def test_on_request_data_invalid_aamation_no_totp():
proxy=proxy,
site="/ap/cvf/verify",
data={
"cvf_aamation_response_token": "bad",
"cvf_aamation_response_token": "bad", # nosec B105
"cvf_aamation_error_code": "err",
"cvf_captcha_captcha_action": "x",
},
Expand Down Expand Up @@ -258,9 +250,9 @@ async def test_on_ajax_html_aaut_injection():
"""P shim injected into /aaut/verify/cvf response."""
interceptor = AmazonWAFInterceptor()
html = (
'<html><head></head><body>'
"<html><head></head><body>"
'<script src="https://abc.token.awswaf.com/captcha.js"></script>'
'</body></html>'
"</body></html>"
)
req = MagicMock()
req.url = URL("https://192.168.1.100:8123/auth/proxy/aaut/verify/cvf")
Expand Down Expand Up @@ -310,7 +302,7 @@ async def test_on_ajax_html_no_body():
async def test_on_page_html_cvf_injection():
"""Submit blocker + AJAX proxy injected into CVF page."""
interceptor = AmazonWAFInterceptor()
html = '<html><head><script>var x=1;</script></head><body>CVF</body></html>'
html = "<html><head><script>var x=1;</script></head><body>CVF</body></html>"
resp = MagicMock(spec=httpx.Response)
resp.url = httpx.URL("https://www.amazon.it/ap/cvf/request")
ctx = _make_ctx(response=resp, text=html, content_type="text/html")
Expand All @@ -329,7 +321,7 @@ async def test_on_page_html_cvf_injection():
async def test_on_page_html_non_cvf():
"""Non-CVF page: text unchanged."""
interceptor = AmazonWAFInterceptor()
html = '<html><head><script>var x=1;</script></head><body>Signin</body></html>'
html = "<html><head><script>var x=1;</script></head><body>Signin</body></html>"
resp = MagicMock(spec=httpx.Response)
resp.url = httpx.URL("https://www.amazon.it/ap/signin")
ctx = _make_ctx(response=resp, text=html, content_type="text/html")
Expand Down
Loading
Loading