Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
109 changes: 109 additions & 0 deletions PR_DESCRIPTION_9446.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Issue #9446 - SSL Verification Downgrade Vulnerability Fix

## 🔐 Security Fix

This PR fixes a critical security vulnerability where `download_image_by_url()` and `download_file()`
would silently downgrade SSL certificate verification to `CERT_NONE` on SSL errors, allowing
man-in-the-middle (MITM) attacks.

### Vulnerability Details

**Before Fix:**
- When SSL certificate verification failed, the code would catch the exception and retry with `ssl.CERT_NONE`
- This fallback behavior was automatic and silent (only logged a warning)
- Attackers could exploit this by triggering certificate errors to force insecure connections

**After Fix:**
- SSL certificate verification is always enforced
- SSL errors raise exceptions immediately without fallback
- No automatic downgrade to insecure connections

## 🔧 Changes

### Modified Functions

#### `download_image_by_url()` (lines 116-144)
- **Removed:** SSL fallback try-except block that would retry with `CERT_NONE`
- **Behavior:** SSL certificate errors now raise immediately instead of falling back to insecure mode
- **Impact:** Functions calling this must handle SSL exceptions explicitly

#### `download_file()` (lines 250-291)
- **Removed:** SSL fallback logic
- **Changed:** Default parameter value `allow_insecure_ssl_fallback=False` (was `True`)
- **Updated:** Parameter documentation to indicate it's deprecated and ignored
- **Behavior:** Always enforces SSL verification regardless of parameter value

#### `download_dashboard()` (lines 452-549)
- **Updated:** Parameter documentation for `allow_insecure_ssl_fallback`
- **Documentation:** Now indicates the parameter is deprecated and ignored
- **Note:** Parameter retained for backward compatibility but has no effect

### Breaking Change ⚠️

**Parameter default changed:** `download_file(allow_insecure_ssl_fallback=False)` (was `True`)

**Impact:** Code that relied on automatic SSL verification downgrade will now raise exceptions
for certificate errors. This is intentional security hardening.

**Migration Guide:**
- If you encounter SSL certificate errors, do NOT disable verification
- For self-signed certificates: Add them to your system's CA certificate store
- For development/testing: Use proper certificate configuration instead of bypassing verification
- The `allow_insecure_ssl_fallback` parameter is now deprecated and ignored

## ✅ Testing

### Test Coverage
- **13 new SSL verification tests** in `tests/unit/test_io_ssl_verification.py`
- **SSL error handling tests** in `tests/unit/test_io_download_file.py`
- All existing download tests updated and passing

### Test Results
- ✅ SSL errors raise immediately without retry
- ✅ HTTPS downloads with valid certificates work correctly
- ✅ HTTP (non-encrypted) URLs continue to work
- ✅ Both `download_image_by_url()` and `download_file()` enforce SSL verification
- ✅ Deprecated parameter is properly ignored

## 📝 Documentation Updates

### Updated Documentation
1. **`download_file()`** - Parameter documentation updated to indicate deprecation
2. **`download_dashboard()`** - Parameter documentation updated to indicate deprecation
3. **Test files** - Include references to Issue #9446 with detailed explanations

### Parameter Documentation
```python
allow_insecure_ssl_fallback: Deprecated parameter, ignored. SSL certificate
verification is always enforced for security.
```

## 🔍 Security Considerations

### Why This Fix Matters
1. **Prevents MITM Attacks:** Ensures all HTTPS connections verify server identity
2. **No Silent Failures:** SSL errors are visible and must be handled explicitly
3. **Defense in Depth:** Removes automatic fallback to insecure mode
4. **Compliance:** Aligns with security best practices and standards

### Affected Code Paths
- Dashboard downloads from official repositories
- Plugin downloads
- Image downloads from external URLs
- Any code using `download_file()` or `download_image_by_url()`

## 📋 Checklist

- [x] Security vulnerability fixed
- [x] Breaking change documented
- [x] All tests passing (13 new SSL tests added)
- [x] Documentation updated for affected functions
- [x] Migration guide provided
- [x] No new dependencies introduced
- [x] Code reviewed for security implications

## 🔗 Related

- Issue: #9446
- Related commits: `218e88755` (earlier SSL error handling work)
- Security: High priority - MITM vulnerability
129 changes: 63 additions & 66 deletions astrbot/core/utils/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,31 @@ async def download_image_by_url(
post: bool = False,
post_data: dict | None = None,
path: str | None = None,
timeout: float = 30.0,
) -> str:
"""下载图片, 返回 path"""
"""下载图片, 返回 path

Args:
url: 图片 URL
post: 是否使用 POST 请求
post_data: POST 请求数据
path: 保存路径, 如果为 None 则保存到临时文件
timeout: 下载超时时间(秒),默认 30 秒

Returns:
图片文件路径
"""
ssl_context = ssl.create_default_context(
cafile=certifi.where(),
) # 使用 certifi 提供的 CA 证书
connector = aiohttp.TCPConnector(ssl=ssl_context) # 使用 certifi 的根证书
client_timeout = aiohttp.ClientTimeout(total=timeout)

try:
ssl_context = ssl.create_default_context(
cafile=certifi.where(),
) # 使用 certifi 提供的 CA 证书
connector = aiohttp.TCPConnector(ssl=ssl_context) # 使用 certifi 的根证书
async with aiohttp.ClientSession(
trust_env=True,
connector=connector,
timeout=client_timeout,
) as session:
if post:
async with session.post(url, json=post_data) as resp:
Expand All @@ -143,34 +158,18 @@ async def download_image_by_url(
with open(path, "wb") as f:
f.write(await resp.read())
return path
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
# 关闭SSL验证(仅在证书验证失败时作为fallback)
logger.warning(
except aiohttp.ClientConnectorSSLError as e:
logger.error(
f"SSL certificate verification failed for {_safe_url_for_log(url)}. "
"Disabling SSL verification (CERT_NONE) as a fallback. "
"This is insecure and exposes the application to man-in-the-middle attacks. "
"Please investigate and resolve certificate issues."
"Please check the server's SSL certificate configuration."
)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession() as session:
if post:
async with session.post(url, json=post_data, ssl=ssl_context) as resp:
if not path:
return save_temp_img(await resp.read())
with open(path, "wb") as f:
f.write(await resp.read())
return path
else:
async with session.get(url, ssl=ssl_context) as resp:
if not path:
return save_temp_img(await resp.read())
with open(path, "wb") as f:
f.write(await resp.read())
return path
except Exception as e:
raise e
raise
except aiohttp.ClientConnectorCertificateError as e:
logger.error(
f"SSL certificate error for {_safe_url_for_log(url)}. "
"Certificate may be expired or invalid."
)
raise


async def _emit_download_progress(progress_callback, payload: dict) -> None:
Expand Down Expand Up @@ -281,7 +280,7 @@ async def download_file(
path: str,
show_progress: bool = False,
progress_callback=None,
allow_insecure_ssl_fallback: bool = True,
allow_insecure_ssl_fallback: bool = False,
) -> None:
"""Download a remote file to a local path.

Expand All @@ -290,18 +289,25 @@ async def download_file(
path: Local destination path.
show_progress: Whether to print progress to stdout.
progress_callback: Optional callback for progress payloads.
allow_insecure_ssl_fallback: Whether certificate failures may retry with
TLS certificate verification disabled.
allow_insecure_ssl_fallback: Deprecated parameter, ignored. SSL verification
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
is always enforced.

Returns:
None.
"""
if allow_insecure_ssl_fallback is not False:
logger.warning(
"Parameter 'allow_insecure_ssl_fallback' is deprecated and ignored. "
"SSL certificate verification is always enforced for security. "
"Please remove this parameter from your code."
)

ssl_context = ssl.create_default_context(
cafile=certifi.where(),
) # 使用 certifi 提供的 CA 证书
connector = aiohttp.TCPConnector(ssl=ssl_context)

try:
ssl_context = ssl.create_default_context(
cafile=certifi.where(),
) # 使用 certifi 提供的 CA 证书
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(
trust_env=True,
connector=connector,
Expand All @@ -316,35 +322,19 @@ async def download_file(
show_progress,
progress_callback,
)
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
if not allow_insecure_ssl_fallback:
raise
# 关闭SSL验证(仅在证书验证失败时作为fallback)
logger.warning(
except aiohttp.ClientConnectorSSLError as e:
logger.error(
f"SSL certificate verification failed for {_safe_url_for_log(url)}. "
"Falling back to unverified connection (CERT_NONE). "
"Please check the server's SSL certificate configuration."
)
logger.warning(
f"SSL certificate verification failed for {_safe_url_for_log(url)}. "
"Falling back to unverified connection (CERT_NONE). "
"This is insecure and exposes the application to man-in-the-middle attacks. "
"Please investigate certificate issues with the remote server."
raise
except aiohttp.ClientConnectorCertificateError as e:
logger.error(
f"SSL certificate error for {_safe_url_for_log(url)}. "
"Certificate may be expired or invalid."
)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession() as session:
async with session.get(url, ssl=ssl_context, timeout=120) as resp:
_raise_for_download_status(resp, url)
with open(path, "wb") as f:
await _download_response_to_file(
resp,
f,
url,
show_progress,
progress_callback,
show_downloading_label=False,
)
raise

if show_progress:
print()

Expand Down Expand Up @@ -528,12 +518,19 @@ async def download_dashboard(
proxy: Optional download proxy prefix.
progress_callback: Optional callback for download progress payloads.
extract: Whether to extract the archive after download.
allow_insecure_ssl_fallback: Whether certificate failures may retry with
TLS certificate verification disabled.
allow_insecure_ssl_fallback: Deprecated parameter, ignored. SSL certificate
verification is always enforced for security.

Returns:
None.
"""
if allow_insecure_ssl_fallback is not True:
logger.warning(
"Parameter 'allow_insecure_ssl_fallback' is deprecated and ignored. "
"SSL certificate verification is always enforced for security. "
"Please remove this parameter from your code."
)

if path is None:
zip_path = Path(get_astrbot_data_path()).absolute() / "dashboard.zip"
else:
Expand Down
16 changes: 9 additions & 7 deletions tests/unit/test_io_download_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,25 +70,27 @@ async def test_download_file_rejects_non_200_response(monkeypatch, tmp_path):


@pytest.mark.asyncio
async def test_download_file_rejects_non_200_response_after_ssl_fallback(
async def test_download_file_raises_ssl_error_immediately(
monkeypatch,
tmp_path,
):
"""Test that SSL errors are raised immediately without fallback retry.

This test validates the fix for Issue #9446 - SSL verification should not
be downgraded when certificate validation fails.
"""
class FakeSSLError(Exception):
pass

target_path = tmp_path / "missing.bin"
_patch_download_sessions(
_patch_download_session(
monkeypatch,
[
FakeSSLError(),
_FakeResponse(status=404, chunks=[b"not found"]),
],
FakeSSLError(),
)
monkeypatch.setattr(io.aiohttp, "ClientConnectorSSLError", FakeSSLError)
monkeypatch.setattr(io.aiohttp, "ClientConnectorCertificateError", FakeSSLError)

with pytest.raises(io.DownloadFileHTTPError, match="HTTP status code: 404"):
with pytest.raises(FakeSSLError):
await io.download_file("https://example.test/missing", str(target_path))

assert not target_path.exists()
Expand Down
Loading