content-core 2.2.0__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. {content_core-2.2.0 → content_core-2.2.1}/.claude-plugin/plugin.json +1 -1
  2. {content_core-2.2.0 → content_core-2.2.1}/.codex-plugin/plugin.json +1 -1
  3. {content_core-2.2.0 → content_core-2.2.1}/CHANGELOG.md +5 -0
  4. {content_core-2.2.0 → content_core-2.2.1}/PKG-INFO +1 -1
  5. {content_core-2.2.0 → content_core-2.2.1}/pyproject.toml +1 -1
  6. content_core-2.2.1/src/content_core/common/http.py +131 -0
  7. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/extraction.py +3 -4
  8. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/url/__init__.py +5 -2
  9. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/url/bs4.py +3 -3
  10. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/url/reddit.py +3 -2
  11. content_core-2.2.1/tests/unit/test_guarded_http.py +170 -0
  12. {content_core-2.2.0 → content_core-2.2.1}/uv.lock +1 -1
  13. {content_core-2.2.0 → content_core-2.2.1}/.agent-smith/index.json +0 -0
  14. {content_core-2.2.0 → content_core-2.2.1}/.agents/plugins/marketplace.json +0 -0
  15. {content_core-2.2.0 → content_core-2.2.1}/.claude-plugin/marketplace.json +0 -0
  16. {content_core-2.2.0 → content_core-2.2.1}/.gitattributes +0 -0
  17. {content_core-2.2.0 → content_core-2.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {content_core-2.2.0 → content_core-2.2.1}/.github/workflows/claude-code-review.yml +0 -0
  19. {content_core-2.2.0 → content_core-2.2.1}/.github/workflows/claude.yml +0 -0
  20. {content_core-2.2.0 → content_core-2.2.1}/.github/workflows/create-tag.yml +0 -0
  21. {content_core-2.2.0 → content_core-2.2.1}/.github/workflows/package.yml +0 -0
  22. {content_core-2.2.0 → content_core-2.2.1}/.github/workflows/publish.yml +0 -0
  23. {content_core-2.2.0 → content_core-2.2.1}/.github/workflows/test.yml +0 -0
  24. {content_core-2.2.0 → content_core-2.2.1}/.gitignore +0 -0
  25. {content_core-2.2.0 → content_core-2.2.1}/.maintainer/PROFILE.md +0 -0
  26. {content_core-2.2.0 → content_core-2.2.1}/.maintainer/README.md +0 -0
  27. {content_core-2.2.0 → content_core-2.2.1}/.maintainer/gotchas.md +0 -0
  28. {content_core-2.2.0 → content_core-2.2.1}/.maintainer/profile.toml +0 -0
  29. {content_core-2.2.0 → content_core-2.2.1}/.maintainer/release/runbook.md +0 -0
  30. {content_core-2.2.0 → content_core-2.2.1}/.maintainer/release/test-matrix.md +0 -0
  31. {content_core-2.2.0 → content_core-2.2.1}/.python-version +0 -0
  32. {content_core-2.2.0 → content_core-2.2.1}/AGENTS.md +0 -0
  33. {content_core-2.2.0 → content_core-2.2.1}/ARCHITECTURE.md +0 -0
  34. {content_core-2.2.0 → content_core-2.2.1}/CLAUDE.md +0 -0
  35. {content_core-2.2.0 → content_core-2.2.1}/CONTRIBUTING.md +0 -0
  36. {content_core-2.2.0 → content_core-2.2.1}/LICENSE +0 -0
  37. {content_core-2.2.0 → content_core-2.2.1}/Makefile +0 -0
  38. {content_core-2.2.0 → content_core-2.2.1}/README.md +0 -0
  39. {content_core-2.2.0 → content_core-2.2.1}/REFACTOR.md +0 -0
  40. {content_core-2.2.0 → content_core-2.2.1}/TEST_PLAN.md +0 -0
  41. {content_core-2.2.0 → content_core-2.2.1}/docs/cli.md +0 -0
  42. {content_core-2.2.0 → content_core-2.2.1}/docs/mcp.md +0 -0
  43. {content_core-2.2.0 → content_core-2.2.1}/docs/processors.md +0 -0
  44. {content_core-2.2.0 → content_core-2.2.1}/docs/usage.md +0 -0
  45. {content_core-2.2.0 → content_core-2.2.1}/examples/main.py +0 -0
  46. {content_core-2.2.0 → content_core-2.2.1}/prompts/content/cleanup.jinja +0 -0
  47. {content_core-2.2.0 → content_core-2.2.1}/prompts/content/summarize.jinja +0 -0
  48. {content_core-2.2.0 → content_core-2.2.1}/skills/content-core/SKILL.md +0 -0
  49. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/__init__.py +0 -0
  50. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/cli.py +0 -0
  51. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/common/__init__.py +0 -0
  52. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/common/exceptions.py +0 -0
  53. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/common/messages.py +0 -0
  54. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/common/retry.py +0 -0
  55. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/common/state.py +0 -0
  56. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/common/types.py +0 -0
  57. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/config.py +0 -0
  58. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/content/__init__.py +0 -0
  59. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/content/extraction/__init__.py +0 -0
  60. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/content/identification/__init__.py +0 -0
  61. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/content/identification/file_detector.py +0 -0
  62. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/content/summary/__init__.py +0 -0
  63. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/content/summary/core.py +0 -0
  64. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/logging.py +0 -0
  65. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/mcp/__init__.py +0 -0
  66. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/mcp/server.py +0 -0
  67. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/models.py +0 -0
  68. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/document/__init__.py +0 -0
  69. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/document/docling.py +0 -0
  70. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/document/docx.py +0 -0
  71. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/document/epub.py +0 -0
  72. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/document/odf.py +0 -0
  73. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/document/pdf.py +0 -0
  74. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/document/pptx.py +0 -0
  75. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/document/xlsx.py +0 -0
  76. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/media/__init__.py +0 -0
  77. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/media/audio.py +0 -0
  78. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/media/video.py +0 -0
  79. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/text.py +0 -0
  80. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/url/crawl4ai.py +0 -0
  81. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/url/firecrawl.py +0 -0
  82. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/url/jina.py +0 -0
  83. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/processors/url/youtube.py +0 -0
  84. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/py.typed +0 -0
  85. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/templated_message.py +0 -0
  86. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/tools/__init__.py +0 -0
  87. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/tools/extract.py +0 -0
  88. {content_core-2.2.0 → content_core-2.2.1}/src/content_core/tools/summarize.py +0 -0
  89. {content_core-2.2.0 → content_core-2.2.1}/tests/conftest.py +0 -0
  90. {content_core-2.2.0 → content_core-2.2.1}/tests/e2e/__init__.py +0 -0
  91. {content_core-2.2.0 → content_core-2.2.1}/tests/e2e/test_docling.py +0 -0
  92. {content_core-2.2.0 → content_core-2.2.1}/tests/e2e/test_media.py +0 -0
  93. {content_core-2.2.0 → content_core-2.2.1}/tests/e2e/test_remote.py +0 -0
  94. {content_core-2.2.0 → content_core-2.2.1}/tests/e2e/test_url_engines.py +0 -0
  95. {content_core-2.2.0 → content_core-2.2.1}/tests/e2e/test_youtube.py +0 -0
  96. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.docx +0 -0
  97. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.epub +0 -0
  98. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.md +0 -0
  99. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.mp3 +0 -0
  100. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.mp4 +0 -0
  101. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.odp +0 -0
  102. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.ods +0 -0
  103. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.odt +0 -0
  104. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.pdf +0 -0
  105. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.pptx +0 -0
  106. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.txt +0 -0
  107. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/file.xlsx +0 -0
  108. {content_core-2.2.0 → content_core-2.2.1}/tests/input_content/new_pdf.pdf +0 -0
  109. {content_core-2.2.0 → content_core-2.2.1}/tests/integration/conftest.py +0 -0
  110. {content_core-2.2.0 → content_core-2.2.1}/tests/integration/test_cli_v2.py +0 -0
  111. {content_core-2.2.0 → content_core-2.2.1}/tests/integration/test_extraction.py +0 -0
  112. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_audio_concurrency.py +0 -0
  113. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_cli.py +0 -0
  114. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_config_file.py +0 -0
  115. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_config_v2.py +0 -0
  116. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_crawl4ai_docker.py +0 -0
  117. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_docling_extraction.py +0 -0
  118. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_epub_extraction.py +0 -0
  119. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_exceptions.py +0 -0
  120. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_file_detector.py +0 -0
  121. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_file_detector_critical.py +0 -0
  122. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_file_detector_performance.py +0 -0
  123. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_html_file_extraction.py +0 -0
  124. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_logging.py +0 -0
  125. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_mcp_server_version.py +0 -0
  126. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_mcp_v2.py +0 -0
  127. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_media_pipeline.py +0 -0
  128. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_models_v2.py +0 -0
  129. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_office_extraction.py +0 -0
  130. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_pdf_extraction.py +0 -0
  131. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_pdf_helpers.py +0 -0
  132. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_reddit_extraction.py +0 -0
  133. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_retry.py +0 -0
  134. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_routing.py +0 -0
  135. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_text_processing.py +0 -0
  136. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_url_engine_select.py +0 -0
  137. {content_core-2.2.0 → content_core-2.2.1}/tests/unit/test_youtube_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-core",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Extract text content from URLs, documents, YouTube videos, Reddit posts, and audio/video files — via CLI (uvx) or MCP.",
5
5
  "author": {
6
6
  "name": "Luis Novo",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-core",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Extract text content from URLs, documents, YouTube videos, Reddit posts, and audio/video files — via CLI (uvx) or MCP.",
5
5
  "skills": "./skills/"
6
6
  }
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.1] - 2026-11-03
11
+
12
+ ### Security
13
+ - URL fetches made directly by content-core (the `simple` engine, the MIME-type probe, remote file downloads and Reddit posts) refuse link-local and cloud-metadata addresses, including on every redirect hop and for hostnames that resolve to them. Private and loopback addresses are still allowed. A refused URL raises `InvalidInputError` (`BlockedAddressError`).
14
+
10
15
  ## [2.2.0] - 2026-10-03
11
16
 
12
17
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: content-core
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Extract what matters from any media source. Available as Python Library, macOS Service, CLI and MCP Server
5
5
  Author-email: LUIS NOVO <lfnovo@gmail.com>
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "content-core"
3
- version = "2.2.0"
3
+ version = "2.2.1"
4
4
  description = "Extract what matters from any media source. Available as Python Library, macOS Service, CLI and MCP Server"
5
5
  readme = "README.md"
6
6
  homepage = "https://github.com/lfnovo/content-core"
@@ -0,0 +1,131 @@
1
+ """HTTP sessions for fetching user-supplied URLs.
2
+
3
+ Callers (Open Notebook, the MCP server) pass URLs they did not choose, and the
4
+ fetch runs from inside their network. ``guarded_session()`` refuses to connect
5
+ to link-local addresses (169.254.0.0/16, fe80::/10, which includes cloud
6
+ metadata services such as 169.254.169.254) and to AWS's IPv6 metadata address,
7
+ on the first request and on every redirect hop:
8
+
9
+ - hostnames are checked by the connector's resolver, on the addresses the
10
+ connection will actually use (no re-resolution between check and connect);
11
+ - IP literals skip the resolver, so they are checked before the request and
12
+ on each redirect ``Location``.
13
+
14
+ Private and loopback addresses stay allowed: self-hosted deployments
15
+ legitimately fetch from their own network. When the session goes through an
16
+ HTTP(S) proxy (``trust_env``), the target hostname is resolved by the proxy,
17
+ so only IP literals are checked locally.
18
+ """
19
+
20
+ import ipaddress
21
+ import socket
22
+ from types import SimpleNamespace
23
+ from typing import Any
24
+
25
+ import aiohttp
26
+ from aiohttp.abc import AbstractResolver, ResolveResult
27
+ from yarl import URL
28
+
29
+ from content_core.common.exceptions import InvalidInputError
30
+
31
+ # AWS IMDSv6 is a Unique Local Address, not link-local.
32
+ _AWS_IMDS_V6 = ipaddress.ip_address("fd00:ec2::254")
33
+
34
+ IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
35
+
36
+
37
+ class BlockedAddressError(InvalidInputError):
38
+ """Raised when a URL, or a redirect it leads to, targets a blocked address."""
39
+
40
+
41
+ def is_blocked_address(ip: IPAddress) -> bool:
42
+ """True for link-local and cloud-metadata addresses."""
43
+ if ip.is_link_local:
44
+ return True
45
+ if isinstance(ip, ipaddress.IPv6Address):
46
+ mapped = ip.ipv4_mapped
47
+ if mapped is not None and mapped.is_link_local:
48
+ return True
49
+ # Compare without scope id so fd00:ec2::254%eth0 is caught too.
50
+ if int(ip) == int(_AWS_IMDS_V6):
51
+ return True
52
+ return False
53
+
54
+
55
+ def _parse_ip(host: str) -> IPAddress | None:
56
+ try:
57
+ return ipaddress.ip_address(host.strip("[]").split("%", 1)[0])
58
+ except ValueError:
59
+ return None
60
+
61
+
62
+ def check_url_host(url: str | URL) -> None:
63
+ """Raise BlockedAddressError if ``url``'s host is a blocked IP literal."""
64
+ host = URL(str(url)).host or ""
65
+ ip = _parse_ip(host)
66
+ if ip is not None and is_blocked_address(ip):
67
+ raise BlockedAddressError(
68
+ f"Refusing to fetch {url}: link-local and cloud-metadata "
69
+ "addresses are not allowed"
70
+ )
71
+
72
+
73
+ class GuardedResolver(AbstractResolver):
74
+ """Resolver that drops blocked addresses and fails if none remain."""
75
+
76
+ def __init__(self) -> None:
77
+ self._resolver = aiohttp.DefaultResolver()
78
+
79
+ async def resolve(
80
+ self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET
81
+ ) -> list[ResolveResult]:
82
+ results = await self._resolver.resolve(host, port, family)
83
+ allowed = [
84
+ r
85
+ for r in results
86
+ if not ((ip := _parse_ip(r["host"])) and is_blocked_address(ip))
87
+ ]
88
+ if not allowed:
89
+ raise BlockedAddressError(
90
+ f"Refusing to fetch from {host}: it resolves to a link-local or "
91
+ "cloud-metadata address"
92
+ )
93
+ return allowed
94
+
95
+ async def close(self) -> None:
96
+ await self._resolver.close()
97
+
98
+
99
+ async def _on_request_redirect(
100
+ session: aiohttp.ClientSession,
101
+ ctx: SimpleNamespace,
102
+ params: aiohttp.TraceRequestRedirectParams,
103
+ ) -> None:
104
+ location = params.response.headers.get("Location") or params.response.headers.get(
105
+ "URI"
106
+ )
107
+ if location:
108
+ check_url_host(params.response.url.join(URL(location)))
109
+
110
+
111
+ def guarded_session(**kwargs: Any) -> aiohttp.ClientSession:
112
+ """A ClientSession that refuses blocked addresses on every hop.
113
+
114
+ Use it for any request whose URL comes from the caller. The initial URL's
115
+ IP literal is checked by ``guarded_request_url``; pass the URL through it
116
+ before the request.
117
+ """
118
+ trace = aiohttp.TraceConfig()
119
+ trace.on_request_redirect.append(_on_request_redirect)
120
+ kwargs.setdefault("trust_env", True)
121
+ return aiohttp.ClientSession(
122
+ connector=aiohttp.TCPConnector(resolver=GuardedResolver()),
123
+ trace_configs=[trace],
124
+ **kwargs,
125
+ )
126
+
127
+
128
+ def guarded_request_url(url: str) -> str:
129
+ """Check ``url``'s host before the first request; returns it unchanged."""
130
+ check_url_host(url)
131
+ return url
@@ -5,8 +5,6 @@ import os
5
5
  import tempfile
6
6
  from urllib.parse import urlparse
7
7
 
8
- import aiohttp
9
-
10
8
  from content_core.common.exceptions import (
11
9
  ConfigurationError,
12
10
  ContentCoreError,
@@ -15,6 +13,7 @@ from content_core.common.exceptions import (
15
13
  UnsupportedTypeException,
16
14
  )
17
15
  from content_core.common.messages import DOCLING_MISSING_MESSAGE
16
+ from content_core.common.http import guarded_request_url, guarded_session
18
17
  from content_core.common.retry import retry_download
19
18
  from content_core.config import ContentCoreConfig, get_default_config
20
19
  from content_core.logging import logger
@@ -307,8 +306,8 @@ async def _extract_file(
307
306
  @retry_download()
308
307
  async def _fetch_remote_file(url: str) -> tuple:
309
308
  """Download a remote file. Wrapped with retry logic."""
310
- async with aiohttp.ClientSession(trust_env=True) as session:
311
- async with session.get(url) as resp:
309
+ async with guarded_session() as session:
310
+ async with session.get(guarded_request_url(url)) as resp:
312
311
  resp.raise_for_status()
313
312
  mime = resp.headers.get("content-type", "").split(";", 1)[0]
314
313
  content = await resp.read()
@@ -12,6 +12,7 @@ from content_core.common.exceptions import (
12
12
  NetworkError,
13
13
  NotFoundError,
14
14
  )
15
+ from content_core.common.http import guarded_request_url, guarded_session
15
16
  from content_core.common.retry import retry_url_network
16
17
  from content_core.common.types import UrlEngine
17
18
  from content_core.config import ContentCoreConfig
@@ -35,8 +36,10 @@ from content_core.processors.url.crawl4ai import extract_url_crawl4ai
35
36
  @retry_url_network()
36
37
  async def _fetch_url_mime_type(url: str) -> str:
37
38
  """Internal function to fetch URL MIME type - wrapped with retry logic."""
38
- async with aiohttp.ClientSession(trust_env=True) as session:
39
- async with session.head(url, timeout=10, allow_redirects=True) as resp:
39
+ async with guarded_session() as session:
40
+ async with session.head(
41
+ guarded_request_url(url), timeout=10, allow_redirects=True
42
+ ) as resp:
40
43
  mime = resp.headers.get("content-type", "").split(";", 1)[0]
41
44
  logger.debug(f"MIME type for {url}: {mime}")
42
45
  return mime
@@ -1,7 +1,7 @@
1
- import aiohttp
2
1
  from bs4 import BeautifulSoup
3
2
  from readability import Document
4
3
 
4
+ from content_core.common.http import guarded_request_url, guarded_session
5
5
  from content_core.common.retry import retry_url_network
6
6
  from content_core.logging import logger
7
7
 
@@ -9,8 +9,8 @@ from content_core.logging import logger
9
9
  @retry_url_network()
10
10
  async def _fetch_url_html(url: str) -> str:
11
11
  """Internal function to fetch URL HTML content - wrapped with retry logic."""
12
- async with aiohttp.ClientSession(trust_env=True) as session:
13
- async with session.get(url, timeout=10) as response:
12
+ async with guarded_session() as session:
13
+ async with session.get(guarded_request_url(url), timeout=10) as response:
14
14
  # Raise ClientResponseError so retry logic can inspect status code
15
15
  # (5xx and 429 will be retried, 4xx will not)
16
16
  response.raise_for_status()
@@ -4,6 +4,7 @@ from urllib.parse import urlparse, urlunparse
4
4
 
5
5
  import aiohttp
6
6
 
7
+ from content_core.common.http import guarded_request_url, guarded_session
7
8
  from content_core.common.retry import retry_url_network
8
9
  from content_core.config import ContentCoreConfig
9
10
  from content_core.logging import logger
@@ -28,9 +29,9 @@ async def _fetch_reddit_json(url: str) -> dict:
28
29
  clean_url = urlunparse((parsed.scheme, parsed.netloc, clean_path, "", "", ""))
29
30
  json_url = clean_url + ".json"
30
31
 
31
- async with aiohttp.ClientSession(trust_env=True) as session:
32
+ async with guarded_session() as session:
32
33
  async with session.get(
33
- json_url,
34
+ guarded_request_url(json_url),
34
35
  headers={"User-Agent": "content-core/2.0"},
35
36
  timeout=aiohttp.ClientTimeout(total=15),
36
37
  ) as resp:
@@ -0,0 +1,170 @@
1
+ """Unit tests for the guarded HTTP session (common/http.py).
2
+
3
+ A local aiohttp server stands in for the remote site, so no network is needed.
4
+ """
5
+
6
+ import ipaddress
7
+ import socket
8
+
9
+ import pytest
10
+ from aiohttp import web
11
+
12
+ from content_core.common.exceptions import InvalidInputError
13
+ from content_core.common.http import (
14
+ BlockedAddressError,
15
+ GuardedResolver,
16
+ check_url_host,
17
+ guarded_request_url,
18
+ guarded_session,
19
+ is_blocked_address,
20
+ )
21
+ from content_core.processors.url.bs4 import _fetch_url_html
22
+
23
+
24
+ @pytest.fixture
25
+ async def site():
26
+ """Local server: /ok answers, /redirect?to=<target> redirects to <target>."""
27
+
28
+ async def ok(request):
29
+ return web.Response(text="<html><body>fine</body></html>")
30
+
31
+ async def redirect(request):
32
+ raise web.HTTPFound(request.query["to"])
33
+
34
+ app = web.Application()
35
+ app.router.add_get("/ok", ok)
36
+ app.router.add_get("/redirect", redirect)
37
+ runner = web.AppRunner(app)
38
+ await runner.setup()
39
+ tcp = web.TCPSite(runner, "127.0.0.1", 0)
40
+ await tcp.start()
41
+ port = tcp._server.sockets[0].getsockname()[1]
42
+ yield f"http://127.0.0.1:{port}"
43
+ await runner.cleanup()
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ "address,blocked",
48
+ [
49
+ ("169.254.169.254", True),
50
+ ("169.254.0.1", True),
51
+ ("fe80::1", True),
52
+ ("::ffff:169.254.169.254", True),
53
+ ("fd00:ec2::254", True),
54
+ ("127.0.0.1", False),
55
+ ("10.0.0.5", False),
56
+ ("192.168.1.10", False),
57
+ ("8.8.8.8", False),
58
+ ("::1", False),
59
+ ],
60
+ )
61
+ def test_is_blocked_address(address, blocked):
62
+ assert is_blocked_address(ipaddress.ip_address(address)) is blocked
63
+
64
+
65
+ def test_check_url_host_rejects_metadata_literal():
66
+ with pytest.raises(BlockedAddressError):
67
+ check_url_host("http://169.254.169.254/latest/meta-data/")
68
+ with pytest.raises(BlockedAddressError):
69
+ check_url_host("http://[fd00:ec2::254]/latest/")
70
+
71
+
72
+ def test_check_url_host_allows_hostnames_and_private_ips():
73
+ check_url_host("https://example.com/page")
74
+ check_url_host("http://192.168.1.10:11434/")
75
+
76
+
77
+ def test_blocked_address_error_is_invalid_input():
78
+ # Typed as invalid input, so retry logic treats it as permanent.
79
+ assert issubclass(BlockedAddressError, InvalidInputError)
80
+
81
+
82
+ async def test_guarded_session_fetches_allowed_url(site):
83
+ async with guarded_session() as session:
84
+ async with session.get(guarded_request_url(f"{site}/ok")) as resp:
85
+ assert resp.status == 200
86
+
87
+
88
+ async def test_guarded_session_follows_allowed_redirect(site):
89
+ async with guarded_session() as session:
90
+ async with session.get(f"{site}/redirect", params={"to": "/ok"}) as resp:
91
+ assert resp.status == 200
92
+ assert "fine" in await resp.text()
93
+
94
+
95
+ async def test_redirect_to_metadata_address_is_refused(site):
96
+ async with guarded_session() as session:
97
+ with pytest.raises(BlockedAddressError):
98
+ async with session.get(
99
+ f"{site}/redirect",
100
+ params={"to": "http://169.254.169.254/latest/meta-data/"},
101
+ ):
102
+ pass
103
+
104
+
105
+ async def test_bs4_fetch_refuses_redirect_to_metadata(site):
106
+ with pytest.raises(BlockedAddressError):
107
+ await _fetch_url_html(
108
+ f"{site}/redirect?to=http://169.254.169.254/latest/meta-data/"
109
+ )
110
+
111
+
112
+ async def test_bs4_fetch_refuses_metadata_literal():
113
+ with pytest.raises(BlockedAddressError):
114
+ await _fetch_url_html("http://169.254.169.254/latest/meta-data/")
115
+
116
+
117
+ class _FakeResolver:
118
+ def __init__(self, addresses):
119
+ self.addresses = addresses
120
+
121
+ async def resolve(self, host, port=0, family=socket.AF_INET):
122
+ return [
123
+ {
124
+ "hostname": host,
125
+ "host": a,
126
+ "port": port,
127
+ "family": family,
128
+ "proto": 0,
129
+ "flags": 0,
130
+ }
131
+ for a in self.addresses
132
+ ]
133
+
134
+ async def close(self):
135
+ pass
136
+
137
+
138
+ async def test_resolver_refuses_host_resolving_only_to_link_local():
139
+ resolver = GuardedResolver()
140
+ resolver._resolver = _FakeResolver(["169.254.169.254"])
141
+ with pytest.raises(BlockedAddressError):
142
+ await resolver.resolve("metadata.attacker.test", 80)
143
+
144
+
145
+ async def test_resolver_drops_link_local_and_keeps_the_rest():
146
+ resolver = GuardedResolver()
147
+ resolver._resolver = _FakeResolver(["169.254.169.254", "93.184.216.34"])
148
+ results = await resolver.resolve("mixed.test", 80)
149
+ assert [r["host"] for r in results] == ["93.184.216.34"]
150
+
151
+
152
+ async def test_redirect_to_hostname_resolving_to_link_local_is_refused(
153
+ site, monkeypatch
154
+ ):
155
+ real_resolve = GuardedResolver.resolve
156
+
157
+ async def resolve(self, host, port=0, family=socket.AF_INET):
158
+ if host == "metadata.attacker.test":
159
+ self._resolver = _FakeResolver(["169.254.169.254"])
160
+ return await real_resolve(self, host, port, family)
161
+
162
+ monkeypatch.setattr(GuardedResolver, "resolve", resolve)
163
+ # No proxy: the hostname must be resolved locally for this check.
164
+ async with guarded_session(trust_env=False) as session:
165
+ with pytest.raises(BlockedAddressError):
166
+ async with session.get(
167
+ f"{site}/redirect",
168
+ params={"to": "http://metadata.attacker.test/latest/"},
169
+ ):
170
+ pass
@@ -940,7 +940,7 @@ wheels = [
940
940
 
941
941
  [[package]]
942
942
  name = "content-core"
943
- version = "2.2.0"
943
+ version = "2.2.1"
944
944
  source = { editable = "." }
945
945
  dependencies = [
946
946
  { name = "ai-prompter" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes