Skip to content

fix: secure project update temp staging#9083

Merged
Soulter merged 3 commits into
masterfrom
codex/fix-vulnerability-in-update-archives-handling
Jul 5, 2026
Merged

fix: secure project update temp staging#9083
Soulter merged 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 flow by isolating update archives in a per-run temporary directory under AstrBot’s data temp path with restricted permissions and relying on automatic cleanup.

Bug Fixes:

  • Eliminate a TOCTOU/temp-directory hijack risk by no longer staging update ZIPs under a shared system temp path that could be pre-created or symlinked by an attacker.

Enhancements:

  • Use AstrBot’s dedicated temp directory and TemporaryDirectory for per-update staging of dashboard and core archives, ensuring isolated, auto-cleaned workspaces for each run.

@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

@LIghtJUNction LIghtJUNction marked this pull request as ready for review July 4, 2026 11:59
Copilot AI review requested due to automatic review settings July 4, 2026 11:59

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@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 left some high level feedback:

  • The progress callback for the core package download now uses start=45, end=45, which prevents the overall progress from advancing during that stage; consider restoring the previous range (e.g. 45–90) so users see meaningful progress.
  • In the setup of update_temp_parent, only symlinks are unlinked; if an attacker or misconfiguration creates a regular file at that path, mkdir will fail—consider handling non-directory cases explicitly (e.g. unlinking or raising a clear error) before mkdir.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The progress callback for the core package download now uses `start=45, end=45`, which prevents the overall progress from advancing during that stage; consider restoring the previous range (e.g. 45–90) so users see meaningful progress.
- In the setup of `update_temp_parent`, only symlinks are unlinked; if an attacker or misconfiguration creates a regular file at that path, `mkdir` will fail—consider handling non-directory cases explicitly (e.g. unlinking or raising a clear error) before `mkdir`.

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.

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Jul 5, 2026
@Soulter Soulter merged commit 30426c4 into master Jul 5, 2026
22 checks passed
@Soulter Soulter deleted the codex/fix-vulnerability-in-update-archives-handling branch July 5, 2026 01:49
BegoniaHe pushed a commit to BegoniaHe/AstrBot that referenced this pull request Jul 6, 2026
* fix: secure project update temp staging

* Update astrbot/dashboard/services/update_service.py

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

* fix: close update staging temp context

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
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 lgtm This PR has been approved by a maintainer size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants