fix: harden SSH trust and dependency security

This commit is contained in:
Andy Dixon
2026-06-02 12:25:20 +01:00
parent fb230ee35d
commit ea6a9743d1
17 changed files with 1365 additions and 186 deletions
+787
View File
@@ -0,0 +1,787 @@
# Security Audit Report
## 1. Executive Summary
This audit reviewed Nexus Terminal, a Wails desktop SSH/Telnet/RAW/VNC/SFTP client with a Go backend and JavaScript frontend. Fix mode was requested, so confirmed issues were patched, regression-tested, and prepared for commit.
The highest-risk issues were SSH trust failures: regular SSH sessions were not wired to the existing known-host verification flow, jump-host SSH connections ignored host keys, and VNC SSH tunnels also ignored host keys. These could allow machine-in-the-middle attacks against SSH credentials and terminal/VNC traffic. This run wired all SSH paths to known-host verification and prompt handling.
Other fixed issues include a local noVNC WebSocket proxy access-control weakness, SSH key-name path traversal in public-key reads, unsafe update-result DOM rendering, inline secret leakage in non-secret profile exports, and current vulnerable dependency versions in `golang.org/x/crypto` and `esbuild`.
Residual risk remains in user-selected insecure modes such as direct unencrypted VNC, user-configured `HostKeyInsecure`, and large local file transfer workflows that still process complete files in memory.
## 2. Scope Reviewed
Reviewed:
- Go backend packages under `internal/`: API bindings, sessions, SSH, VNC, SFTP, storage, secrets, updater, raw/telnet clients, recording, validation, known hosts, config, app startup.
- Frontend JavaScript under `frontend/src/js`: settings/update UI, host-key event handling, terminal and SFTP/VNC/GitHub surfaces, common DOM helpers.
- Dependency manifests and lockfiles: `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
- Build and release scripts: `Makefile`, `build.sh`, `release.sh`, Wails config.
- Tests under `internal/**/*_test.go`.
Not in scope:
- Live network penetration testing of remote hosts.
- Reverse engineering packaged binaries.
- Manual verification of Wails runtime origin values in a built app.
## 3. Methodology
- Static source review of trust boundaries, file operations, SSH/VNC flows, Wails API bindings, frontend DOM sinks, update checks, storage, crypto, and secret handling.
- Dependency review with `govulncheck` and `npm audit`.
- Configuration review of Wails, build scripts, update manifests, and release paths.
- Threat modelling of desktop renderer compromise, local web-origin attacks, remote SSH/VNC attackers, and malicious update metadata.
- Denial-of-service review of network timeouts, WebSocket proxy behavior, transfer paths, and file ingestion.
- Regression tests added for fixed Go issues.
## 4. Risk Rating Method
Severity considered exploitability, attacker position, affected security property, and impact:
- Critical: practical remote compromise or credential disclosure without meaningful preconditions.
- High: credential/session compromise or machine-in-the-middle exposure on primary connection paths.
- Medium: meaningful local, renderer, or network-assisted exploit with constraints.
- Low: hardening issue, local DoS, or user-assisted exposure.
- Informational: checked area or non-security note.
Confidence reflects direct code evidence and verification coverage.
## 5. Findings Summary Table
| ID | Severity | Confidence | Title | Component | Status |
|---|---:|---:|---|---|---|
| SEC-001 | High | High | SSH sessions and jump hosts bypassed known-host verification | SSH sessions | Fixed |
| SEC-002 | High | High | VNC SSH tunnels accepted any SSH host key | VNC over SSH | Fixed |
| SEC-003 | Medium | High | Local noVNC WebSocket proxy used a guessable root endpoint | VNC WebSocket proxy | Fixed |
| SEC-004 | Medium | High | SSH public-key read accepted traversal key names | SSH key API | Fixed |
| SEC-005 | Medium | High | Update metadata rendered through dynamic HTML and inline handler | Settings/update UI | Fixed |
| SEC-006 | Medium | High | Non-secret profile export leaked inline SSH secrets | Profile export | Fixed |
| SEC-007 | High | High | `golang.org/x/crypto` vulnerable in reachable SSH code | Go dependencies | Fixed |
| SEC-008 | Medium | High | `esbuild` dev dependency matched current npm advisory | Frontend dependency | Fixed |
| SEC-009 | Low | Medium | Large local file upload paths remain memory-heavy | File transfer UX | Open |
| SEC-010 | Low | Medium | Direct VNC remains unencrypted by protocol choice | VNC direct mode | Open |
## 6. Detailed Findings
### Finding ID: SEC-001
#### Title
SSH sessions and jump hosts bypassed known-host verification
#### Severity
High
#### Confidence
High
#### Affected Files / Components
- `internal/sessions/manager.go`, SSH connection setup, now fixed at lines 657 and 1450-1502.
- `internal/sshclient/sshclient.go`, jump-host config, now fixed at lines 599 and 646-652.
- `frontend/src/js/app.js`, host-key response correlation, now fixed at lines 334-342.
#### Description
The project already contained known-host storage, a host-key verifier, and frontend prompt UI, but the main SSH session manager previously called the SSH client's default `Connect` path. That default path used `ssh.InsecureIgnoreHostKey()`. Jump hosts also built their own `ssh.ClientConfig` with `ssh.InsecureIgnoreHostKey()`.
#### Evidence
The fix now calls `ConnectWithHostKeyCallback(...)` for regular SSH sessions and propagates the final host-key callback into jump-host configs. A request ID is included in host-key prompt responses to prevent cross-session prompt confusion.
#### Attack Scenario
An attacker on the network path presents a fake SSH server or jump host. Without host-key verification, Nexus Terminal would accept the attacker's key, allowing credential capture, terminal tampering, or traffic interception.
#### Impact
Possible SSH credential theft, terminal command tampering, and loss of server authenticity.
#### Recommendation
Always use the known-host verifier for every SSH hop. Unknown host keys should follow the configured profile policy: strict, ask, accept-new, or explicit insecure.
#### Safer Example
Implemented: `Manager.hostKeyCallback` builds `sshclient.NewHostKeyVerifier(...)`, emits a prompt when needed, and `connectSSH` calls `ConnectWithHostKeyCallback(...)`.
#### Suggested Tests
Added regression coverage:
- `TestHostKeyCallbackAcceptNewStoresHost`
- `TestHostKeyCallbackStrictRejectsUnknownHost`
- `TestJumpHostClientConfigUsesFinalHostKeyCallback`
#### References
- CWE-295: Improper Certificate Validation, analogous host-key trust failure.
- OWASP ASVS 9.1.2: server certificate validation principles.
#### Assumptions / Limitations
Profiles may still explicitly choose `HostKeyInsecure`; that is a user-selected insecure mode and remains visible as residual risk.
### Finding ID: SEC-002
#### Title
VNC SSH tunnels accepted any SSH host key
#### Severity
High
#### Confidence
High
#### Affected Files / Components
- `internal/vncclient/vncclient.go`, VNC SSH config, now fixed at lines 49 and 89-95.
- `internal/sessions/manager.go`, VNC SSH callback wiring, now fixed at line 1016.
#### Description
VNC SSH tunnels established SSH connections with `ssh.InsecureIgnoreHostKey()`, allowing a network attacker to impersonate the SSH tunnel endpoint.
#### Evidence
The VNC client now requires `SSHHostKeyCallback` for tunneled SSH. The session manager supplies the same known-host/prompt path used by SSH sessions.
#### Attack Scenario
An attacker controlling DNS, Wi-Fi, or a gateway presents a fake SSH tunnel host for VNC traffic. The prior client accepted the key and tunneled VNC traffic through the attacker.
#### Impact
Possible VNC credential exposure, remote desktop traffic interception, and remote interaction tampering.
#### Recommendation
Require known-host verification for VNC SSH tunnels and reject tunnel setup if no callback is configured.
#### Safer Example
Implemented: `vncclient.Connect` returns an error if `cfg.SSHHostKeyCallback` is nil, and uses the provided callback in `ssh.ClientConfig`.
#### Suggested Tests
Covered indirectly by session host-key callback tests and VNC package tests.
#### References
- CWE-295.
- OWASP ASVS 9.1.2.
#### Assumptions / Limitations
VNC tunnel host-key policy defaults to ask mode because VNC profiles do not currently expose a separate host-key policy field.
### Finding ID: SEC-003
#### Title
Local noVNC WebSocket proxy used a guessable root endpoint
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/vncclient/vncclient.go`, WebSocket proxy setup, now fixed at lines 171-183 and 255-260.
#### Description
The local VNC WebSocket proxy listened on `127.0.0.1` with a random port, but accepted upgrades on `/` and allowed any Origin. A malicious local web page could scan localhost ports and attempt to connect to an active proxy.
#### Evidence
The proxy now generates a 128-bit random token and exposes only `ws://127.0.0.1:<port>/<token>` to the Wails frontend.
#### Attack Scenario
A user visits a malicious web page while a VNC session is active. The page scans localhost WebSocket ports and connects to the proxy endpoint, potentially reading or injecting VNC traffic.
#### Impact
Possible local cross-origin access to VNC session traffic if the port is discovered.
#### Recommendation
Require an unguessable per-session URL token and continue binding only to loopback.
#### Safer Example
Implemented: `newProxyToken()` uses `crypto/rand` and the proxy route is registered only at the token path.
#### Suggested Tests
Added `TestNewProxyTokenIsRandomHex`.
#### References
- CWE-346: Origin Validation Error.
- OWASP ASVS 14.4.3: cross-origin resource restrictions.
#### Assumptions / Limitations
`CheckOrigin` still returns true because Wails/noVNC origin behavior needs built-app verification. The unguessable path materially reduces exposure.
### Finding ID: SEC-004
#### Title
SSH public-key read accepted traversal key names
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/api/api.go`, SSH key API, now fixed at lines 1653-1708.
#### Description
`GetSSHPublicKey(name)` previously built `~/.ssh/<name>.pub` directly from a renderer-provided name. Unlike key generation, it did not restrict names to simple key basenames. Names such as `../somefile` could read `~/.ssh/../somefile.pub`.
#### Evidence
The fix centralizes key name validation through `sshKeyPairPaths(...)`, allowing only alphanumeric characters, hyphens, and underscores.
#### Attack Scenario
If renderer-side JavaScript is compromised or a malicious plugin/flow can invoke the Wails API, it can request public-key contents outside the intended `.ssh` key namespace if the target path ends in `.pub`.
#### Impact
Local file disclosure for matching `.pub` paths under paths reachable through traversal.
#### Recommendation
Treat key names as basenames, not paths. Apply the same validation to generate, read, and delete operations.
#### Safer Example
Implemented: `GetSSHPublicKey`, `GenerateSSHKey`, and `DeleteSSHKey` now all use `sshKeyPairPaths`.
#### Suggested Tests
Added:
- `TestSSHKeyPairPathsAcceptsSafeName`
- `TestSSHKeyPairPathsRejectsTraversal`
- `TestSSHKeyPairPathsRejectsNestedPath`
#### References
- CWE-22: Path Traversal.
#### Assumptions / Limitations
The API still intentionally lists and reads legitimate SSH public keys in `~/.ssh`.
### Finding ID: SEC-005
#### Title
Update metadata rendered through dynamic HTML and inline handler
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `frontend/src/js/settings.js`, update-check UI, now fixed at lines 940-958.
#### Description
Update metadata from GitHub or a configured update manifest was inserted into `innerHTML`, including an inline `onclick` that interpolated `info.releaseURL`. A malicious or compromised update metadata source could inject script into the Wails renderer.
#### Evidence
The fix constructs DOM nodes, sets text via `textContent`, and attaches a click listener with `addEventListener`.
#### Attack Scenario
An attacker controlling a self-hosted update manifest returns a release URL containing a quote and JavaScript payload. The prior inline handler interpolation could execute renderer JavaScript.
#### Impact
Renderer code execution. In a Wails app, renderer execution can call exposed backend APIs, increasing impact.
#### Recommendation
Never render remote metadata through string-built HTML. Use `textContent` and event listeners.
#### Safer Example
Implemented in `settings.js` with `createElement`, `document.createTextNode`, and `addEventListener`.
#### Suggested Tests
No frontend unit harness exists. Verified with `npm run build`.
#### References
- CWE-79: Cross-Site Scripting.
- OWASP ASVS 5.3: output encoding.
#### Assumptions / Limitations
Other static modal templates still use `innerHTML`; reviewed high-risk dynamic remote metadata paths first.
### Finding ID: SEC-006
#### Title
Non-secret profile export leaked inline SSH secrets
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/api/api.go`, Wails export wrapper, now fixed at lines 683-684.
- `internal/storage/store.go`, storage export, now fixed at lines 356-358 and 726-738.
#### Description
The plain profile export path accepted `includeSecrets=false` but profiles can still contain inline `ssh.proxyPassword` and `ssh.totpSecret` fields. Those fields could be serialized despite a non-secret export request.
#### Evidence
The fix redacts `ProxyPassword` and `TOTPSecret` for non-secret exports in both API and storage layers.
#### Attack Scenario
A user creates a plain profile export believing secrets are excluded. The exported JSON contains proxy credentials or TOTP secrets that are then shared, backed up, or stored insecurely.
#### Impact
Credential leakage and potential 2FA bypass for affected profiles.
#### Recommendation
Redact inline secret fields whenever `includeSecrets=false`. Prefer encrypted export for any export that intentionally includes secrets.
#### Safer Example
Implemented `redactProfileSecrets(...)` in storage and API packages.
#### Suggested Tests
Added:
- `TestExportProfilesRedactsInlineSSHSecrets`
- `TestRedactProfileSecretsClearsInlineSSHSecrets`
#### References
- CWE-200: Exposure of Sensitive Information.
- OWASP ASVS 8.3: sensitive data protection.
#### Assumptions / Limitations
The encrypted export path intentionally includes selected profile data and is password-protected.
### Finding ID: SEC-007
#### Title
`golang.org/x/crypto` vulnerable in reachable SSH code
#### Severity
High
#### Confidence
High
#### Affected Files / Components
- `go.mod`, `golang.org/x/crypto`, now fixed at line 14.
- `go.mod`, indirect `golang.org/x/sys`, now updated at line 45.
- Reachable SSH code in `internal/sshclient`, `internal/vncclient`, `internal/sftpclient`.
#### Description
`govulncheck` reported seven reachable vulnerabilities in `golang.org/x/crypto@v0.51.0`, including SSH DoS/panic issues, FIDO/U2F security-key interaction bypass risk, and SSH agent forwarding constraint handling issues.
#### Evidence
`govulncheck` identified GO-2026-5033, GO-2026-5020, GO-2026-5019, GO-2026-5018, GO-2026-5017, GO-2026-5013, and GO-2026-5006 as reachable before the update. The dependency is now `v0.52.0`, and rerun output reports: "No vulnerabilities found. Your code is affected by 0 vulnerabilities."
#### Attack Scenario
A malicious or compromised SSH server, SSH agent, key material, or protocol peer triggers vulnerable SSH library behavior during connection, channel use, key parsing, or agent forwarding.
#### Impact
Possible client panic, denial of service, weakened security-key interaction, or agent forwarding constraint mishandling depending on the CVE path.
#### Recommendation
Keep `golang.org/x/crypto` at or above `v0.52.0` and run `govulncheck` in CI.
#### Safer Example
Implemented: `go get golang.org/x/crypto@v0.52.0`.
#### Suggested Tests
Ran `govulncheck` and `go test ./...`.
#### References
- https://pkg.go.dev/vuln/GO-2026-5033
- https://pkg.go.dev/vuln/GO-2026-5020
- https://pkg.go.dev/vuln/GO-2026-5019
- https://pkg.go.dev/vuln/GO-2026-5018
- https://pkg.go.dev/vuln/GO-2026-5017
- https://pkg.go.dev/vuln/GO-2026-5013
- https://pkg.go.dev/vuln/GO-2026-5006
#### Assumptions / Limitations
`govulncheck` also noted vulnerabilities in required modules that are not called by this code.
### Finding ID: SEC-008
#### Title
`esbuild` dev dependency matched current npm advisory
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `frontend/package.json`, `esbuild`, now fixed at line 22.
- `frontend/package-lock.json`.
#### Description
`npm audit` reported GHSA-67mh-4wv8-2f99 for `esbuild <=0.24.2`: any website could send requests to the dev server and read responses under affected versions.
#### Evidence
The frontend previously resolved `esbuild@0.24.2`. It now uses `^0.28.0`, and `npm audit --json` reports zero vulnerabilities.
#### Attack Scenario
During local development, a malicious website could interact with the esbuild dev server and read responses if a developer runs an affected dev server.
#### Impact
Possible local development data exposure.
#### Recommendation
Use a fixed esbuild release and keep npm audit in CI.
#### Safer Example
Implemented: `npm install esbuild@0.28.0`.
#### Suggested Tests
Ran `npm audit --json` and `npm run build`.
#### References
- https://github.com/advisories/GHSA-67mh-4wv8-2f99
#### Assumptions / Limitations
This is a devDependency, so production exploitability is lower than runtime dependencies.
### Finding ID: SEC-009
#### Title
Large local file upload paths remain memory-heavy
#### Severity
Low
#### Confidence
Medium
#### Affected Files / Components
- `frontend/src/js/terminal.js`, dropped-file paste/upload flows.
- `internal/api/api.go`, `SFTPUploadData`.
#### Description
Some upload paths read whole files into memory and send base64 payloads to Go before uploading. This can be expensive for very large local files.
#### Evidence
`SFTPUploadData` decodes the entire base64 payload before calling session upload. The frontend drag/drop paste path also reads file contents into strings.
#### Attack Scenario
A local user or compromised renderer submits very large files, causing high memory use and potential application unresponsiveness.
#### Impact
Local denial of service.
#### Recommendation
Add size limits for paste-as-text, prefer path-based streaming upload for large files, and surface user-facing warnings for files above a threshold.
#### Safer Example
Use file path streaming upload where possible and reject in-memory uploads above a configured maximum.
#### Suggested Tests
Add boundary tests for maximum accepted in-memory upload size and UI tests for large-file warnings.
#### References
- CWE-400: Uncontrolled Resource Consumption.
#### Assumptions / Limitations
This is primarily local/user-assisted. It was left open to avoid changing UX and transfer semantics during the security fix pass.
### Finding ID: SEC-010
#### Title
Direct VNC remains unencrypted by protocol choice
#### Severity
Low
#### Confidence
Medium
#### Affected Files / Components
- `internal/vncclient/vncclient.go`, direct TCP VNC mode.
- VNC profile settings.
#### Description
Direct VNC connections use raw TCP to the VNC server. VNC is commonly unencrypted unless tunneled or separately protected.
#### Evidence
The direct branch calls `net.DialTimeout` to the VNC endpoint. SSH tunnel mode is available and now host-key verified.
#### Attack Scenario
On an untrusted network, direct VNC traffic may expose screen contents or credentials depending on server configuration.
#### Impact
Sensitive remote desktop data exposure.
#### Recommendation
Warn users before direct VNC connections and prefer SSH tunnel mode by default.
#### Safer Example
Default new VNC profiles to SSH tunnel where feasible, or show an explicit "unencrypted direct VNC" warning.
#### Suggested Tests
Add UI tests that direct VNC profiles show a warning before connection.
#### References
- OWASP ASVS 9.1: communications security.
#### Assumptions / Limitations
Some VNC servers may provide their own encryption; this audit did not test remote server configurations.
## 7. Denial-of-Service Review
Fixed:
- Updated `golang.org/x/crypto` to resolve reachable SSH panic/deadlock/DoS vulnerabilities.
- Added random-token access control to the local VNC WebSocket proxy, reducing local cross-origin abuse.
Reviewed and not changed:
- HTTP clients for GitHub and update checks use finite timeouts.
- SSH/Telnet/RAW connection setup uses timeouts.
- VNC WebSocket bridge is intentionally long-lived and uses no read/write timeout.
- Large in-memory file upload/paste paths remain a local DoS risk; see SEC-009.
## 8. Dependency and Supply-Chain Review
Checks run:
- `govulncheck`: fixed reachable `golang.org/x/crypto` vulnerabilities by updating to `v0.52.0`; rerun found zero reachable vulnerabilities.
- `npm audit --json`: fixed `esbuild` advisory by updating to `0.28.0`; rerun found zero vulnerabilities.
- `npm run build`: passed after dependency update.
Notes:
- `xterm` packages are deprecated upstream in favor of `@xterm/*`, but no current audit advisory was reported in this run.
- `go.mod` still has transitive modules with vulnerabilities that `govulncheck` reports as not called by this code.
## 9. Secrets and Sensitive Data Review
Fixed:
- Non-secret profile export now redacts inline `ProxyPassword` and `TOTPSecret`.
- SSH host-key verification now protects credential submission from basic MITM paths.
Reviewed:
- Vault uses Argon2id and AES-256-GCM with random salts/nonces.
- Vault file is written with `0600` permissions and store directories with `0700`.
- OS keychain is preferred where available.
Residual:
- Secret values still exist in process memory while sessions need them.
- Proxy password is still present in profile model state when entered inline.
## 10. Authentication and Authorisation Review
Fixed:
- SSH, jump host, and VNC-over-SSH authentication now has host authenticity checks before credential use.
- SSH host-key prompt responses are request-correlated.
Reviewed:
- SSH/TOTP flows use profile-specific secret callbacks.
- Agent forwarding is explicit profile configuration.
- Known-host policies include strict, ask, accept-new, and insecure.
Residual:
- `HostKeyInsecure` remains available for user-selected insecure connections.
## 11. Input Validation and Output Encoding Review
Fixed:
- SSH key names now reject traversal and nested path separators.
- Update metadata is rendered with DOM APIs instead of string-built HTML.
- Non-secret export redacts inline secret fields.
Reviewed:
- SQL operations use parameterized queries.
- Profile validation checks host, port, username, protocol-specific fields, and port-forwarding basics.
- Frontend frequently uses `textContent` or `escapeHtml`; remaining `innerHTML` static templates should still be reviewed during future frontend hardening.
## 12. Infrastructure and Deployment Review
Reviewed:
- Wails app binds Go services directly to the renderer. This makes frontend XSS more impactful than in a browser-only app.
- Build scripts use `set -euo pipefail`.
- Update checking is opt-in and does not auto-install binaries.
Residual:
- No CI configuration was present in the reviewed file list, so dependency scanning is not currently enforced by repository automation.
## 13. Logging, Monitoring, and Error Handling Review
Reviewed:
- Audit logger records profile create/update/delete, connection events, secret access, import/export.
- Errors are generally returned to the frontend with enough context for troubleshooting.
Residual:
- Some error messages include file paths, which is acceptable for a local desktop tool but should be considered if telemetry is later added.
## 14. Test Coverage and Security Regression Gaps
Added tests:
- API SSH key path validation and export redaction.
- Session host-key callback storage and strict rejection behavior.
- Jump-host host-key callback propagation.
- Storage export redaction.
- VNC proxy token generation.
Remaining gaps:
- No frontend unit test harness for DOM injection regressions.
- No integration test for full Wails host-key prompt round trip.
- No VNC WebSocket integration test verifying token-only connection behavior.
- No large-file upload boundary tests.
## 15. Prioritised Remediation Plan
Immediate:
- Keep the fixes in this audit and ensure they are merged.
- Add dependency scanning to CI: `govulncheck ./...` and `npm audit --audit-level=moderate`.
Short-term:
- Add a frontend test harness for update UI and other dynamic DOM surfaces.
- Add direct VNC warning UX and default to SSH tunnel where possible.
- Add explicit in-memory upload size limits.
Medium-term:
- Expose host-key policy for VNC SSH tunnels instead of always using ask mode.
- Review all Wails-exposed methods under a compromised-renderer threat model.
- Migrate deprecated `xterm` packages to maintained `@xterm/*` packages.
Long-term:
- Consider capability gating or confirmation for high-risk renderer-callable APIs such as file deletion, SSH key deletion, local shell selection, and profile import/export.
- Add signed update metadata if self-hosted update manifests become a production channel.
## 16. False Positives / Needs Manual Verification
- Wails/noVNC Origin behavior in a packaged app should be manually verified. Tokenized VNC WebSocket paths are in place, but Origin policy may still be tightened later.
- Direct VNC encryption depends on remote VNC server configuration and network placement.
- `HostKeyInsecure` is intentionally user-configurable; confirm UI wording makes the risk clear.
- Large-file memory usage thresholds need product decisions before enforcement.
## 17. Final Notes
Fix mode completed with code changes, tests, and dependency updates. The strongest residual concern is the general Wails threat model: a frontend XSS can call powerful local backend APIs. This audit fixed the confirmed dynamic update UI injection path and several backend trust-boundary issues, but future work should continue reducing string-built HTML and adding capability checks around sensitive local operations.
+122 -104
View File
@@ -17,13 +17,13 @@
"xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
"esbuild": "^0.28.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
@@ -38,9 +38,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
@@ -55,9 +55,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
@@ -72,9 +72,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
@@ -89,9 +89,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
@@ -106,9 +106,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
@@ -123,9 +123,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
@@ -140,9 +140,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
@@ -157,9 +157,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
@@ -174,9 +174,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
@@ -191,9 +191,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
@@ -208,9 +208,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
@@ -225,9 +225,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
@@ -242,9 +242,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
@@ -259,9 +259,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
@@ -276,9 +276,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
@@ -293,9 +293,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
@@ -310,9 +310,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
@@ -327,9 +327,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
@@ -344,9 +344,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
@@ -361,9 +361,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
@@ -377,10 +377,27 @@
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
@@ -395,9 +412,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
@@ -412,9 +429,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
@@ -429,9 +446,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
@@ -452,9 +469,9 @@
"license": "MPL-2.0"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -465,31 +482,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/xterm": {
+1 -1
View File
@@ -19,6 +19,6 @@
"xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
"esbuild": "^0.28.0"
}
}
+2
View File
@@ -332,11 +332,13 @@
payload.fingerprint,
);
window.runtime.EventsEmit("session:hostkey:response", {
requestID: payload.requestID,
sessionID: payload.sessionID,
accepted,
});
} catch (_) {
window.runtime.EventsEmit("session:hostkey:response", {
requestID: payload.requestID,
sessionID: payload.sessionID,
accepted: false,
});
+23 -5
View File
@@ -937,11 +937,29 @@
this.app.setUpdateInfo(info);
}
if (info.available) {
resultEl.innerHTML =
`<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
(info.releaseURL
? ` — <a href="#" onclick="window.runtime.BrowserOpenURL('${info.releaseURL}');return false;" style="color:var(--accent)">View release</a>`
: "");
resultEl.textContent = "";
const updateText = createElement("span");
updateText.style.color = "var(--accent)";
updateText.appendChild(document.createTextNode("Update available: "));
const versionText = createElement("strong");
versionText.textContent = String(info.latestVersion || "");
updateText.appendChild(versionText);
resultEl.appendChild(updateText);
if (info.releaseURL) {
resultEl.appendChild(document.createTextNode(" - "));
const releaseLink = createElement("a", "", {
href: "#",
textContent: "View release",
});
releaseLink.style.color = "var(--accent)";
releaseLink.addEventListener("click", (e) => {
e.preventDefault();
if (window.runtime && window.runtime.BrowserOpenURL) {
window.runtime.BrowserOpenURL(String(info.releaseURL));
}
});
resultEl.appendChild(releaseLink);
}
} else {
resultEl.textContent = "You are running the latest version.";
}
+2 -2
View File
@@ -11,7 +11,7 @@ require (
github.com/pquerna/otp v1.5.0
github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.51.0
golang.org/x/crypto v0.52.0
golang.org/x/net v0.54.0
golang.org/x/text v0.37.0
)
@@ -42,5 +42,5 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/sys v0.45.0 // indirect
)
+4
View File
@@ -84,6 +84,8 @@ github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPc
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
@@ -93,6 +95,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
+53 -32
View File
@@ -680,6 +680,9 @@ func (s *Service) ExportProfiles(includeSecrets bool) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to list profiles for export: %w", err)
}
if !includeSecrets {
profiles = redactProfileSecrets(profiles)
}
data, err := json.MarshalIndent(profiles, "", " ")
if err != nil {
@@ -1604,28 +1607,17 @@ func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
// GenerateSSHKey generates a new SSH key pair.
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
// Sanitise name — only allow alphanumeric, hyphens, underscores
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
}
}
home, err := os.UserHomeDir()
keyPath, _, err := sshKeyPairPaths(name)
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
return err
}
sshDir := filepath.Join(home, ".ssh")
sshDir := filepath.Dir(keyPath)
// Ensure .ssh directory exists
if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("unable to create .ssh directory: %w", err)
}
keyPath := filepath.Join(sshDir, name)
// Check if key already exists
if _, err := os.Stat(keyPath); err == nil {
return fmt.Errorf("key %q already exists", name)
@@ -1659,11 +1651,10 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
// GetSSHPublicKey returns the content of a public key file.
func (s *Service) GetSSHPublicKey(name string) (string, error) {
home, err := os.UserHomeDir()
_, pubPath, err := sshKeyPairPaths(name)
if err != nil {
return "", fmt.Errorf("unable to determine home directory: %w", err)
return "", err
}
pubPath := filepath.Join(home, ".ssh", name+".pub")
data, err := os.ReadFile(pubPath)
if err != nil {
return "", fmt.Errorf("unable to read public key: %w", err)
@@ -1673,22 +1664,9 @@ func (s *Service) GetSSHPublicKey(name string) (string, error) {
// DeleteSSHKey removes an SSH key pair.
func (s *Service) DeleteSSHKey(name string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
home, err := os.UserHomeDir()
privPath, pubPath, err := sshKeyPairPaths(name)
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
privPath := filepath.Join(sshDir, name)
pubPath := privPath + ".pub"
// Verify paths are within .ssh directory (prevent directory traversal)
absPriv, _ := filepath.Abs(privPath)
absSsh, _ := filepath.Abs(sshDir)
if !strings.HasPrefix(absPriv, absSsh+string(os.PathSeparator)) {
return fmt.Errorf("invalid key name")
return err
}
var errs []string
@@ -1703,3 +1681,46 @@ func (s *Service) DeleteSSHKey(name string) error {
}
return nil
}
func sshKeyPairPaths(name string) (string, string, error) {
keyName, err := validateSSHKeyName(name)
if err != nil {
return "", "", err
}
home, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
privPath := filepath.Join(sshDir, keyName)
pubPath := privPath + ".pub"
return privPath, pubPath, nil
}
func validateSSHKeyName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("key name is required")
}
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return "", fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
}
}
return name, nil
}
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
redacted := make([]*models.Profile, len(profiles))
for i, profile := range profiles {
cp := *profile
if cp.SSH != nil {
sshCfg := *cp.SSH
sshCfg.ProxyPassword = ""
sshCfg.TOTPSecret = ""
cp.SSH = &sshCfg
}
redacted[i] = &cp
}
return redacted
}
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"path/filepath"
"strings"
"testing"
"github.com/andydixon/nexus/internal/models"
)
func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
privPath, pubPath, err := sshKeyPairPaths("deploy_key-01")
if err != nil {
t.Fatalf("sshKeyPairPaths() error = %v", err)
}
wantPriv := filepath.Join(home, ".ssh", "deploy_key-01")
if privPath != wantPriv {
t.Fatalf("private path = %q, want %q", privPath, wantPriv)
}
if pubPath != wantPriv+".pub" {
t.Fatalf("public path = %q, want %q", pubPath, wantPriv+".pub")
}
}
func TestSSHKeyPairPathsRejectsTraversal(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("../outside")
if err == nil {
t.Fatal("expected traversal key name to be rejected")
}
if !strings.Contains(err.Error(), "invalid characters") {
t.Fatalf("error = %q, want invalid characters", err.Error())
}
}
func TestSSHKeyPairPathsRejectsNestedPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("nested/key")
if err == nil {
t.Fatal("expected nested key path to be rejected")
}
}
func TestRedactProfileSecretsClearsInlineSSHSecrets(t *testing.T) {
profile := models.NewProfile(models.ProtocolSSH)
profile.SSH.ProxyPassword = "proxy-secret"
profile.SSH.TOTPSecret = "totp-secret"
got := redactProfileSecrets([]*models.Profile{&profile})
if got[0].SSH.ProxyPassword != "" {
t.Fatalf("ProxyPassword = %q, want empty", got[0].SSH.ProxyPassword)
}
if got[0].SSH.TOTPSecret != "" {
t.Fatalf("TOTPSecret = %q, want empty", got[0].SSH.TOTPSecret)
}
if profile.SSH.ProxyPassword == "" || profile.SSH.TOTPSecret == "" {
t.Fatal("redaction mutated the original profile")
}
}
+61 -5
View File
@@ -15,6 +15,7 @@ import (
"github.com/google/uuid"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/crypto/ssh"
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/githubclient"
@@ -653,7 +654,7 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
}
}
if err := client.Connect(getPassword, getPassphrase); err != nil {
if err := client.ConnectWithHostKeyCallback(m.hostKeyCallback(sess.Info.ID, sshConf.HostKeyPolicy), getPassword, getPassphrase); err != nil {
return err
}
@@ -1012,6 +1013,7 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
cfg.SSHUsername = vncConf.SSHUsername
cfg.SSHAuthMethod = vncConf.SSHAuthMethod
cfg.SSHAgentAuth = vncConf.SSHAgentAuth
cfg.SSHHostKeyCallback = m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk)
// Resolve SSH credentials
switch vncConf.SSHAuthMethod {
@@ -1445,6 +1447,60 @@ func (m *Manager) PingSession(sessionID string) (int64, error) {
// Internal: helpers
// ---------------------------------------------------------------------------
func (m *Manager) hostKeyCallback(sessionID string, policy models.HostKeyPolicy) ssh.HostKeyCallback {
if policy == "" {
policy = models.HostKeyAsk
}
verifier := sshclient.NewHostKeyVerifier(m.store, policy)
verifier.SetPromptHandler(func(host string, keyType string, fingerprint string) (bool, error) {
return m.promptHostKey(sessionID, host, keyType, fingerprint)
})
return verifier.HostKeyCallback()
}
func (m *Manager) promptHostKey(sessionID string, host string, keyType string, fingerprint string) (bool, error) {
if m.ctx == nil {
return false, fmt.Errorf("host key prompt unavailable")
}
requestID := uuid.New().String()
ch := make(chan bool, 1)
cancel := wailsRuntime.EventsOn(m.ctx, "session:hostkey:response", func(optionalData ...interface{}) {
if len(optionalData) == 0 {
return
}
data, ok := optionalData[0].(map[string]interface{})
if !ok {
return
}
rid, _ := data["requestID"].(string)
if rid != requestID {
return
}
accepted, _ := data["accepted"].(bool)
select {
case ch <- accepted:
default:
}
})
defer cancel()
m.emitEvent("session:hostkey:verify", map[string]interface{}{
"requestID": requestID,
"sessionID": sessionID,
"host": host,
"keyType": keyType,
"fingerprint": fingerprint,
})
select {
case accepted := <-ch:
return accepted, nil
case <-time.After(5 * time.Minute):
return false, fmt.Errorf("host key prompt timed out")
}
}
// buildSecretCallback returns a func() (string, error) that resolves a
// credential according to the profile's SecretMode:
// - "saved": check keychain/vault first, prompt if missing, then persist
@@ -1806,11 +1862,11 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
}
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionReconnecting),
"attempt": attempt,
"sessionID": sessionID,
"state": string(models.SessionReconnecting),
"attempt": attempt,
"maxRetries": maxTries,
"delaySec": delaySec,
"delaySec": delaySec,
})
go func() {
+63
View File
@@ -1,9 +1,14 @@
package sessions
import (
"crypto/ed25519"
"crypto/rand"
"path/filepath"
"testing"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/storage"
"golang.org/x/crypto/ssh"
)
// ---------------------------------------------------------------------------
@@ -148,3 +153,61 @@ func TestCloseAllEmpty(t *testing.T) {
t.Error("expected 0 sessions after CloseAll")
}
}
func TestHostKeyCallbackAcceptNewStoresHost(t *testing.T) {
store := newSessionTestStore(t)
mgr := NewManager(store, nil, nil, nil, nil)
callback := mgr.hostKeyCallback("session-1", models.HostKeyAcceptNew)
key := testPublicKey(t)
if err := callback("jump.example:2222", nil, key); err != nil {
t.Fatalf("HostKeyCallback() error = %v", err)
}
saved, err := store.GetKnownHost("jump.example", 2222)
if err != nil {
t.Fatalf("GetKnownHost() error = %v", err)
}
if saved == nil {
t.Fatal("expected host key to be saved")
}
if saved.Host != "jump.example" {
t.Fatalf("saved host = %q, want jump.example", saved.Host)
}
if saved.Port != 2222 {
t.Fatalf("saved port = %d, want 2222", saved.Port)
}
}
func TestHostKeyCallbackStrictRejectsUnknownHost(t *testing.T) {
mgr := NewManager(newSessionTestStore(t), nil, nil, nil, nil)
callback := mgr.hostKeyCallback("session-1", models.HostKeyStrict)
err := callback("unknown.example:22", nil, testPublicKey(t))
if err == nil {
t.Fatal("expected strict policy to reject unknown host")
}
}
func testPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey() error = %v", err)
}
key, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatalf("NewPublicKey() error = %v", err)
}
return key
}
func newSessionTestStore(t *testing.T) *storage.Store {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
+10 -6
View File
@@ -596,12 +596,7 @@ func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.Clien
jumpAuth = append(jumpAuth, ssh.PublicKeys(signer))
}
jumpConfig := &ssh.ClientConfig{
User: jump.Username,
Auth: jumpAuth,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: finalConfig.Timeout,
}
jumpConfig := jumpHostClientConfig(jump, jumpAuth, finalConfig)
if currentClient == nil {
client, err := ssh.Dial("tcp", jumpAddr, jumpConfig)
@@ -648,6 +643,15 @@ func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.Clien
return ssh.NewClient(ncc, chans, reqs), nil
}
func jumpHostClientConfig(jump models.JumpHost, auth []ssh.AuthMethod, finalConfig *ssh.ClientConfig) *ssh.ClientConfig {
return &ssh.ClientConfig{
User: jump.Username,
Auth: auth,
HostKeyCallback: finalConfig.HostKeyCallback,
Timeout: finalConfig.Timeout,
}
}
// parsePrivateKey loads and parses an SSH private key file. If the key is
// encrypted and getPassphrase is provided, it will attempt decryption.
func (c *Client) parsePrivateKey(path string, getPassphrase func() (string, error)) (ssh.Signer, error) {
+49
View File
@@ -0,0 +1,49 @@
package sshclient
import (
"net"
"testing"
"time"
"github.com/andydixon/nexus/internal/models"
"golang.org/x/crypto/ssh"
)
func TestJumpHostClientConfigUsesFinalHostKeyCallback(t *testing.T) {
called := false
hostKeyCallback := func(hostname string, _ net.Addr, _ ssh.PublicKey) error {
called = true
if hostname != "jump.example:2222" {
t.Fatalf("hostname = %q, want jump.example:2222", hostname)
}
return nil
}
finalConfig := &ssh.ClientConfig{
HostKeyCallback: hostKeyCallback,
Timeout: 12 * time.Second,
}
jump := models.JumpHost{
Host: "jump.example",
Port: 2222,
Username: "alice",
}
got := jumpHostClientConfig(jump, nil, finalConfig)
if got.User != jump.Username {
t.Fatalf("User = %q, want %q", got.User, jump.Username)
}
if got.Timeout != finalConfig.Timeout {
t.Fatalf("Timeout = %s, want %s", got.Timeout, finalConfig.Timeout)
}
if got.HostKeyCallback == nil {
t.Fatal("HostKeyCallback is nil")
}
if err := got.HostKeyCallback("jump.example:2222", nil, nil); err != nil {
t.Fatalf("HostKeyCallback() error = %v", err)
}
if !called {
t.Fatal("final host key callback was not used")
}
}
+27
View File
@@ -3,6 +3,7 @@ package storage
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
@@ -455,6 +456,32 @@ func TestExportProfiles(t *testing.T) {
}
}
func TestExportProfilesRedactsInlineSSHSecrets(t *testing.T) {
store := setupTestStore(t)
profile := makeSSHProfile("Export Secret", "secret.example.com")
profile.SSH.ProxyPassword = "proxy-secret"
profile.SSH.TOTPSecret = "totp-secret"
if err := store.CreateProfile(profile); err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
data, err := store.ExportProfiles(false)
if err != nil {
t.Fatalf("ExportProfiles(false) error = %v", err)
}
if strings.Contains(string(data), "proxy-secret") || strings.Contains(string(data), "totp-secret") {
t.Fatalf("ExportProfiles(false) leaked inline SSH secrets: %s", string(data))
}
data, err = store.ExportProfiles(true)
if err != nil {
t.Fatalf("ExportProfiles(true) error = %v", err)
}
if !strings.Contains(string(data), "proxy-secret") || !strings.Contains(string(data), "totp-secret") {
t.Fatalf("ExportProfiles(true) did not include inline SSH secrets: %s", string(data))
}
}
func TestImportProfiles(t *testing.T) {
store := setupTestStore(t)
p := makeSSHProfile("Import Me", "import.local")
+27 -10
View File
@@ -346,13 +346,15 @@ func (s *Store) UpdateLastConnected(id string) error {
}
// ExportProfiles serialises all profiles as JSON. If includeSecrets is false
// the export omits credential-related fields (currently a placeholder — all
// secrets live in the SecretStore, not in profiles).
// the export omits inline credential-related fields.
func (s *Store) ExportProfiles(includeSecrets bool) ([]byte, error) {
profiles, err := s.ListProfiles()
if err != nil {
return nil, fmt.Errorf("storage: export failed: %w", err)
}
if !includeSecrets {
profiles = redactProfileSecrets(profiles)
}
return json.MarshalIndent(profiles, "", " ")
}
@@ -567,11 +569,11 @@ func (s *Store) SavePreferences(prefs *models.AppPreferences) error {
func scanProfile(row *sql.Row) (*models.Profile, error) {
p := &models.Profile{}
var (
protocol, tagsJSON string
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
lastConn sql.NullString
favourite int
createdAt, updatedAt string
)
err := row.Scan(
@@ -634,11 +636,11 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
for rows.Next() {
p := &models.Profile{}
var (
protocol, tagsJSON string
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
lastConn sql.NullString
favourite int
createdAt, updatedAt string
)
err := rows.Scan(
@@ -720,6 +722,21 @@ func nullableJSON(ptr interface{}, data []byte) interface{} {
return string(data)
}
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
redacted := make([]*models.Profile, len(profiles))
for i, profile := range profiles {
cp := *profile
if cp.SSH != nil {
sshCfg := *cp.SSH
sshCfg.ProxyPassword = ""
sshCfg.TOTPSecret = ""
cp.SSH = &sshCfg
}
redacted[i] = &cp
}
return redacted
}
// ---------------------------------------------------------------------------
// Snippet CRUD
// ---------------------------------------------------------------------------
+41 -21
View File
@@ -4,6 +4,8 @@ package vncclient
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/http"
@@ -17,14 +19,14 @@ import (
// Client manages a VNC session, optionally tunnelled through SSH.
type Client struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
listener net.Listener
sshClient *ssh.Client
wsURL string
connected bool
onError func(error)
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
listener net.Listener
sshClient *ssh.Client
wsURL string
connected bool
onError func(error)
}
// Config holds the configuration for a VNC connection.
@@ -34,16 +36,17 @@ type Config struct {
VNCPort int
// SSH tunnel settings
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string // "password", "publickey"
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string // "password", "publickey"
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
SSHHostKeyCallback ssh.HostKeyCallback
// Callbacks
OnError func(error)
@@ -83,9 +86,12 @@ func (c *Client) Connect(cfg Config) (string, error) {
if cfg.SSHTunnel {
// Establish SSH connection first
sshAddr := fmt.Sprintf("%s:%d", cfg.SSHHost, cfg.SSHPort)
if cfg.SSHHostKeyCallback == nil {
return "", fmt.Errorf("SSH host key callback is required")
}
sshConfig := &ssh.ClientConfig{
User: cfg.SSHUsername,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use known hosts
HostKeyCallback: cfg.SSHHostKeyCallback,
Timeout: 15 * time.Second,
}
@@ -162,13 +168,19 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
if err != nil {
return "", err
}
token, err := newProxyToken()
if err != nil {
listener.Close()
return "", err
}
c.listener = listener
port := listener.Addr().(*net.TCPAddr).Port
wsURL := fmt.Sprintf("ws://127.0.0.1:%d", port)
proxyPath := "/" + token
wsURL := fmt.Sprintf("ws://127.0.0.1:%d%s", port, proxyPath)
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc(proxyPath, func(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
@@ -240,6 +252,14 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
return wsURL, nil
}
func newProxyToken() (string, error) {
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
return "", fmt.Errorf("generate proxy token: %w", err)
}
return hex.EncodeToString(token), nil
}
// Close shuts down the VNC proxy and any SSH tunnel.
func (c *Client) Close() {
c.mu.Lock()
+26
View File
@@ -0,0 +1,26 @@
package vncclient
import "testing"
func TestNewProxyTokenIsRandomHex(t *testing.T) {
first, err := newProxyToken()
if err != nil {
t.Fatalf("newProxyToken() error = %v", err)
}
second, err := newProxyToken()
if err != nil {
t.Fatalf("newProxyToken() second error = %v", err)
}
if len(first) != 32 {
t.Fatalf("token length = %d, want 32", len(first))
}
if first == second {
t.Fatal("expected separate tokens to differ")
}
for _, ch := range first {
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
t.Fatalf("token contains non-hex character %q", ch)
}
}
}