Skip to content

feat: Liquid Glass UI + Security Hardening + Bug Fixes#2481

Open
xjwm5685-ui wants to merge 2 commits intofarion1231:mainfrom
xjwm5685-ui:feat/liquid-glass-ui-security-fixes
Open

feat: Liquid Glass UI + Security Hardening + Bug Fixes#2481
xjwm5685-ui wants to merge 2 commits intofarion1231:mainfrom
xjwm5685-ui:feat/liquid-glass-ui-security-fixes

Conversation

@xjwm5685-ui
Copy link
Copy Markdown

Summary

UI Overhaul — Liquid Glass Design System

  • Translucent frosted glass surfaces with specular highlights across all components
  • Dynamic Island / macOS Dock app switcher with spring physics and magnification
  • Stacked bar chart usage dashboard (blue-purple gradient) replacing area chart
  • Model breakdown list with color dots and percentage bars (inspired by Claude Code Desktop)
  • Spring animations throughout: cards, dialogs, buttons, view transitions, staggered entrances
  • Responsive hover lift, press squish, and elastic micro-interactions
  • Two app switcher modes: Island (morphing pill) and Dock (3D magnification)

Bug Fixes

Security Hardening

  • AES-256-GCM encryption for API keys in SQLite (crypto.rs) — key derived from machine fingerprint
  • WebDAV password encryption in settings.json
  • Deep-link SSRF protection — block localhost, private IPs, CGN, link-local addresses
  • Input validation on deep-link name (≤256), API key (≤1024), repo field (whitelist chars)
  • SwitchLock TOCTOU fix — double-check pattern eliminates race window
  • cargo-audit CI step for dependency security scanning
  • CSP tightened — removed generic http: from connect-src

Refactoring

  • lib.rs setup extracted into named functions (init_database, import_initial_data, register_deeplink_handler, setup_tray)
  • Forwarder status updates extracted into record_success / record_failure helpers
  • Named proxy config constants replacing magic numbers in schema.rs

Build

  • Release profile optimized for speed: codegen-units=16, lto=false, opt-level=2 (~5-8x faster builds, slightly larger binary)

Test plan

  • Verify liquid glass renders correctly in both light and dark themes
  • Test Island/Dock mode toggle and persistence
  • Verify Responses 转换中 Read 工具可能携带空 pages 字段 #2471 fix: Responses API Read tool with pages: "" no longer errors
  • Verify "effortLevel": "high" 无论如何都不能保存为Max #2465 fix: effortLevel: "max" persists after save/reopen
  • Verify API keys are encrypted in SQLite (check with sqlite3 that settings_config is not plaintext)
  • Verify deep-link SSRF protection blocks ccswitch://import?provider[api_key]=test&provider[base_url]=http://127.0.0.1
  • Run cargo test and pnpm test
  • Build and verify .exe/.msi installers

## UI Overhaul
- Liquid Glass design system: translucent frosted glass surfaces with specular highlights
- Dynamic Island / macOS Dock app switcher with spring physics
- Stacked bar chart usage dashboard with blue-purple gradient
- Model breakdown list with color dots and percentage bars
- Spring animations throughout (cards, dialogs, buttons, view transitions)
- Responsive hover lift, press squish, and stagger entrance effects

