Skip to content

fix(updater): only install app updates from our own release assets - #528

Merged
thcp merged 1 commit into
0.16.1from
fix/510-updater-url-allowlist
Aug 31, 2026
Merged

fix(updater): only install app updates from our own release assets#528
thcp merged 1 commit into
0.16.1from
fix/510-updater-url-allowlist

Conversation

@thcp

@thcp thcp commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes #510.

The problem

download_app_update took plan.app_url straight from the WebView and verified against plan.app_sha256 from the same caller. A checksum supplied alongside the URL proves the bytes arrived intact, not that they came from us -- an attacker supplies both. apply_app_update then extracts that archive over StemDeck.exe and backend/ and relaunches.

Reachability

Narrower than "remote code execution", but real:

  • In the normal flow the URL is appAsset.browser_download_url from the GitHub API
  • No XSS was found in the frontend audit; CSP is script-src 'self' with no unsafe-inline
  • However, catalog.js:2386-2393 documents that the page is served over http by the Python backend -- a remote origin to Tauri -- and that these app-defined commands are not ACL-gated by the capability config. A LAN attacker reaches this once "Make StemDeck available on your network" is on.

So: missing defence-in-depth on a code-execution path.

Change

const RELEASE_ASSET_HOSTS: [&str; 2] = ["github.com", "objects.githubusercontent.com"];

fn validate_release_url(url: &str) -> Result<(), String> { ... }

Both hosts are needed -- GitHub redirects release assets to objects.githubusercontent.com. https is required, so bytes cannot be swapped in flight on a network where the page itself is already plain http.

Deliberately a new function, not validate_download_url. #510 originally suggested reusing that one; it permits only 127.0.0.1/localhost, serves a different caller, and would reject every legitimate release URL.

apply_app_update re-verifies. It trusted that whatever sat at the archive path was what download_app_update approved, so anything able to write into data/downloads between the two calls was extracted unchecked. The verified digest is recorded beside the archive and re-checked before extraction.

Verification

New test only_our_own_release_assets_are_downloadable_as_updates, covering host lookalikes a naive substring check would pass:

github.com.evil.example   rejected
notgithub.com             rejected
http://github.com/...     rejected (scheme)
file:///etc/passwd        rejected
cargo fmt --check   OK
cargo clippy        0 errors
cargo test          58 passed, 1 failed

The 1 failure is tests::a_free_port_is_granted_as_asked. It fails 3/3 on 0.16.1 without this change too -- verified by checking out the base branch and running the full suite three times. It is the known parallel-execution flake: it probes port 21000 with std::net::TcpListener (which sets SO_REUSEADDR) then asserts claim_port (socket2, no SO_REUSEADDR) binds the same port. Currently failing 100% on this machine; unrelated to this PR and not filed.

Left for a follow-up

fetch_text (main.rs:943-966) will still GET any URL the WebView passes as runtimeIdUrl/appShaUrl. Weaker than this -- the body is not returned on success, only reachability and parse outcome leak -- but it is the same class and would suit the same allowlist. Kept out of scope here.

@thcp
thcp marked this pull request as ready for review August 31, 2026 21:03
download_app_update took its URL straight from the WebView and verified the
download against a SHA-256 supplied by the same caller, so the checksum proved
the bytes arrived intact -- not that they came from us. apply_app_update then
extracts that archive over StemDeck's own executable and backend/ and
relaunches.

Reachability is narrower than it first looks: in the normal flow the URL is
appAsset.browser_download_url from the GitHub API, no XSS was found, and the
CSP is script-src 'self'. But the page is served over http by the Python
backend, which Tauri treats as a remote origin, and these app-defined commands
are not ACL-gated by the capability config -- both facts the code already
documents. A LAN attacker reaches this once network access is enabled.

validate_release_url pins the host to github.com and
objects.githubusercontent.com (GitHub redirects release assets to the latter)
and requires https, so bytes cannot be swapped in flight on a network where
the page itself is already plain http.

Deliberately a new function rather than the existing validate_download_url:
that one permits only 127.0.0.1/localhost, serves a different caller, and
would reject every legitimate release URL.

apply_app_update also re-verifies now. It trusted that whatever sat at the
archive path was what download_app_update had approved, so anything able to
write into data/downloads between the two calls was extracted unchecked. The
verified digest is recorded next to the archive and re-checked before
extraction.

The test covers host lookalikes (github.com.evil.example, notgithub.com) as
well as plain rejection, since a substring check would pass those.

Refs #510
@thcp
thcp force-pushed the fix/510-updater-url-allowlist branch from 6a05da5 to a404c7f Compare August 31, 2026 21:05
@thcp
thcp merged commit d23dc8b into 0.16.1 Aug 31, 2026
10 checks passed
@thcp
thcp deleted the fix/510-updater-url-allowlist branch August 31, 2026 21:09
thcp added a commit that referenced this pull request Aug 31, 2026
#529)

Fixes #516. **Targets `fix/510-updater-url-allowlist`** -- both touch
`main.rs` and the plan sequences the Rust changes. Merge #528 first.

## The problem

`child_output_with_timeout` read the pipes only after `try_wait()`
reported an exit:

```rust
loop {
    if let Some(status) = child.try_wait()? {
        // read_to_end on stdout/stderr -- only reachable once the child is gone
    }
    thread::sleep(Duration::from_millis(100));
}
```

A child that outruns the OS pipe buffer blocks in `write()` with nobody
reading, so it never exits, so `try_wait()` never reports an exit. The
call ends at the timeout with no output.

`warmup_models` pipes both streams (`main.rs:1414-1415`) and its model
downloads emit tqdm progress to stderr in proportion to how **long**
they take, not how large they are. The failure therefore lands on slow
connections -- exactly the users warmup exists to spare a mid-pipeline
download.

## Change

Each stream drains on its own thread. Both are required: draining one
then the other reintroduces the deadlock on whichever is second. Readers
are joined rather than detached on the timeout path, since killing the
child closes its ends and dropping the handles would leak two threads
per timeout.

`command_output_with_timeout` had the identical shape and now delegates,
so the two cannot drift apart again.

## Verification -- the deadlock is reproduced, not assumed

New test `a_chatty_child_is_drained_rather_than_deadlocked` writes 512
KiB to each stream.

| implementation | result |
|---|---|
| read-after-exit (old) | **FAILED** -- `"chatty child timed out after
20 seconds"`, took 20.09s |
| concurrent draining (this PR) | passed in 0.11s |

Worth recording: when I filed #516 I measured a real cold-cache warmup
on a fast connection and found only **8,360 bytes** of stderr -- under
the buffer. That is why this had not been hit in practice, not why it
could not happen. The test settles it.

```
cargo fmt --check   OK
cargo clippy        0 errors
cargo test          59 passed, 1 failed
```

The 1 failure is `tests::a_free_port_is_granted_as_asked`, the known
parallel-execution flake, failing 3/3 on the base branch without this
change.

## One thing to check in review

While rewriting `command_output_with_timeout` I initially sliced out the
`#[cfg(windows)]` attribute above `hide_console_window`, which `cargo
check` caught immediately. Restored, and both `hide_console_window`
definitions keep their cfg guards -- worth a glance since a
Linux/macOS-only compile would not exercise the Windows one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant