-
Notifications
You must be signed in to change notification settings - Fork 0
fix(poller): close DNS rebinding gap #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # OSApplyTrack Backlog | ||
|
|
||
| This file mirrors the open | ||
| [GitHub Issues](https://github.com/CryptoJones/OSApplyTrack/issues) for the project. | ||
| Check an item only when its matching issue is closed. Deferred ideas in `README.md` | ||
| or `SPRINTS.md` are not committed backlog until they have a corresponding issue. | ||
|
|
||
| ## Security and stability | ||
|
|
||
| - [x] [#51 — Close the Python poller link-check DNS rebinding gap](https://github.com/CryptoJones/OSApplyTrack/issues/51) | ||
| - [ ] [#49 — Restrict forwarded-header trust to configured proxies](https://github.com/CryptoJones/OSApplyTrack/issues/49) | ||
| - [ ] [#50 — Add global JSON body caps and per-field/cardinality limits](https://github.com/CryptoJones/OSApplyTrack/issues/50) | ||
| - [ ] [#52 — Serialize overlapping tenant poll runs](https://github.com/CryptoJones/OSApplyTrack/issues/52) | ||
|
|
||
| ## Operations and scalability | ||
|
|
||
| - [ ] [#53 — Add hardened production container defaults](https://github.com/CryptoJones/OSApplyTrack/issues/53) | ||
| - [ ] [#54 — Paginate or delta-refresh the applications list](https://github.com/CryptoJones/OSApplyTrack/issues/54) | ||
|
|
||
| Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,9 +11,11 @@ | |
|
|
||
| import ipaddress | ||
| import socket | ||
| from collections.abc import Iterable | ||
| from dataclasses import dataclass | ||
| from urllib.parse import urljoin, urlsplit | ||
|
|
||
| import httpcore | ||
| import httpx | ||
|
|
||
| # Identify as a current Windows 11 desktop Chrome. Windows 11 still reports | ||
|
|
@@ -91,30 +93,122 @@ def _ip_is_public(ip: str) -> bool: | |
| ) | ||
|
|
||
|
|
||
| def _normalize_host(host: str) -> str: | ||
| """Canonical key shared by URL parsing, DNS pinning, and httpcore.""" | ||
| return (host or "").strip().strip("[]").rstrip(".").casefold() | ||
|
|
||
|
|
||
| class _PinnedResolver: | ||
| """Resolve each hostname once, reject mixed/private answers, and retain one IP.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self._addresses: dict[str, str] = {} | ||
|
|
||
| def pin(self, host: str) -> bool: | ||
| key = _normalize_host(host) | ||
| if not key: | ||
| return False | ||
| if key in self._addresses: | ||
| return True | ||
|
|
||
| try: | ||
| ipaddress.ip_address(key) | ||
| addresses = [key] | ||
| except ValueError: | ||
| try: | ||
| infos = socket.getaddrinfo(key, None, type=socket.SOCK_STREAM) | ||
| except OSError: | ||
| return False | ||
| addresses = list(dict.fromkeys(str(info[4][0]) for info in infos)) | ||
|
|
||
| if not addresses or not all(_ip_is_public(address) for address in addresses): | ||
| return False | ||
| self._addresses[key] = addresses[0] | ||
| return True | ||
|
|
||
| def address_for(self, host: str) -> str | None: | ||
| """Return only an address previously accepted by :meth:`pin`.""" | ||
| return self._addresses.get(_normalize_host(host)) | ||
|
|
||
|
|
||
| class _PinnedNetworkBackend(httpcore.SyncBackend): | ||
| """Connect httpcore's TCP socket to the pinned IP while preserving TLS SNI.""" | ||
|
|
||
| def __init__(self, resolver: _PinnedResolver) -> None: | ||
| super().__init__() | ||
| self._resolver = resolver | ||
|
|
||
| def connect_tcp( | ||
| self, | ||
| host: str, | ||
| port: int, | ||
| timeout: float | None = None, | ||
| local_address: str | None = None, | ||
| socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None, | ||
| ) -> httpcore.NetworkStream: | ||
| address = self._resolver.address_for(host) | ||
| if address is None: | ||
| raise httpcore.ConnectError(f"refused unpinned host: {host}") | ||
| return super().connect_tcp(address, port, timeout, local_address, socket_options) | ||
|
|
||
|
|
||
| class _PinnedTransport(httpx.HTTPTransport): | ||
| """HTTPX transport whose connection pool cannot perform a second DNS lookup.""" | ||
|
|
||
| def __init__(self, resolver: _PinnedResolver) -> None: | ||
| super().__init__(trust_env=False) | ||
| self._pool.close() | ||
| self._pool = httpcore.ConnectionPool( | ||
| ssl_context=httpx.create_ssl_context(trust_env=False), | ||
| network_backend=_PinnedNetworkBackend(resolver), | ||
| ) | ||
|
Comment on lines
+155
to
+164
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: The default limits used by httpx.HTTPTransport are managed by the Limits configuration class, which sets max_connections to 100 and max_keepalive_connections to 20 [1][2][3]. Additionally, the keepalive_expiry default value is 5.0 seconds [1][3]. Yes, httpx.HTTPTransport still stores its connection pool on a _pool attribute in the latest release [4][5][6][7]. This attribute is used internally to hold the underlying httpcore.ConnectionPool (or proxy-specific variants such as httpcore.HTTPProxy or httpcore.SOCKSProxy) [4][6][7]. While this attribute is part of the library's internal implementation and not a public API, it remains present for managing connections within the transport [8]. Citations:
Preserve 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| class _PinnedClient(httpx.Client): | ||
| """HTTP client that connects only to addresses pinned by its resolver.""" | ||
|
|
||
| def __init__(self, *, timeout: float) -> None: | ||
| self._resolver = _PinnedResolver() | ||
| super().__init__( | ||
| timeout=timeout, | ||
| follow_redirects=False, | ||
| headers=BROWSER_HEADERS, | ||
| transport=_PinnedTransport(self._resolver), | ||
| trust_env=False, | ||
| ) | ||
|
|
||
| def pin(self, host: str) -> bool: | ||
| return self._resolver.pin(host) | ||
|
|
||
|
|
||
| def ssrf_safe_client(*, timeout: float = 12.0) -> httpx.Client: | ||
| """Return a reusable client that pins every validated hostname to one public IP.""" | ||
| return _PinnedClient(timeout=timeout) | ||
|
Comment on lines
+167
to
+186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win No test exercises the All new tests ( Consider a lightweight test that at least asserts 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| def _host_is_public(host: str) -> bool: | ||
| """True only when every address ``host`` resolves to is a public IP. | ||
|
|
||
| The SSRF gate: a lead URL (or a redirect it chains to) is attacker-influenced, | ||
| and the poller fetches it server-side, so a link pointing at ``localhost`` or | ||
| an internal/metadata IP must be refused. An IP literal is checked directly; a | ||
| name is resolved and *all* of its A/AAAA records must be public, so a public | ||
| hostname that maps to an internal address is still rejected. (A determined | ||
| attacker could still race DNS between this check and the connect; closing that | ||
| fully needs connect-by-IP, out of scope for v1's self-host threat model.) | ||
| name is resolved and *all* of its A/AAAA records must be public. Production | ||
| requests additionally use :func:`ssrf_safe_client`, whose transport connects to | ||
| the selected validated IP without resolving the hostname again. | ||
| """ | ||
| host = (host or "").strip().strip("[]") # tolerate bracketed IPv6 literals | ||
| if not host: | ||
| return False | ||
| return _PinnedResolver().pin(host) | ||
|
|
||
|
|
||
| def _pin_for_request(client: httpx.Client, host: str) -> bool: | ||
| """Fail closed when a caller supplies an ordinary client for a DNS hostname.""" | ||
| if isinstance(client, _PinnedClient): | ||
| return client.pin(host) | ||
| normalized = _normalize_host(host) | ||
| try: | ||
| ipaddress.ip_address(host) | ||
| return _ip_is_public(host) | ||
| ipaddress.ip_address(normalized) | ||
| except ValueError: | ||
| pass # not a literal — resolve it below | ||
| try: | ||
| infos = socket.getaddrinfo(host, None) | ||
| except socket.gaierror: | ||
| return False | ||
| return bool(infos) and all(_ip_is_public(str(info[4][0])) for info in infos) | ||
| return _ip_is_public(normalized) | ||
|
|
||
|
|
||
| def _is_home(url: str) -> bool: | ||
|
|
@@ -160,14 +254,12 @@ def probe( | |
| parts = urlsplit(url) | ||
| if parts.scheme not in ("http", "https"): | ||
| return LinkStatus(url=url, ok=False, error="not an http(s) URL") | ||
| if not _host_is_public(parts.hostname or ""): | ||
| return LinkStatus(url=url, ok=False, error="refused non-public address") | ||
|
|
||
| owns_client = client is None | ||
| client = client or httpx.Client( | ||
| timeout=timeout, follow_redirects=False, headers=BROWSER_HEADERS | ||
| ) | ||
| client = client or ssrf_safe_client(timeout=timeout) | ||
| try: | ||
| if not _pin_for_request(client, parts.hostname or ""): | ||
| return LinkStatus(url=url, ok=False, error="refused non-public or unpinned address") | ||
|
Comment on lines
+259
to
+262
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: In the httpx library, the URL.host property and the internal host used to build the connection (httpcore origin) behave differently regarding internationalized domain names (IDN) [1][2]. 1. Does URL.host return Punycode or Unicode? The URL.host property is designed to return the Unicode (decoded) form of the hostname [1][2]. When you access this property, httpx attempts to decode any Punycode (IDNA-encoded) sequences (those starting with "xn--") into their human-readable Unicode representation [1]. Note that there have been reports of behavior inconsistencies in some versions where Punycode might be returned if the hostname does not strictly start with "xn--" or in specific edge cases [3], but the intended and documented design is to return the decoded Unicode string [1][2]. 2. Does it match the host used internally to build the connection? No, it does not directly match the internal host used to build the httpcore connection origin. For network requests, httpx uses the.raw_host property (or underlying IDNA-encoded bytes) to build the connection [4][1][2]. The.raw_host property is always IDNA-encoded (Punycode) ASCII, as required by DNS and HTTP protocols [1][2]. Summary: - URL.host: Returns the Unicode form (e.g., "中国.icom.museum") [1]. - Internal connection host: Uses the IDNA-encoded Punycode form (e.g., "xn--fiqs8s.icom.museum"), accessible via URL.raw_host [1][2]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== file outline ==\n'
ast-grep outline src/applytrack/linkcheck.py --view expanded || true
printf '\n== relevant slices ==\n'
sed -n '1,220p' src/applytrack/linkcheck.py | nl -ba | sed -n '1,220p'
printf '\n---\n'
sed -n '220,360p' src/applytrack/linkcheck.py | nl -ba | sed -n '220,360p'
printf '\n== httpx host usages in repo ==\n'
rg -n "raw_host|URL\\(|httpx\\.URL|hostname" src -g '*.py' || trueRepository: CryptoJones/OSApplyTrack Length of output: 1711 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '== lines 90-210 =='
awk 'NR>=90 && NR<=210 {print NR ": " $0}' src/applytrack/linkcheck.py
echo
echo '== lines 242-290 =='
awk 'NR>=242 && NR<=290 {print NR ": " $0}' src/applytrack/linkcheck.py
echo
echo '== lines 290-340 =='
awk 'NR>=290 && NR<=340 {print NR ": " $0}' src/applytrack/linkcheck.pyRepository: CryptoJones/OSApplyTrack Length of output: 8417 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
path = Path('src/applytrack/linkcheck.py')
text = path.read_text()
for start, end in [(90, 210), (242, 290), (290, 340)]:
print(f'== lines {start}-{end} ==')
for i, line in enumerate(text.splitlines(), 1):
if start <= i <= end:
print(f'{i}: {line}')
print()
PYRepository: CryptoJones/OSApplyTrack Length of output: 8418 Normalize the pinning key for IDN hosts 🤖 Prompt for AI Agents |
||
| current = url | ||
| try: | ||
| for _ in range(_MAX_REDIRECTS + 1): | ||
|
|
@@ -181,8 +273,12 @@ def probe( | |
| hop = urlsplit(current) | ||
| if hop.scheme not in ("http", "https"): | ||
| return LinkStatus(url=url, ok=False, error="redirect to non-http(s) URL") | ||
| if not _host_is_public(hop.hostname or ""): | ||
| return LinkStatus(url=url, ok=False, error="redirect to non-public address") | ||
| if not _pin_for_request(client, hop.hostname or ""): | ||
| return LinkStatus( | ||
| url=url, | ||
| ok=False, | ||
| error="redirect to non-public or unpinned address", | ||
| ) | ||
| else: | ||
| return LinkStatus(url=url, ok=False, error="too many redirects") | ||
| except httpx.HTTPError as exc: | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep issue
#51unchecked until it is closed.The file says items may be checked only after their matching issue is closed, but GitHub currently shows issue
#51as Open. Change this to[ ]or close the issue as part of the release workflow. (github.com)🤖 Prompt for AI Agents