From ea6a9743d1dbd0fedbe9342837394290a27c6370 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Tue, 2 Jun 2026 12:25:20 +0100 Subject: [PATCH] fix: harden SSH trust and dependency security --- 20260602-1221-securityAudit.md | 787 +++++++++++++++++++++++++++ frontend/package-lock.json | 226 ++++---- frontend/package.json | 2 +- frontend/src/js/app.js | 2 + frontend/src/js/settings.js | 28 +- go.mod | 4 +- go.sum | 4 + internal/api/api.go | 85 +-- internal/api/api_test.go | 67 +++ internal/sessions/manager.go | 66 ++- internal/sessions/manager_test.go | 63 +++ internal/sshclient/sshclient.go | 16 +- internal/sshclient/sshclient_test.go | 49 ++ internal/storage/storage_test.go | 27 + internal/storage/store.go | 37 +- internal/vncclient/vncclient.go | 62 ++- internal/vncclient/vncclient_test.go | 26 + 17 files changed, 1365 insertions(+), 186 deletions(-) create mode 100644 20260602-1221-securityAudit.md create mode 100644 internal/api/api_test.go create mode 100644 internal/sshclient/sshclient_test.go create mode 100644 internal/vncclient/vncclient_test.go diff --git a/20260602-1221-securityAudit.md b/20260602-1221-securityAudit.md new file mode 100644 index 0000000..219e335 --- /dev/null +++ b/20260602-1221-securityAudit.md @@ -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:/` 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/.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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ffcbae2..268a7de 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": { diff --git a/frontend/package.json b/frontend/package.json index ba38b00..04d58de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,6 @@ "xterm-addon-web-links": "0.9.0" }, "devDependencies": { - "esbuild": "^0.24.0" + "esbuild": "^0.28.0" } } diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 2d484b5..3543136 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -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, }); diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js index d58a421..e89c649 100644 --- a/frontend/src/js/settings.js +++ b/frontend/src/js/settings.js @@ -937,11 +937,29 @@ this.app.setUpdateInfo(info); } if (info.available) { - resultEl.innerHTML = - `Update available: ${info.latestVersion}` + - (info.releaseURL - ? ` — View release` - : ""); + 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."; } diff --git a/go.mod b/go.mod index e67e57b..750cdbb 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 43c5a16..d413a52 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/api.go b/internal/api/api.go index 9b76711..e3271ad 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..933ace1 --- /dev/null +++ b/internal/api/api_test.go @@ -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") + } +} diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 88a97d1..b57269c 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -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() { diff --git a/internal/sessions/manager_test.go b/internal/sessions/manager_test.go index 76c4d3c..ece51ad 100644 --- a/internal/sessions/manager_test.go +++ b/internal/sessions/manager_test.go @@ -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 +} diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index 7fb0bc9..bba0f4f 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -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) { diff --git a/internal/sshclient/sshclient_test.go b/internal/sshclient/sshclient_test.go new file mode 100644 index 0000000..346b5a7 --- /dev/null +++ b/internal/sshclient/sshclient_test.go @@ -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") + } +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 0ab3855..204e33c 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -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") diff --git a/internal/storage/store.go b/internal/storage/store.go index 6b4b554..5f0aa36 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -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 // --------------------------------------------------------------------------- diff --git a/internal/vncclient/vncclient.go b/internal/vncclient/vncclient.go index 3b4e013..c864345 100644 --- a/internal/vncclient/vncclient.go +++ b/internal/vncclient/vncclient.go @@ -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() diff --git a/internal/vncclient/vncclient_test.go b/internal/vncclient/vncclient_test.go new file mode 100644 index 0000000..aa06537 --- /dev/null +++ b/internal/vncclient/vncclient_test.go @@ -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) + } + } +}