## Bug Fixes
- fix(farion1231#2471): Strip empty `pages` field from Responses API tool_use input
- fix(farion1231#2465): effortLevel "max" not persisting — use deepFill for snippet re-application
- fix: Remove dead `update_proxy` function from http_client
- fix: body_filter log now shows all removed keys across entire request

## Security
- AES-256-GCM encryption for API keys in SQLite (crypto.rs)
- WebDAV password encryption in settings.json
- Deep-link SSRF protection (block localhost/private IPs)
- Input validation on deep-link name, API key, repo fields
- SwitchLock TOCTOU race condition fix
- cargo-audit CI step

## Refactoring
- Extract lib.rs setup into named functions (init_database, import_initial_data, etc.)
- Extract forwarder status update helpers (record_success, record_failure)
- Named proxy config constants replacing magic numbers
- CSP tightened (removed http: from connect-src)

## Build
- Release profile: codegen-units=16, lto=false, opt-level=2 (5-8x faster builds)
@farion1231
Copy link
Copy Markdown
Owner

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc00b82f60

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/src/crypto.rs Outdated
Comment on lines +35 to +36
pub fn is_encrypted_str(data: &str) -> bool {
is_encrypted(data.as_bytes())
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Detect encrypted strings after base64 decoding

encrypt stores ciphertext as base64 (CCS1+nonce+ciphertext encoded), but is_encrypted_str compares the raw UTF-8 string prefix to CCS1, which will always be false for valid ciphertext. As a result, all call sites that gate decryption on this helper (e.g. provider/settings reads) skip decryption, and write paths that avoid double-encrypting can repeatedly encrypt already-encrypted values; this breaks round-tripping of stored secrets and config JSON.

Useful? React with 👍 / 👎.

Comment thread src-tauri/src/deeplink/utils.rs Outdated
|| v4.octets()[0] == 0 // 0.0.0.0/8 (current network)
|| v4.octets()[0] == 100 && v4.octets()[1] >= 64 && v4.octets()[1] <= 127 // 100.64.0.0/10 (CGN)
|| v4.octets()[0] == 169 && v4.octets()[1] == 254 // 169.254.0.0/16 (link-local)
|| v4.octets()[0] == 198 && v4.octets()[1] == 18 || v4.octets()[1] == 19 // 198.18.0.0/15 (benchmark)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Parenthesize 198.18/15 IPv4 benchmark range check

The last clause in is_private_ip has an operator-precedence bug: v4.octets()[0] == 198 && v4.octets()[1] == 18 || v4.octets()[1] == 19 evaluates true for any IPv4 address with second octet 19, even when the first octet is not 198. This causes false SSRF blocking for legitimate literal hosts like 8.19.x.x instead of only blocking 198.18.0.0/15.

Useful? React with 👍 / 👎.

…etection, IPv4 range

- fix(ui): replace border-image with background-clip gradient border on .liquid-glass,
  .liquid-glass-active, .liquid-glass-emerald — border-image ignores border-radius,
  causing select/dropdown panels to render with rectangular borders
- feat(ui): add LiquidGlassFilter SVG component (opt-in displacement refraction filters)
- fix(crypto): is_encrypted_str now base64-decodes before checking the magic prefix,
  fixing false negatives when testing already-stored encrypted values (fixes farion1231#2471-P1)
- fix(deeplink): add parentheses around IPv4 198.18/15 range check to fix operator
  precedence bug where octet[1]==19 was OR'd with the whole expression (fixes SSRF P2)
- fix(skills): resolve skills install "目录不存在" on Windows by adding hyphen-bounded
  suffix matching — handles org-namespaced skill IDs like 'vercel-react-best-practices'
  mapping to repo dir 'react-best-practices' (fixes farion1231#2477)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@farion1231
Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@farion1231
Copy link
Copy Markdown
Owner

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 1, 2026

Claude finished @farion1231's task in 3m 9s —— View job


PR Review — feat: Liquid Glass UI + Security Hardening + Bug Fixes

Tasks

  • Gather context and understand request
  • Read security-critical files (crypto.rs, deeplink/utils.rs, switch_lock.rs)
  • Read proxy and settings changes
  • Read frontend/UI changes
  • Compile and post comprehensive review

Overview

This is a large PR (3348+/1555−, 45 files) combining a UI overhaul, encryption-at-rest for API keys, deep-link SSRF protection, and several bug fixes. The security work is solid in intent; a few gaps are flagged below.

Note on Codex review comments: Both issues Codex flagged on commit fc00b82 — the is_encrypted_str prefix check and the IPv4 operator precedence bug — are already fixed in commit efa5460. Those reports are stale and can be dismissed.


🔴 P1 — Issues to fix before merging

1. IPv4-mapped IPv6 SSRF bypassdeeplink/utils.rs:102–109

The IPv6 branch of is_private_ip doesn't cover IPv4-mapped addresses like ::ffff:127.0.0.1 or ::ffff:10.0.0.1. Rust's Ipv6Addr::is_loopback() returns true only for ::1, not for ::ffff:127.0.0.1. An attacker supplying http://[::ffff:127.0.0.1]/... as a deep-link endpoint would bypass all SSRF checks.

// Current IPv6 branch — misses IPv4-mapped addresses
IpAddr::V6(v6) => {
    v6.is_loopback()           // only ::1
    || v6.is_unique_local()    // fc00::/7
    || v6.is_unicast_link_local() // fe80::/10
    || *v6 == Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0)  // ::
    || *v6 == Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)  // ::1 (redundant)
}

Fix: add an IPv4-mapped check using v6.to_ipv4_mapped():

IpAddr::V6(v6) => {
    // Unwrap IPv4-mapped addresses (e.g. ::ffff:127.0.0.1) and check those too
    if let Some(v4) = v6.to_ipv4_mapped() {
        return is_private_ip(&IpAddr::V4(v4));
    }
    v6.is_loopback()
        || v6.is_unique_local()
        || v6.is_unicast_link_local()
        || *v6 == Ipv6Addr::UNSPECIFIED
}

Fix this →


2. Overly broad empty-string stripping in tool inputstransform_responses.rs:460–461

The fix for issue #2471 (pages: "") strips every empty-string field from every tool's input, not just the pages field:

// strips ANY "" value from ANY tool's input object
obj.retain(|_k, v| !v.as_str().is_some_and(|s| s.is_empty()));

This will silently drop legitimate empty-string arguments. For example, a code-editing tool with old_string: "foo" / new_string: "" (delete pattern) would have new_string stripped, producing an incorrect replacement. The stripping should either be scoped to the pages key specifically, or scoped to tools known to have this problem.

// More conservative: only strip `pages: ""`
obj.retain(|k, v| k != "pages" || !v.as_str().is_some_and(|s| s.is_empty()));

Fix this →


🟡 P2 — Should be addressed

3. Silent API key loss on hostname changeproviders.rs:74–76

When decrypt_settings_config_str fails (hostname changed, machine migrated, corrupt data), the provider row silently becomes settings_config = null:

let settings_config = decrypt_settings_config_str(&settings_config_str)
    .map(|s| serde_json::from_str(&s).unwrap_or(serde_json::Value::Null))
    .unwrap_or(serde_json::Value::Null);  // silent null on decrypt failure

A user who renames their machine will see all providers appear with empty configuration and no explanation. Recommend at least logging a log::warn! when the decrypt path returns None, so there's a trace in logs.


4. config_url deep-link parameter not validatedparser.rs:137

config_url is extracted and forwarded in the DeepLinkImportRequest struct without being passed to validate_url. If this URL is ever fetched (e.g., to auto-fill a provider config), it is an unvalidated SSRF vector arriving through a deep link. Suggest adding:

if let Some(ref url) = config_url {
    if !url.is_empty() {
        validate_url(url, "configUrl")?;
    }
}

5. DNS rebinding not defendeddeeplink/utils.rs:63–88

validate_host_not_internal does string/IP matching but performs no DNS resolution. A domain that DNS-resolves to 127.0.0.1 (DNS rebinding attack) or a private range will pass the check. This is an inherent limitation of blocklist-based SSRF mitigations in client-side apps. The existing code comment and the PR description don't acknowledge this gap; worth adding a // NOTE: DNS rebinding not defended comment so future maintainers are aware.


🔵 P3 — Minor / polish

6. Inconsistent encrypt-before-write patternproviders.rs vs settings.rs:455

encrypt_settings_config (providers.rs) calls crypto::encrypt() directly without checking is_encrypted_str first. settings.rs:455 correctly checks !crypto::is_encrypted_str(&sync.password) before encrypting. The encrypt_if_needed helper in crypto.rs exists precisely for this idiom but is marked #[allow(dead_code)] — it's never called. Suggest replacing the manual guard in settings.rs and using encrypt_if_needed both places for consistency.

7. Duplicate link-local checkdeeplink/utils.rs:99

v4.is_link_local() already covers 169.254.0.0/16, so || v4.octets()[0] == 169 && v4.octets()[1] == 254 is redundant. Harmless but can be removed.


What's good ✅

  • is_encrypted_str is correctly implemented at HEAD — decodes from base64 first, then checks the CCS1 magic prefix. The roundtrip test (test_encrypt_decrypt_roundtrip) validates this end-to-end.
  • AES-256-GCM nonce extraction is correct: format is [4 magic][12 nonce][ciphertext], bytes[4..16] extracts the 12-byte nonce properly.
  • SwitchLock TOCTOU fix: the double-check pattern using entry().or_insert_with() under the write lock is correct and race-free.
  • WebDAV password leak prevention: get_settings_for_frontend() clears the password field before sending to the renderer — the credential never crosses the IPC boundary.
  • settings.json file permissions: mode(0o600) on Unix correctly restricts the file to owner-only reads.
  • Bug fix "effortLevel": "high" 无论如何都不能保存为Max #2465 (deepFill): deepFill correctly preserves user-set fields vs deepMerge which was overwriting them. The fix is minimal and correct.
  • cargo-audit CI step: adds supply-chain vulnerability scanning. The two-step install-action + cargo audit pattern is standard.
  • transform_responses.rs test coverage: the #2471 test case (test_responses_to_anthropic_strips_empty_pages_from_tool_input) covers the intended fix.
  • crypto.rs module comment accurately documents the threat model and limitations (machine-level key, not OS keychain). This transparency is appreciated.

@farion1231
Copy link
Copy Markdown
Owner

@claude review

(Re-running with the updated review prompt for comparison.)

@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

Claude finished @farion1231's task in 2m 18s —— View job


PR Re-review (strict prompt) — feat: Liquid Glass UI + Security Hardening + Bug Fixes


Re-reviewed under stricter criteria. Two of the previous review's findings still meet the bar; most others were either speculative, pre-existing, or pedantic and have been dropped.


Findings

1. IPv4-mapped IPv6 SSRF bypasssrc-tauri/src/deeplink/utils.rs:102-109Important — confidence 92

Ipv6Addr::is_loopback() only matches ::1. The IPv6 branch of is_private_ip does not unwrap IPv4-mapped addresses, so a deep link of the form ccswitch://v1/import?…&endpoint=http://[::ffff:127.0.0.1]/v1 parses as IpAddr::V6, falls through every check in this branch, and reaches the loopback as if it were public. validate_host_not_internal also only string-matches 127.0.0.1 / ::1 / localhost, so the IPv4-mapped form bypasses that gate too. Fix:

IpAddr::V6(v6) => {
    if let Some(v4) = v6.to_ipv4_mapped() {
        return is_private_ip(&IpAddr::V4(v4));
    }
    v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local()
        || *v6 == Ipv6Addr::UNSPECIFIED
}

Fix this →


2. Overly broad empty-string stripping in tool inputssrc-tauri/src/proxy/providers/transform_responses.rs:460-461Important — confidence 85

The fix for #2471 strips every empty-string field from every function_call's arguments, not only pages:

obj.retain(|_k, v| !v.as_str().is_some_and(|s| s.is_empty()));

Concrete failure scenario: a model emits an Edit-style tool call with new_string: "" to delete a span of text. The retain removes new_string, the downstream Anthropic-format consumer sees a tool_use missing a required field, and the edit fails or behaves wrong. Scope the strip to the known-bad key:

obj.retain(|k, v| k != "pages" || !v.as_str().is_some_and(|s| s.is_empty()));

Fix this →


3. Silent data wipe when decrypt failssrc-tauri/src/database/dao/providers.rs:74-76 and :182-184Important — confidence 80

let settings_config = decrypt_settings_config_str(&settings_config_str)
    .map(|s| serde_json::from_str(&s).unwrap_or(serde_json::Value::Null))
    .unwrap_or(serde_json::Value::Null);

Concrete trigger: the encryption key is derived from hostname + os/arch + salt (crypto.rs:51). If the user renames their machine or restores from a backup onto a different host, every encrypted settings_config decrypts to None, the chain falls into unwrap_or(Value::Null), and providers load with no API keys / endpoints — silently. There is no log, no error surfaced, no flag for the user. The same pattern repeats in get_provider_by_id (line 182-184). At minimum, log::warn! on the decrypt failure path so the failure is traceable. Same applies to universal_providers.rs if it has the same shape.


Notes on dropped findings

  • config_url not validatedparse_and_merge_config (provider.rs:486-490) currently rejects any config_url with "Remote config URL is not yet supported", so there is no fetch path today. Speculative.
  • DNS rebinding — known limitation of static blocklists, not a bug introduced by this PR.
  • encrypt_if_needed not used / duplicate 169.254 check — pre-existing style; linter/cleanup territory.
  • is_encrypted_str base64 prefix check and IPv4 198.18 precedence bug — both already fixed in efa5460 (Codex's review was against fc00b82).

Everything else looked solid: SwitchLock TOCTOU fix is correct, AES-GCM nonce framing is correct, WebDAV password redaction before IPC is correct, 0o600 on settings.json is correct, the deepFill swap for #2465 is minimal and right.
• Branch: feat/liquid-glass-ui-security-fixes

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.

2 participants