Skip to content

Security fix#514

Open
ssh4net wants to merge 4 commits intorustdesk:mainfrom
ssh4net:Security
Open

Security fix#514
ssh4net wants to merge 4 commits intorustdesk:mainfrom
ssh4net:Security

Conversation

@ssh4net
Copy link
Copy Markdown

@ssh4net ssh4net commented Apr 2, 2026

This is a part of RustDesk client security hardening PR:

Summary

This PR adds the shared transport and bootstrap changes needed for a stricter client trust model.

Main changes:

  • add an explicit bootstrap config layer for fixed infrastructure and trust roots
  • remove legacy rendezvous fallback paths when no explicit bootstrap or user config is present
  • add bootstrap trusted-peers support for preseeded peer identity trust
  • encrypt pairing passphrase options at rest
  • cap framed packet decoding to 64 MiB
  • expose secure transport detection used by the client to avoid fail-open behavior

Why

The previous client behavior mixed mutable runtime config with implicit fallback behavior. In practice, that meant:

  • an unconfigured client could still slide onto public infrastructure paths
  • remote config and cached state had too much influence over endpoint selection
  • pairing passphrases were stored too loosely
  • framed transport parsing accepted attacker-chosen allocation sizes

This PR moves the shared layer toward explicit trust and fail-closed behavior.

Design Decisions

1. Separate bootstrap trust roots from mutable runtime options

BootstrapConfig is loaded from a dedicated -bootstrap file and is intended for values that should not silently drift at runtime:

  • rendezvous server list
  • server signing key
  • API server
  • update server
  • trusted peer signing keys

That keeps deployer-provided trust roots separate from ordinary mutable option state.

2. Remove implicit rendezvous fallback

When no explicit bootstrap or user-provided rendezvous server exists, shared config resolution now stays empty instead of reintroducing legacy fallback paths.

This is important for:

  • local-only deployments
  • private/self-hosted distributions
  • fail-closed behavior when config is incomplete

3. Preseed peer trust with bootstrap trusted-peers

Bootstrap trusted-peers allows a distributor to ship known peer signing keys up front. This supports first-contact trust without depending only on interactive trust-on-first-use.

4. Bound framed message size

The frame decoder now rejects oversized packets before reserving memory. This is a direct hardening step against remote memory exhaustion.

5. Encrypt pairing passphrases at rest

Pairing passphrase options are now treated like other protected secrets and stored encrypted rather than plaintext.

Operational Impact

This PR changes shared config behavior in an intentional way:

  • explicit bootstrap config is now the preferred way to ship fixed infrastructure
  • absence of explicit endpoint config no longer implies a default rendezvous path
  • client-side peer trust can now be preseeded from bootstrap config

Compatibility

This PR is intended to be consumed together with the matching rustdesk-client security PR. The client PR uses:

  • bootstrap trusted peers
  • stricter endpoint resolution
  • encrypted pairing passphrase storage
  • secure transport/fail-closed helpers

Example Bootstrap Config

The matching client PR adds an example bootstrap file showing the intended packaging model:

  • rendezvous-servers
  • key
  • api-server
  • update-server
  • [trusted-peers]

Testing

Verified in the client workspace against the matching branch with:

  • targeted bootstrap resolution checks
  • framed packet limit tests
  • client compile/build validation against the updated shared API

Summary by CodeRabbit

  • New Features

    • Bootstrap configuration system for centralized server, API and update settings.
    • Configuration options transparently encrypt/decrypt stored values.
    • New checks to detect whether a connection/transport is secure.
    • Protocols now accept a licence key in peer/registration/online requests.
  • Bug Fixes

    • Enforced a 64 MB default packet size limit to reject oversized packets.
  • Behavior Changes

    • Version/update and WebSocket scheme now prefer bootstrap-configured servers.
    • New peer/trust and pairing option keys added.

add explicit bootstrap trust config and fail-closed framing
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 692e7ddf-1fac-4094-8b66-5a4eed314102

📥 Commits

Reviewing files that changed from the base of the PR and between f5032b1 and 26aae29.

📒 Files selected for processing (1)
  • protos/rendezvous.proto

📝 Walkthrough

Walkthrough

Added bootstrap configuration and getters, wired bootstrap values into rendezvous/API/update resolution; options now support encrypted storage/decryption; added transport-security detection APIs; introduced a 64 MiB default packet-length cap and related tests; updated protobufs to carry a licence_key and new register results.

Changes

Cohort / File(s) Summary
Bootstrap configuration & option handling
src/config.rs
Add BootstrapConfig and global BOOTSTRAP_CONFIG: RwLock<...>, bootstrap_file() and get_bootstrap_* getters, test setter. Options now encrypt/decrypt on set/get and enforce size limits. Removed RENDEZVOUS_SERVERS/RS_PUB_KEY. Added sign_pk to DiscoveryPeer and new option keys in keys.
Endpoint integration / version check
src/lib.rs
Replace hardcoded update URL with Config::get_bootstrap_update_server() in version_check_request.
WebSocket endpoint & TLS detection
src/websocket.rs
check_ws() now prefers bootstrap API server for scheme selection before falling back to option; added WsFramedStream::has_tls_transport() to report TLS/Rustls backing.
Stream security API
src/stream.rs
Added Stream::has_secure_transport(&self) -> bool (WebRTC true, WebSocket delegates, TCP false).
Packet-length validation & tests
src/bytes_codec.rs
Introduce const DEFAULT_MAX_PACKET_LENGTH: usize = 64 * 1024 * 1024; BytesCodec::new() uses this default instead of usize::MAX. Add encode_head_only(len) test helper and unit tests that decode() errors when header length exceeds default and custom limits.
Protocol changes
protos/rendezvous.proto
Added string licence_key to RegisterPeer, TestNatRequest, RegisterPk, OnlineRequest; extended RegisterPkResponse.Result with LICENSE_MISMATCH and PEER_LIMIT_REACHED.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰
I hopped through configs, neat and spry,
Found bootstrap seeds beneath the sky,
I checked for TLS on every stream,
I bounded packet limits—no extreme,
A little rabbit, coding by. 🥕

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Security fix' is vague and generic, using non-descriptive terms that fail to convey meaningful information about the specific changes in the pull request. Provide a more specific title that reflects the main changes, such as 'Add bootstrap config and packet length limits' or 'Introduce bootstrap configuration and security hardening'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/bytes_codec.rs (1)

302-315: Consider adding a boundary acceptance test.

The rejection tests are good. For completeness, consider adding a test that verifies a packet exactly at DEFAULT_MAX_PACKET_LENGTH is accepted (returns Ok(None) since there's no payload). This would confirm the boundary is inclusive.

🧪 Optional boundary test
+    #[test]
+    fn test_codec_accepts_packet_at_default_limit() {
+        let mut codec = BytesCodec::new();
+        let mut buf = encode_head_only(DEFAULT_MAX_PACKET_LENGTH);
+        // Should succeed (returns Ok(None) since payload is missing)
+        assert!(codec.decode(&mut buf).is_ok());
+    }
+
     #[test]
     fn test_codec_rejects_packet_above_default_limit() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bytes_codec.rs` around lines 302 - 315, Add a boundary acceptance test
that verifies a packet with a header size exactly equal to
DEFAULT_MAX_PACKET_LENGTH is accepted: create a BytesCodec via
BytesCodec::new(), prepare a buffer with
encode_head_only(DEFAULT_MAX_PACKET_LENGTH), call codec.decode(&mut buf) and
assert it returns Ok(None) (no payload). Optionally add a second case for the
custom limit by calling codec.set_max_packet_length(16) and testing
encode_head_only(16) similarly; reference BytesCodec, DEFAULT_MAX_PACKET_LENGTH,
set_max_packet_length, encode_head_only, and decode to locate where to add the
test.
src/websocket.rs (1)

390-400: Add test coverage for bootstrap api_server WebSocket scheme selection.

The bootstrap-first api_server resolution is correctly implemented. However, the existing test_check_ws function does not verify that bootstrap api_server takes precedence over the fallback api-server option.

Consider adding a test case using Config::set_bootstrap_config_for_test() with an https api_server to ensure the WebSocket scheme respects bootstrap priority:

♻️ Suggested test addition
// In test_check_ws, add a test for bootstrap api_server:
let saved_bootstrap = Config::BOOTSTRAP_CONFIG.read().unwrap().clone();
Config::set_bootstrap_config_for_test(BootstrapConfig {
    api_server: "https://bootstrap.example.com".to_owned(),
    ..Default::default()
});
Config::set_option("api-server".to_string(), "http://fallback.example.com".to_string());
assert_eq!(check_ws("rustdesk.com:21116"), "wss://rustdesk.com/ws/id");
// Restore original bootstrap config
Config::set_bootstrap_config_for_test(saved_bootstrap);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/websocket.rs` around lines 390 - 400, Add a unit test in test_check_ws
that verifies bootstrap config overrides the fallback option: save the current
Config::BOOTSTRAP_CONFIG, call Config::set_bootstrap_config_for_test(...) with
api_server = "https://bootstrap.example.com", set the fallback via
Config::set_option("api-server", "http://fallback.example.com"), then call
check_ws("rustdesk.com:21116") and assert it returns the
"wss://rustdesk.com/ws/id" result, and finally restore the saved bootstrap
config; this ensures the WebSocket scheme selection logic (the api_server
resolution used in check_ws) prefers bootstrap settings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/config.rs`:
- Around line 1308-1328: The code silently drops long passphrases because
encrypt_str_or_original returns an empty string for inputs beyond
ENCRYPT_MAX_LEN; update maybe_encrypt_option_value (and/or the caller
set_option) to validate length and handle encryption failures: if
is_encrypted_option(k) and v is non-empty, first check v.len() against
ENCRYPT_MAX_LEN and return an error or trim with a clear log/warning to the
user; after calling encrypt_str_or_original, detect an empty return as an
encryption failure and log an error/propagate a Result rather than replacing the
value silently. Reference functions: maybe_encrypt_option_value,
maybe_decrypt_option_value, is_encrypted_option, encrypt_str_or_original, and
set_option when adding validation/error propagation.

---

Nitpick comments:
In `@src/bytes_codec.rs`:
- Around line 302-315: Add a boundary acceptance test that verifies a packet
with a header size exactly equal to DEFAULT_MAX_PACKET_LENGTH is accepted:
create a BytesCodec via BytesCodec::new(), prepare a buffer with
encode_head_only(DEFAULT_MAX_PACKET_LENGTH), call codec.decode(&mut buf) and
assert it returns Ok(None) (no payload). Optionally add a second case for the
custom limit by calling codec.set_max_packet_length(16) and testing
encode_head_only(16) similarly; reference BytesCodec, DEFAULT_MAX_PACKET_LENGTH,
set_max_packet_length, encode_head_only, and decode to locate where to add the
test.

In `@src/websocket.rs`:
- Around line 390-400: Add a unit test in test_check_ws that verifies bootstrap
config overrides the fallback option: save the current Config::BOOTSTRAP_CONFIG,
call Config::set_bootstrap_config_for_test(...) with api_server =
"https://bootstrap.example.com", set the fallback via
Config::set_option("api-server", "http://fallback.example.com"), then call
check_ws("rustdesk.com:21116") and assert it returns the
"wss://rustdesk.com/ws/id" result, and finally restore the saved bootstrap
config; this ensures the WebSocket scheme selection logic (the api_server
resolution used in check_ws) prefers bootstrap settings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e7ff9eb-a0da-4182-9dee-3b0d0cb64a9d

📥 Commits

Reviewing files that changed from the base of the PR and between f08ce5d and 192cab4.

📒 Files selected for processing (5)
  • src/bytes_codec.rs
  • src/config.rs
  • src/lib.rs
  • src/stream.rs
  • src/websocket.rs

Comment thread src/config.rs
Signed-off-by: Vlad (Kuzmin) Erium <libalias@gmail.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/config.rs (1)

1255-1274: ⚠️ Potential issue | 🟠 Major

Avoid stale snapshot when restoring rejected encrypted options.

Line 1255 snapshots previous_options under a read lock, but reconciliation happens later; a concurrent set_option() can update a key in between and then get overwritten with stale data. Reconcile rejected keys against config.options after acquiring the write lock.

💡 Suggested fix
 pub fn set_options(mut v: HashMap<String, String>) {
     Self::purify_options(&mut v);
-    let previous_options = CONFIG2.read().unwrap().options.clone();
     let mut rejected_keys = Vec::new();
     for (key, value) in v.iter_mut() {
         if !Self::maybe_encrypt_option_value(key, value) {
             rejected_keys.push(key.clone());
         }
     }
-    for key in rejected_keys {
-        if let Some(old) = previous_options.get(&key) {
-            v.insert(key, old.clone());
-        } else {
-            v.remove(&key);
-        }
-    }
     let mut config = CONFIG2.write().unwrap();
+    for key in rejected_keys {
+        if let Some(old) = config.options.get(&key) {
+            v.insert(key, old.clone());
+        } else {
+            v.remove(&key);
+        }
+    }
     if config.options == v {
         return;
     }
     config.options = v;
     config.store();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 1255 - 1274, The snapshot of previous_options
taken from CONFIG2.read() is stale during reconciliation; change the flow to
acquire CONFIG2.write() before reconciling rejected_keys so you reconcile
against the live config.options instead of the earlier snapshot. Specifically:
keep building v and rejected_keys using Self::maybe_encrypt_option_value, then
acquire let mut config = CONFIG2.write().unwrap() and use config.options as the
source of truth when restoring values for keys in rejected_keys (update v from
config.options or remove if absent), then compare config.options == v, assign
config.options = v and call config.store(). Ensure references to
previous_options/read lock are removed and set_option race conditions are
avoided by holding the write lock during reconciliation.
🧹 Nitpick comments (2)
src/config.rs (2)

923-924: Use the existing key constant instead of string literals.

"custom-rendezvous-server" is duplicated here while keys::OPTION_CUSTOM_RENDEZVOUS_SERVER already exists. Using the constant avoids drift/typos.

♻️ Suggested cleanup
-            rendezvous_server = Self::get_option("custom-rendezvous-server");
+            rendezvous_server = Self::get_option(keys::OPTION_CUSTOM_RENDEZVOUS_SERVER);
...
-        let s = Self::get_option("custom-rendezvous-server");
+        let s = Self::get_option(keys::OPTION_CUSTOM_RENDEZVOUS_SERVER);

Also applies to: 940-941

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 923 - 924, Replace the duplicated string literal
"custom-rendezvous-server" with the existing constant
keys::OPTION_CUSTOM_RENDEZVOUS_SERVER wherever it’s used (e.g., the call
rendezvous_server = Self::get_option("custom-rendezvous-server")). Update all
occurrences (also the similar use around the other block noted) to call
Self::get_option(keys::OPTION_CUSTOM_RENDEZVOUS_SERVER) so the code uses the
canonical constant and avoids drift/typos.

3562-3634: Make test global-state restoration panic-safe.

These tests restore globals only at the end; if an assertion fails earlier, later tests can inherit mutated global state. Wrap restoration in a drop guard to guarantee cleanup.

Also applies to: 3638-3654, 3658-3676

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 3562 - 3634, The test mutates globals
(BOOTSTRAP_CONFIG/EXE_RENDEZVOUS_SERVER/PROD_RENDEZVOUS_SERVER/CONFIG2) and
currently restores them only at the end, which can leak state on panic; fix by
creating a small RAII drop guard (e.g., RestoreGuard) that captures
saved_bootstrap, saved_exe, saved_prod, saved_config2 and implements Drop to
restore them (calling Config::set_bootstrap_config_for_test and writing back the
other globals). Instantiate this guard before you mutate any globals (before
calling Config::set_bootstrap_config_for_test with the test bootstrap and before
modifying EXE_RENDEZVOUS_SERVER/PROD_RENDEZVOUS_SERVER/CONFIG2) so restoration
happens even if assertions panic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/config.rs`:
- Around line 1255-1274: The snapshot of previous_options taken from
CONFIG2.read() is stale during reconciliation; change the flow to acquire
CONFIG2.write() before reconciling rejected_keys so you reconcile against the
live config.options instead of the earlier snapshot. Specifically: keep building
v and rejected_keys using Self::maybe_encrypt_option_value, then acquire let mut
config = CONFIG2.write().unwrap() and use config.options as the source of truth
when restoring values for keys in rejected_keys (update v from config.options or
remove if absent), then compare config.options == v, assign config.options = v
and call config.store(). Ensure references to previous_options/read lock are
removed and set_option race conditions are avoided by holding the write lock
during reconciliation.

---

Nitpick comments:
In `@src/config.rs`:
- Around line 923-924: Replace the duplicated string literal
"custom-rendezvous-server" with the existing constant
keys::OPTION_CUSTOM_RENDEZVOUS_SERVER wherever it’s used (e.g., the call
rendezvous_server = Self::get_option("custom-rendezvous-server")). Update all
occurrences (also the similar use around the other block noted) to call
Self::get_option(keys::OPTION_CUSTOM_RENDEZVOUS_SERVER) so the code uses the
canonical constant and avoids drift/typos.
- Around line 3562-3634: The test mutates globals
(BOOTSTRAP_CONFIG/EXE_RENDEZVOUS_SERVER/PROD_RENDEZVOUS_SERVER/CONFIG2) and
currently restores them only at the end, which can leak state on panic; fix by
creating a small RAII drop guard (e.g., RestoreGuard) that captures
saved_bootstrap, saved_exe, saved_prod, saved_config2 and implements Drop to
restore them (calling Config::set_bootstrap_config_for_test and writing back the
other globals). Instantiate this guard before you mutate any globals (before
calling Config::set_bootstrap_config_for_test with the test bootstrap and before
modifying EXE_RENDEZVOUS_SERVER/PROD_RENDEZVOUS_SERVER/CONFIG2) so restoration
happens even if assertions panic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 96291512-dab8-4d13-8a32-36295a09042c

📥 Commits

Reviewing files that changed from the base of the PR and between 192cab4 and f5032b1.

📒 Files selected for processing (1)
  • src/config.rs

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