fix(security): address SSL fallback, ZIP slip, and unbounded download vulnerabilities - #9586
Conversation
…lpers io.download_file() and io.download_image_by_url() silently retried HTTPS requests with ssl.CERT_NONE when certificate verification failed. A network attacker could intercept these connections and serve malicious content (plugin/dashboard assets, chat media) without triggering any user-visible warning. Change the default of download_file() and dashboard_assets._download_package() to allow_insecure_ssl_fallback=False. Drop the silent CERT_NONE retry in download_image_by_url() entirely so SSL errors propagate to the caller. Existing callers that intentionally opt into the fallback keep the keyword argument and behavior. Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
upload_and_find_file() caught SSL verification errors and retried the download with ssl_verify=False, allowing a network attacker to serve arbitrary bytes that would then be uploaded to the Misskey instance as if they came from the legitimate URL. Drop the CERT_NONE retry entirely so certificate errors propagate to the caller instead of being masked. Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
_RepoZipUpdater._extract_archive() and _PluginUpdater._extract_plugin_archive() called zipfile.ZipFile.extractall(target_dir) without validating archive member paths. A crafted plugin or update archive could include members like ../../<outside path>/evil.py, which zipfile writes outside target_dir during extractall and only moves inside the finalize step. The later commonpath() check rejects the move but the file is already on disk. Pass a path-traversal filter to extractall() that raises ValueError before any member whose destination escapes the target directory is written. Update the existing test fake ZipFile to accept the new keyword argument. Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
download_file() streamed the response body to disk in 8 KiB chunks and only used the Content-Length header to compute progress, never to bound the download. A malicious or misconfigured remote could advertise a huge Content-Length or stream unlimited bytes, exhausting free disk space before any other validation runs. Reject responses whose advertised Content-Length exceeds a 2 GiB cap before writing any bytes, abort the stream as soon as the running total crosses that cap, and remove the partial file the size guard tripped on so a truncated artifact is not left behind. Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
P0 - Silent TLS Verification Fallback in Download HelpersRisk: A network attacker could intercept HTTPS connections and serve malicious content (plugin/dashboard assets, chat media) without triggering any user-visible warning. Fix:
Code References:
|
P0 - Silent TLS Verification Fallback in Download HelpersRisk: A network attacker could intercept HTTPS connections and serve malicious content (plugin/dashboard assets, chat media) without triggering any user-visible warning. Fix:
Code References:
|
P0 - Silent TLS Verification Fallback in Misskey URL UploadsRisk: A network attacker could intercept the connection to a user-supplied URL and serve arbitrary bytes that would then be uploaded to the Misskey instance as if they came from the legitimate URL. Fix: Dropped the Code References:
|
P1 - ZIP Slip (Path Traversal) in Archive ExtractionRisk: A crafted plugin or update archive could include members like Fix: Implemented Code References:
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- Consider applying the same
_DOWNLOAD_MAX_BYTESlimiting logic (or a dedicated cap) todownload_image_by_url()so image downloads cannot be used for an unbounded disk-fill DoS while other HTTP downloads are protected. - The
_DOWNLOAD_MAX_BYTESsafety cap is currently hard-coded at 2 GiB; if this is likely to vary across deployments, you may want to make it configurable via settings/env so operators can tune it without code changes.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Consider applying the same `_DOWNLOAD_MAX_BYTES` limiting logic (or a dedicated cap) to `download_image_by_url()` so image downloads cannot be used for an unbounded disk-fill DoS while other HTTP downloads are protected.
- The `_DOWNLOAD_MAX_BYTES` safety cap is currently hard-coded at 2 GiB; if this is likely to vary across deployments, you may want to make it configurable via settings/env so operators can tune it without code changes.
## Individual Comments
### Comment 1
<location path="astrbot/core/utils/io.py" line_range="25-28" />
<code_context>
+# 2 GiB safety cap for streamed HTTP downloads. Content-Length headers above this
+# threshold cause the download to abort before any bytes are written to disk.
+_DOWNLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024
+
+
+class DownloadTooLargeError(RuntimeError):
+ """Raised when an HTTP download exceeds the configured size cap."""
+
</code_context>
<issue_to_address>
**🚨 suggestion (security):** Consider reusing the download size cap for `download_image_by_url` to avoid unbounded reads.
`download_image_by_url` still calls `await resp.read()` and writes the full body without any size check, so large responses can bypass the new cap and lead to excessive memory/disk usage. To keep download limits consistent, either apply `_DOWNLOAD_MAX_BYTES` there as well or have it reuse `_download_response_to_file` (or a similar streaming helper).
Suggested implementation:
```python
async def download_image_by_url(session: aiohttp.ClientSession, url: str) -> bytes:
async with session.get(url) as resp:
resp.raise_for_status()
# Enforce the same maximum download size as for streamed HTTP downloads.
total_read = 0
chunks: list[bytes] = []
async for chunk in resp.content.iter_chunked(64 * 1024):
if not chunk:
break
total_read += len(chunk)
if total_read > _DOWNLOAD_MAX_BYTES:
raise DownloadTooLargeError(
f"image download exceeded size cap of {_DOWNLOAD_MAX_BYTES} bytes"
)
chunks.append(chunk)
return b"{}".join(chunks)
```
I assumed `download_image_by_url` currently does a plain `await resp.read()`. If its signature or body differ, apply the same pattern:
1. Replace `await resp.read()` with an async loop over `resp.content.iter_chunked(...)`.
2. Track `total_read` and raise `DownloadTooLargeError` when it exceeds `_DOWNLOAD_MAX_BYTES`.
3. If you already have a helper like `_download_response_to_file(resp, path)` that enforces `_DOWNLOAD_MAX_BYTES`, consider refactoring `download_image_by_url` to reuse it instead of duplicating the chunked read logic (e.g. download to a temp file and then load the image from disk).
4. Ensure `DownloadTooLargeError` and `_DOWNLOAD_MAX_BYTES` are in scope in this module and, if needed, add tests that cover oversized image responses (both with and without `Content-Length` headers).
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
P1 - Unbounded Streamed Download Size (Disk-Fill DoS)Risk: A malicious or misconfigured remote could advertise a huge Fix: Added
Code References:
|
DownloadImageByUrl previously read the full response body without any size check, bypassing the _DOWNLOAD_MAX_BYTES protection added for streamed file downloads. Stream the response in 64 KiB chunks, enforce the same cap, and raise DownloadTooLargeError when exceeded. Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>
This PR fixes four security vulnerabilities found during a security audit:
Summary of Fixes
P0 - Silent TLS Verification Fallback in Download Helpers (
io.download_file(),io.download_image_by_url(),dashboard_assets._download_package())Risk: A network attacker could intercept HTTPS connections and serve malicious content (plugin/dashboard assets, chat media) without triggering any user-visible warning.
Fix:
download_file()allow_insecure_ssl_fallback=True→Falsedashboard_assets._download_package()allow_insecure_ssl_fallback=True→Falsessl.CERT_NONEretry indownload_image_by_url()entirelyCode References:
astrbot/core/utils/io.py:209-download_file()default changedastrbot/core/utils/io.py:283-download_image_by_url()fallback removedastrbot/core/dashboard_assets.py:103-_download_package()default changedP0 - Silent TLS Verification Fallback in Misskey URL Uploads (
misskey_api.upload_and_find_file())Risk: A network attacker could intercept the connection to a user-supplied URL and serve arbitrary bytes that would then be uploaded to the Misskey instance as if they came from the legitimate URL.
Fix: Dropped the
CERT_NONEretry entirely so certificate errors propagate to the caller.Code References:
astrbot/core/platform/sources/misskey/misskey_api.py:126- Removed silent retry withssl_verify=FalseP1 - ZIP Slip (Path Traversal) in Archive Extraction (
_RepoZipUpdater._extract_archive(),_PluginUpdater._extract_plugin_archive())Risk: A crafted plugin or update archive could include members like
../../<outside path>/evil.py, which zipfile writes outsidetarget_dirduringextractall. The latercommonpath()check rejects the move but the file is already on disk.Fix: Implemented
_extract_zip_safely()helper that validates each member's destination againsttarget_dirusingos.path.commonpathbefore delegating toZipFile.extract(). Handles cross-driveValueErroron Windows.Code References:
astrbot/core/zip_updater.py:20- New_extract_zip_safely()helperastrbot/core/zip_updater.py:345-_extract_archive()uses helperastrbot/core/star/updater.py:463-_extract_plugin_archive()uses helperP1 - Unbounded Streamed Download Size (Disk-Fill DoS) in
io.download_file()Risk: A malicious or misconfigured remote could advertise a huge
Content-Lengthor stream unlimited bytes, exhausting free disk space before any other validation runs.Fix: Added
_DOWNLOAD_MAX_BYTES = 2 GiBcap with:Content-Lengthheader before writing any bytesCode References:
astrbot/core/utils/io.py:17- NewDownloadTooLargeErrorexception and_DOWNLOAD_MAX_BYTESconstantastrbot/core/utils/io.py:226- Content-Length pre-checkastrbot/core/utils/io.py:242- Streaming size check with partial file cleanupTesting
_extract_zip_safely()interfaceCo-authored-by
All commits include
Co-authored-by: cgsdn <chaogeshuodiannao@users.noreply.github.com>Summary by Sourcery
Harden network and archive handling to address multiple security issues, including unsafe TLS fallbacks, ZIP slip during extraction, and unbounded HTTP download sizes.
Bug Fixes:
Enhancements:
Tests: