Skip to content

fix: secure project update temp staging#9083

Draft
LIghtJUNction wants to merge 3 commits into
masterfrom
codex/fix-vulnerability-in-update-archives-handling
Draft

fix: secure project update temp staging#9083
LIghtJUNction wants to merge 3 commits into
masterfrom
codex/fix-vulnerability-in-update-archives-handling

Conversation

@LIghtJUNction

@LIghtJUNction LIghtJUNction commented Jun 30, 2026

Copy link
Copy Markdown
Member

Motivation

  • Prevent a local TOCTOU/temp-directory hijack where update archives were staged under a shared system temp path (get_astrbot_system_tmp_path) that could be pre-created or symlinked by an attacker, allowing replacement of verified ZIPs before application.

Description

  • Replace staging under the shared system temp path with per-update temporary directories created inside AstrBot's data temp directory via get_astrbot_temp_path() and tempfile.TemporaryDirectory so each update run gets an isolated directory.
  • Ensure the updates parent directory is created with restrictive permissions using mkdir(mode=0o700, ...) and chmod(0o700) before creating the per-update temp dir, and place dashboard/core ZIPs inside that per-update directory.
  • Add import tempfile and adjust the _run_update_project flow to use the scoped TemporaryDirectory (automatic cleanup) and remove the previous manual finally ZIP cleanup block while preserving the download, verification (zipfile.testzip()), apply, extraction, dependency install, and restart behavior.

Testing

  • Ran uv run --no-sync ruff format . and uv run --no-sync ruff check . and they passed.
  • Compiled the modified module with uv run --no-sync python -m py_compile astrbot/dashboard/services/update_service.py and it succeeded.
  • Ran git diff --check to validate no leftover whitespace/errors and it passed.

Codex Task

Summary by Sourcery

Secure the project update staging process by isolating update archives in per-run temporary directories under AstrBot’s data temp path and tightening permissions.

Bug Fixes:

  • Prevent potential TOCTOU and temp-directory hijack by avoiding shared system temp paths for update archives.
  • Eliminate reliance on manual cleanup of update ZIP files by using scoped temporary directories with automatic cleanup.

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. feature:updater The bug / feature is about astrbot updater system labels Jun 30, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The core update download progress callback now uses self._make_progress_callback(progress_id, "core", 45, 45), which prevents the overall percentage from advancing beyond 45; consider restoring the original end value (e.g., 90) for correct progress reporting.
  • You unconditionally chmod the updates parent directory to 0o700 after mkdir(exist_ok=True), which could unexpectedly change permissions on a pre-existing directory; if that's a concern, consider differentiating between newly created and existing directories or validating ownership/perms before chmod.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The core update download progress callback now uses `self._make_progress_callback(progress_id, "core", 45, 45)`, which prevents the overall percentage from advancing beyond 45; consider restoring the original end value (e.g., 90) for correct progress reporting.
- You unconditionally chmod the `updates` parent directory to `0o700` after `mkdir(exist_ok=True)`, which could unexpectedly change permissions on a pre-existing directory; if that's a concern, consider differentiating between newly created and existing directories or validating ownership/perms before chmod.

## Individual Comments

### Comment 1
<location path="astrbot/dashboard/services/update_service.py" line_range="202" />
<code_context>
-                progress_callback=self._make_progress_callback(
+            update_temp_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+            update_temp_parent.chmod(0o700)
+            with tempfile.TemporaryDirectory(
+                prefix="project-update-",
+                dir=update_temp_parent,
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the verification logic and the whole download–verify–apply update workflow into separate helper methods to reduce nesting inside `_run_update_project` while preserving current behavior.

The added `TemporaryDirectory` context and inner `_verify_update_packages` do increase local nesting. You can keep all behavior (including secure temp dir and automatic cleanup) while reducing complexity by (1) extracting the verification helper, and (2) moving the monolithic update sequence into a dedicated helper that’s called from inside the `with` block.

**1. Move `_verify_update_packages` out of `_run_update_project`**

Make it a small private method; this trims the body of `_run_update_project` and avoids inner function nesting:

```python
def _verify_update_packages(self, dashboard_zip_path: Path, core_zip_path: Path) -> None:
    for zip_path in (dashboard_zip_path, core_zip_path):
        with zipfile.ZipFile(zip_path, "r") as archive:
            corrupt_member = archive.testzip()
        if corrupt_member:
            raise UpdateServiceError(f"更新包校验失败: {corrupt_member}")
```

Usage in `_run_update_project`:

```python
self._set_update_stage(progress_id, "verify", "running", "下载完成,正在校验更新包...", 90)
await asyncio.to_thread(self._verify_update_packages, dashboard_zip_path, core_zip_path)
self._set_update_stage(progress_id, "verify", "done", "更新包校验完成。", 91)
```

**2. Extract the update sequence into a helper called from the temp-dir context**

Keep `TemporaryDirectory` but reduce indentation by moving the “download → verify → apply → dependencies → reboot” pipeline into a separate method. The `with` block only sets up paths and calls the helper.

```python
async def _perform_project_update(
    self,
    progress_id: str,
    dashboard_zip_path: Path,
    core_zip_path: Path,
    latest: bool,
    version: str,
    proxy: str,
    reboot: bool,
) -> None:
    self._set_update_stage(progress_id, "dashboard", "running", "正在下载 WebUI...", 0)
    await self.download_dashboard(
        path=str(dashboard_zip_path),
        latest=latest,
        version=version,
        proxy=proxy,
        progress_callback=self._make_progress_callback(progress_id, "dashboard", 0, 45),
        extract=False,
    )
    self._set_update_stage(progress_id, "dashboard", "done", "WebUI 下载完成。", 45)

    self._set_update_stage(progress_id, "core", "running", "正在下载 AstrBot 项目代码...", 45)
    core_zip_actual = Path(
        await self.astrbot_updator.download_update_package(
            latest=latest,
            version=version,
            proxy=proxy,
            path=core_zip_path,
            progress_callback=self._make_progress_callback(progress_id, "core", 45, 45),
        )
    )
    core_zip_path = core_zip_actual  # keep existing behavior

    # ... verify, apply, dependencies, reboot and progress updates as in current code ...
```

Then `_run_update_project` becomes:

```python
async def _run_update_project(
    self,
    progress_id: str,
    version: str,
    latest: bool,
    reboot: bool,
    proxy: str | None,
) -> None:
    update_temp_parent = Path(get_astrbot_temp_path()) / "updates"
    try:
        update_temp_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
        update_temp_parent.chmod(0o700)
        with tempfile.TemporaryDirectory(
            prefix="project-update-",
            dir=update_temp_parent,
        ) as update_temp_dir_name:
            update_temp_dir = Path(update_temp_dir_name)
            update_token = uuid.uuid4().hex
            dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
            core_zip_path = update_temp_dir / f"{update_token}-core.zip"

            await self._perform_project_update(
                progress_id=progress_id,
                dashboard_zip_path=dashboard_zip_path,
                core_zip_path=core_zip_path,
                latest=latest,
                version=version,
                proxy=proxy or "",
                reboot=reboot,
            )
    except asyncio.CancelledError:
        ...
    except Exception as exc:
        ...
```

This keeps the secure temp-dir semantics and automatic cleanup, but flattens `_run_update_project` into “setup temp dir + call helper + error handling,” making the core workflow easier to read and test.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

progress_callback=self._make_progress_callback(
update_temp_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
update_temp_parent.chmod(0o700)
with tempfile.TemporaryDirectory(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the verification logic and the whole download–verify–apply update workflow into separate helper methods to reduce nesting inside _run_update_project while preserving current behavior.

The added TemporaryDirectory context and inner _verify_update_packages do increase local nesting. You can keep all behavior (including secure temp dir and automatic cleanup) while reducing complexity by (1) extracting the verification helper, and (2) moving the monolithic update sequence into a dedicated helper that’s called from inside the with block.

1. Move _verify_update_packages out of _run_update_project

Make it a small private method; this trims the body of _run_update_project and avoids inner function nesting:

def _verify_update_packages(self, dashboard_zip_path: Path, core_zip_path: Path) -> None:
    for zip_path in (dashboard_zip_path, core_zip_path):
        with zipfile.ZipFile(zip_path, "r") as archive:
            corrupt_member = archive.testzip()
        if corrupt_member:
            raise UpdateServiceError(f"更新包校验失败: {corrupt_member}")

Usage in _run_update_project:

self._set_update_stage(progress_id, "verify", "running", "下载完成,正在校验更新包...", 90)
await asyncio.to_thread(self._verify_update_packages, dashboard_zip_path, core_zip_path)
self._set_update_stage(progress_id, "verify", "done", "更新包校验完成。", 91)

2. Extract the update sequence into a helper called from the temp-dir context

Keep TemporaryDirectory but reduce indentation by moving the “download → verify → apply → dependencies → reboot” pipeline into a separate method. The with block only sets up paths and calls the helper.

async def _perform_project_update(
    self,
    progress_id: str,
    dashboard_zip_path: Path,
    core_zip_path: Path,
    latest: bool,
    version: str,
    proxy: str,
    reboot: bool,
) -> None:
    self._set_update_stage(progress_id, "dashboard", "running", "正在下载 WebUI...", 0)
    await self.download_dashboard(
        path=str(dashboard_zip_path),
        latest=latest,
        version=version,
        proxy=proxy,
        progress_callback=self._make_progress_callback(progress_id, "dashboard", 0, 45),
        extract=False,
    )
    self._set_update_stage(progress_id, "dashboard", "done", "WebUI 下载完成。", 45)

    self._set_update_stage(progress_id, "core", "running", "正在下载 AstrBot 项目代码...", 45)
    core_zip_actual = Path(
        await self.astrbot_updator.download_update_package(
            latest=latest,
            version=version,
            proxy=proxy,
            path=core_zip_path,
            progress_callback=self._make_progress_callback(progress_id, "core", 45, 45),
        )
    )
    core_zip_path = core_zip_actual  # keep existing behavior

    # ... verify, apply, dependencies, reboot and progress updates as in current code ...

Then _run_update_project becomes:

async def _run_update_project(
    self,
    progress_id: str,
    version: str,
    latest: bool,
    reboot: bool,
    proxy: str | None,
) -> None:
    update_temp_parent = Path(get_astrbot_temp_path()) / "updates"
    try:
        update_temp_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
        update_temp_parent.chmod(0o700)
        with tempfile.TemporaryDirectory(
            prefix="project-update-",
            dir=update_temp_parent,
        ) as update_temp_dir_name:
            update_temp_dir = Path(update_temp_dir_name)
            update_token = uuid.uuid4().hex
            dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
            core_zip_path = update_temp_dir / f"{update_token}-core.zip"

            await self._perform_project_update(
                progress_id=progress_id,
                dashboard_zip_path=dashboard_zip_path,
                core_zip_path=core_zip_path,
                latest=latest,
                version=version,
                proxy=proxy or "",
                reboot=reboot,
            )
    except asyncio.CancelledError:
        ...
    except Exception as exc:
        ...

This keeps the secure temp-dir semantics and automatic cleanup, but flattens _run_update_project into “setup temp dir + call helper + error handling,” making the core workflow easier to read and test.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the update process in update_service.py to use tempfile.TemporaryDirectory for managing temporary update files securely and automatically. Feedback was provided regarding two main issues: a potential security vulnerability where update_temp_parent could be a symlink (meaning chmod would follow it and modify permissions of the target file/directory), and a potential reliability issue where automatic cleanup by tempfile.TemporaryDirectory might raise exceptions (e.g., due to file locks on Windows) and propagate, causing a successful update to be marked as failed. Explicitly handling cleanup in a try...finally block is recommended.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread astrbot/dashboard/services/update_service.py
"overall_percent": 100,
},
)
logger.info(message)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using with tempfile.TemporaryDirectory(...) means any exception raised during the automatic cleanup (e.g., due to file locks on Windows or antivirus interference) will propagate out of the block and be caught by the outer except Exception as exc block. This would mark a successful update as failed in the UI, leading to a bad user experience. Handling the cleanup explicitly in a try...finally block and catching any cleanup errors allows us to log them as warnings without failing the entire update process.

                logger.info(message)
            finally:
                try:
                    update_temp_dir_obj.cleanup()
                except Exception as cleanup_exc:
                    logger.warning(f"清理更新临时目录失败: {cleanup_exc}")

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
@LIghtJUNction

Copy link
Copy Markdown
Member Author

LGTM

@LIghtJUNction LIghtJUNction marked this pull request as draft June 30, 2026 05:52
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 30, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
astrbot-docs f6a1fc3 Commit Preview URL

Branch Preview URL
Jun 30 2026, 06:16 AM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

aardvark codex feature:updater The bug / feature is about astrbot updater system size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant