id: PYSEC-2026-2611 published: "2026-07-13T15:19:13.275856Z" modified: "2026-07-13T16:04:42.883493Z" aliases: - CVE-2026-46526 - GHSA-g23j-2vwm-5c25 summary: local-deep-research has an SSRF bypass in `safe_get` details: "### Summary\nThe URL checking logic in local-deep-research has a logical flaw that could be bypassed by attackers, leading to SSRF attacks.\n\n### Details\nThe current project uses `validate_url` to validate the input URL. The main logic is to perform security checks on the host portion of the URL extracted by urlparse to prevent SSRF attacks.\n\n\"QQ20260430-212334-30-1\"\n\nHowever, there are indeed differences in parsing between urlparse and the library that actually sends the request. For example, in `safe_get`, `validate_url` is first used to perform an SSRF check, and then `requests.get` is used to send the actual request.\n\n\"QQ20260430-212431-30-2\"\n\nThe core issue: urlparse() and requests disagree on which host a URL like `http://127.0.0.1:6666\\@1.1.1.1` points to:\n\n- urlparse() treats \\ as a regular character and @ as the userinfo-host delimiter, so it extracts hostname as `1.1.1.1` (public)\n- requests treats \\ as a path character, connecting to `127.0.0.1` (internal)\n\nBelow is a test code I wrote following the code.\n```\n#!/usr/bin/env python3\n\"\"\"Standalone demo: import project via absolute path and call safe_get.\"\"\"\n\nfrom __future__ import annotations\n\nimport importlib.util\nimport enum\nimport sys\nimport types\nfrom pathlib import Path\n\n# Hardcoded absolute path to the project's \"src\" directory.\nSRC_ROOT = Path(\n r\"d:\\BaiduNetdiskDownload\\local-deep-research-main\\local-deep-research-main\\src\"\n)\n\n# Python 3.10 compatibility:\n# project constants import StrEnum (available in Python 3.11+).\nif not hasattr(enum, \"StrEnum\"):\n class _CompatStrEnum(str, enum.Enum):\n pass\n\n enum.StrEnum = _CompatStrEnum # type: ignore[attr-defined]\n\n\ndef _load_safe_get():\n \"\"\"Load safe_get directly from file, bypassing package __init__ imports.\"\"\"\n ldr_pkg_name = \"local_deep_research\"\n security_pkg_name = \"local_deep_research.security\"\n\n # Build lightweight package modules so relative imports in safe_requests.py\n # resolve without executing package __init__.py files.\n if ldr_pkg_name not in sys.modules:\n ldr_pkg = types.ModuleType(ldr_pkg_name)\n ldr_pkg.__path__ = [str(SRC_ROOT / \"local_deep_research\")] # type: ignore[attr-defined]\n sys.modules[ldr_pkg_name] = ldr_pkg\n\n if security_pkg_name not in sys.modules:\n security_pkg = types.ModuleType(security_pkg_name)\n security_pkg.__path__ = [str(SRC_ROOT / \"local_deep_research\" / \"security\")] # type: ignore[attr-defined]\n sys.modules[security_pkg_name] = security_pkg\n\n module_name = \"local_deep_research.security.safe_requests\"\n module_path = SRC_ROOT / \"local_deep_research\" / \"security\" / \"safe_requests.py\"\n\n spec = importlib.util.spec_from_file_location(module_name, module_path)\n if spec is None or spec.loader is None:\n raise ImportError(f\"Cannot load module from {module_path}\")\n\n module = importlib.util.module_from_spec(spec)\n sys.modules[module_name] = module\n spec.loader.exec_module(module)\n return module.safe_get\n\n\nsafe_get = _load_safe_get()\n\n\ndef main() -> None:\n # Hardcoded URL for demonstration.\n url = \"http://127.0.0.1:6666\"\n # url = \"http://127.0.0.1:6666\\@1.1.1.1\"\n\n safe_get(url, timeout=15)\n\n\nif __name__ == \"__main__\":\n main()\n```\nWhen an attacker uses `http://127.0.0.1:6666/`, the existing detection logic can detect that this is an internal network address and block it.\n\n\"QQ20260430-212723-30-3\"\n\nHowever, when an attacker uses `http://127.0.0.1:6666\\@1.1.1.1`, the detection logic resolves the host to `1.1.1.1`, which is a public IP address, thus passing the verification. But in the actual request process, this URL is forwarded by requests.get to `http://127.0.0.1:6666`, bypassing the detection and achieving an SSRF attack.\n\n\"QQ20260430-212833-30-4\"\n\n### PoC\n```\nhttp://127.0.0.1:6666\\@1.1.1.1\n```\n\n### Impact\nSSRF\n\n\n\n---\n\n## Maintainer note (2026-05-15)\n\nThanks @Fushuling and @RacerZ-fighting for the detailed report. The remediation\nspans four PRs, all merged to `main` and shipped in **v1.6.10**:\n\n**#3873** (merged 2026-05-08) — the load-bearing fix for the parser-differential\nbypass:\n- New `RFC_FORBIDDEN_URL_CHARS_RE` in `security/ssrf_validator.py` rejects\n URLs containing backslash, ASCII control bytes, or whitespace — RFC 3986\n forbids these and their presence signals a parser-differential attempt.\n- Host extraction switched from `urllib.parse.urlparse(url).hostname` to\n `urllib3.util.parse_url(url).host`. `urllib3` is the parser `requests`\n uses internally, so the validator and the HTTP client now agree on the\n destination by construction — closing the `\\@` divergence that drove the\n PoC.\n- Same two-layer defence applied to `NotificationURLValidator.validate_service_url`.\n- 53 new tests across `test_ssrf_validator.py`, `test_notification_validator.py`,\n `test_safe_requests.py`, and `test_ssrf_redirect_bypass.py`, including the\n advisory PoC `http://127.0.0.1:6666\\@1.1.1.1` and the post-prepare canonical\n form `http://127.0.0.1:6666/%5C@1.1.1.1`.\n\n**#3882** (merged 2026-05-08) — hardens the metadata-IP block and redacts\nuserinfo from log output so rejected URLs don't leak credentials to logs.\n\n**#3889** (merged 2026-05-09) — locks in real-world URL fixtures and behavior\ninvariants from #3873/#3882 as regression tests.\n\n**#3932** (merged 2026-05-10) — blocks IPv6 transition prefixes (`2002::/16`\n6to4, `64:ff9b::/96` NAT64, `2001::/32` Teredo, `100::/64` discard) so private\nIPv4 destinations cannot be reached via an IPv6-wrapped form. NAT64 has an\noperator opt-in (`LDR_SECURITY_ALLOW_NAT64=true`) for IPv6-only deployments,\nbut cloud metadata IPs remain blocked regardless.\n\n### Affected versions\n\n- **The specific parser-differential bypass** described above exists from\n **v1.3.0** (when `validate_url` was first introduced) through **v1.6.9**.\n The validator used `urlparse(url).hostname` for that entire span.\n- **Versions before v1.3.0** had no SSRF validator at all — requests went\n directly to `requests.get()` without any host check. Those versions are\n vulnerable to SSRF via this URL and any other internal address; the\n parser-differential trick is unnecessary.\n\nIn both cases the remediation is the same: **upgrade to v1.6.10 or later.**" affected: - package: name: local-deep-research ecosystem: PyPI purl: pkg:pypi/local-deep-research ranges: - type: ECOSYSTEM events: - introduced: "0" - fixed: 1.6.10 versions: - 0.1.0 - 0.1.1 - 0.1.12 - 0.1.13 - 0.1.14 - 0.1.15 - 0.1.16 - 0.1.17 - 0.1.18 - 0.1.19 - 0.1.20 - 0.1.21 - 0.1.22 - 0.1.23 - 0.1.24 - 0.1.25 - 0.1.26 - 0.2.0 - 0.2.2 - 0.2.3 - 0.3.0 - 0.3.1 - 0.3.10 - 0.3.11 - 0.3.12 - 0.3.2 - 0.3.3 - 0.3.5 - 0.3.6 - 0.3.8 - 0.3.9 - 0.4.0 - 0.4.1 - 0.4.2 - 0.4.3 - 0.4.4 - 0.5.0 - 0.5.2 - 0.5.3 - 0.5.4 - 0.5.5 - 0.5.6 - 0.5.7 - 0.5.9 - 0.6.0 - 0.6.1 - 0.6.4 - 0.6.5 - 0.6.7 - 1.0.0 - 1.0.1 - 1.1.1 - 1.1.10 - 1.1.11 - 1.1.6 - 1.1.7 - 1.1.8 - 1.1.9 - 1.2.0 - 1.2.1 - 1.2.10 - 1.2.11 - 1.2.12 - 1.2.13 - 1.2.14 - 1.2.15 - 1.2.16 - 1.2.17 - 1.2.2 - 1.2.24 - 1.2.25 - 1.2.26 - 1.2.27 - 1.2.28 - 1.2.3 - 1.2.4 - 1.2.5 - 1.2.6 - 1.2.7 - 1.2.8 - 1.2.9 - 1.3.0 - 1.3.1 - 1.3.10 - 1.3.11 - 1.3.12 - 1.3.13 - 1.3.14 - 1.3.15 - 1.3.16 - 1.3.17 - 1.3.18 - 1.3.19 - 1.3.20 - 1.3.21 - 1.3.22 - 1.3.24 - 1.3.25 - 1.3.26 - 1.3.28 - 1.3.29 - 1.3.30 - 1.3.40 - 1.3.41 - 1.3.42 - 1.3.43 - 1.3.44 - 1.3.45 - 1.3.46 - 1.3.47 - 1.3.48 - 1.3.49 - 1.3.50 - 1.3.51 - 1.3.52 - 1.3.53 - 1.3.54 - 1.3.55 - 1.3.56 - 1.3.57 - 1.3.58 - 1.3.59 - 1.3.6 - 1.3.60 - 1.3.7 - 1.3.8 - 1.3.9 - 1.4.0 - 1.5.0 - 1.5.3 - 1.5.5 - 1.5.6 - 1.6.0 - 1.6.1 - 1.6.2 - 1.6.3 - 1.6.4 - 1.6.5 - 1.6.6 - 1.6.7 - 1.6.8 - 1.6.9 references: - type: WEB url: https://github.com/LearningCircuit/local-deep-research/security/advisories/GHSA-g23j-2vwm-5c25 - type: ADVISORY url: https://nvd.nist.gov/vuln/detail/CVE-2026-46526 - type: WEB url: https://github.com/LearningCircuit/local-deep-research/pull/3873 - type: WEB url: https://github.com/LearningCircuit/local-deep-research/pull/3882 - type: WEB url: https://github.com/LearningCircuit/local-deep-research/pull/3889 - type: WEB url: https://github.com/LearningCircuit/local-deep-research/pull/3932 - type: PACKAGE url: https://github.com/LearningCircuit/local-deep-research - type: WEB url: https://github.com/LearningCircuit/local-deep-research/releases/tag/v1.6.10 - type: PACKAGE url: https://pypi.org/project/local-deep-research - type: ADVISORY url: https://github.com/advisories/GHSA-g23j-2vwm-5c25 severity: - type: CVSS_V3 score: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N