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" "xterm-addon-web-links": "0.9.0"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.24.0" "esbuild": "^0.28.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -38,9 +38,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -55,9 +55,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -72,9 +72,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -89,9 +89,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -106,9 +106,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -123,9 +123,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -140,9 +140,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -157,9 +157,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -174,9 +174,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -191,9 +191,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -208,9 +208,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -225,9 +225,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -242,9 +242,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -259,9 +259,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -276,9 +276,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -293,9 +293,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -310,9 +310,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -327,9 +327,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -344,9 +344,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -361,9 +361,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -377,10 +377,27 @@
"node": ">=18" "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": { "node_modules/@esbuild/sunos-x64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -395,9 +412,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -412,9 +429,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -429,9 +446,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -452,9 +469,9 @@
"license": "MPL-2.0" "license": "MPL-2.0"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.24.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -465,31 +482,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2", "@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.24.2", "@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.24.2", "@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.24.2", "@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.24.2", "@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.24.2", "@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.24.2", "@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.24.2", "@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.24.2", "@esbuild/openbsd-x64": "0.28.0",
"@esbuild/sunos-x64": "0.24.2", "@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/win32-arm64": "0.24.2", "@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-x64": "0.24.2" "@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
} }
}, },
"node_modules/xterm": { "node_modules/xterm": {
+1 -1
View File
@@ -19,6 +19,6 @@
"xterm-addon-web-links": "0.9.0" "xterm-addon-web-links": "0.9.0"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.24.0" "esbuild": "^0.28.0"
} }
} }
+2
View File
@@ -332,11 +332,13 @@
payload.fingerprint, payload.fingerprint,
); );
window.runtime.EventsEmit("session:hostkey:response", { window.runtime.EventsEmit("session:hostkey:response", {
requestID: payload.requestID,
sessionID: payload.sessionID, sessionID: payload.sessionID,
accepted, accepted,
}); });
} catch (_) { } catch (_) {
window.runtime.EventsEmit("session:hostkey:response", { window.runtime.EventsEmit("session:hostkey:response", {
requestID: payload.requestID,
sessionID: payload.sessionID, sessionID: payload.sessionID,
accepted: false, accepted: false,
}); });
+23 -5
View File
@@ -937,11 +937,29 @@
this.app.setUpdateInfo(info); this.app.setUpdateInfo(info);
} }
if (info.available) { if (info.available) {
resultEl.innerHTML = resultEl.textContent = "";
`<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` + const updateText = createElement("span");
(info.releaseURL updateText.style.color = "var(--accent)";
? ` — <a href="#" onclick="window.runtime.BrowserOpenURL('${info.releaseURL}');return false;" style="color:var(--accent)">View release</a>` 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 { } else {
resultEl.textContent = "You are running the latest version."; 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/pquerna/otp v1.5.0
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.8 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/net v0.54.0
golang.org/x/text v0.37.0 golang.org/x/text v0.37.0
) )
@@ -42,5 +42,5 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // 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= 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 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= 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.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 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= 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.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 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.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 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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 { if err != nil {
return "", fmt.Errorf("failed to list profiles for export: %w", err) return "", fmt.Errorf("failed to list profiles for export: %w", err)
} }
if !includeSecrets {
profiles = redactProfileSecrets(profiles)
}
data, err := json.MarshalIndent(profiles, "", " ") data, err := json.MarshalIndent(profiles, "", " ")
if err != nil { if err != nil {
@@ -1604,28 +1607,17 @@ func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
// GenerateSSHKey generates a new SSH key pair. // GenerateSSHKey generates a new SSH key pair.
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error { func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
if name == "" { keyPath, _, err := sshKeyPairPaths(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()
if err != nil { 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 // Ensure .ssh directory exists
if err := os.MkdirAll(sshDir, 0700); err != nil { if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("unable to create .ssh directory: %w", err) return fmt.Errorf("unable to create .ssh directory: %w", err)
} }
keyPath := filepath.Join(sshDir, name)
// Check if key already exists // Check if key already exists
if _, err := os.Stat(keyPath); err == nil { if _, err := os.Stat(keyPath); err == nil {
return fmt.Errorf("key %q already exists", name) 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. // GetSSHPublicKey returns the content of a public key file.
func (s *Service) GetSSHPublicKey(name string) (string, error) { func (s *Service) GetSSHPublicKey(name string) (string, error) {
home, err := os.UserHomeDir() _, pubPath, err := sshKeyPairPaths(name)
if err != nil { 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) data, err := os.ReadFile(pubPath)
if err != nil { if err != nil {
return "", fmt.Errorf("unable to read public key: %w", err) 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. // DeleteSSHKey removes an SSH key pair.
func (s *Service) DeleteSSHKey(name string) error { func (s *Service) DeleteSSHKey(name string) error {
if name == "" { privPath, pubPath, err := sshKeyPairPaths(name)
return fmt.Errorf("key name is required")
}
home, err := os.UserHomeDir()
if err != nil { if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err) return 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")
} }
var errs []string var errs []string
@@ -1703,3 +1681,46 @@ func (s *Service) DeleteSSHKey(name string) error {
} }
return nil 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" "github.com/google/uuid"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/crypto/ssh"
"github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/githubclient" "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 return err
} }
@@ -1012,6 +1013,7 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
cfg.SSHUsername = vncConf.SSHUsername cfg.SSHUsername = vncConf.SSHUsername
cfg.SSHAuthMethod = vncConf.SSHAuthMethod cfg.SSHAuthMethod = vncConf.SSHAuthMethod
cfg.SSHAgentAuth = vncConf.SSHAgentAuth cfg.SSHAgentAuth = vncConf.SSHAgentAuth
cfg.SSHHostKeyCallback = m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk)
// Resolve SSH credentials // Resolve SSH credentials
switch vncConf.SSHAuthMethod { switch vncConf.SSHAuthMethod {
@@ -1445,6 +1447,60 @@ func (m *Manager) PingSession(sessionID string) (int64, error) {
// Internal: helpers // 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 // buildSecretCallback returns a func() (string, error) that resolves a
// credential according to the profile's SecretMode: // credential according to the profile's SecretMode:
// - "saved": check keychain/vault first, prompt if missing, then persist // - "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{}{ m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID, "sessionID": sessionID,
"state": string(models.SessionReconnecting), "state": string(models.SessionReconnecting),
"attempt": attempt, "attempt": attempt,
"maxRetries": maxTries, "maxRetries": maxTries,
"delaySec": delaySec, "delaySec": delaySec,
}) })
go func() { go func() {
+63
View File
@@ -1,9 +1,14 @@
package sessions package sessions
import ( import (
"crypto/ed25519"
"crypto/rand"
"path/filepath"
"testing" "testing"
"github.com/andydixon/nexus/internal/models" "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") 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)) jumpAuth = append(jumpAuth, ssh.PublicKeys(signer))
} }
jumpConfig := &ssh.ClientConfig{ jumpConfig := jumpHostClientConfig(jump, jumpAuth, finalConfig)
User: jump.Username,
Auth: jumpAuth,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: finalConfig.Timeout,
}
if currentClient == nil { if currentClient == nil {
client, err := ssh.Dial("tcp", jumpAddr, jumpConfig) 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 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 // parsePrivateKey loads and parses an SSH private key file. If the key is
// encrypted and getPassphrase is provided, it will attempt decryption. // encrypted and getPassphrase is provided, it will attempt decryption.
func (c *Client) parsePrivateKey(path string, getPassphrase func() (string, error)) (ssh.Signer, error) { 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 ( import (
"encoding/json" "encoding/json"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "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) { func TestImportProfiles(t *testing.T) {
store := setupTestStore(t) store := setupTestStore(t)
p := makeSSHProfile("Import Me", "import.local") 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 // ExportProfiles serialises all profiles as JSON. If includeSecrets is false
// the export omits credential-related fields (currently a placeholder — all // the export omits inline credential-related fields.
// secrets live in the SecretStore, not in profiles).
func (s *Store) ExportProfiles(includeSecrets bool) ([]byte, error) { func (s *Store) ExportProfiles(includeSecrets bool) ([]byte, error) {
profiles, err := s.ListProfiles() profiles, err := s.ListProfiles()
if err != nil { if err != nil {
return nil, fmt.Errorf("storage: export failed: %w", err) return nil, fmt.Errorf("storage: export failed: %w", err)
} }
if !includeSecrets {
profiles = redactProfileSecrets(profiles)
}
return json.MarshalIndent(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) { func scanProfile(row *sql.Row) (*models.Profile, error) {
p := &models.Profile{} p := &models.Profile{}
var ( var (
protocol, tagsJSON string protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString lastConn sql.NullString
favourite int favourite int
createdAt, updatedAt string createdAt, updatedAt string
) )
err := row.Scan( err := row.Scan(
@@ -634,11 +636,11 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
for rows.Next() { for rows.Next() {
p := &models.Profile{} p := &models.Profile{}
var ( var (
protocol, tagsJSON string protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString lastConn sql.NullString
favourite int favourite int
createdAt, updatedAt string createdAt, updatedAt string
) )
err := rows.Scan( err := rows.Scan(
@@ -720,6 +722,21 @@ func nullableJSON(ptr interface{}, data []byte) interface{} {
return string(data) 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 // Snippet CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+41 -21
View File
@@ -4,6 +4,8 @@ package vncclient
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -17,14 +19,14 @@ import (
// Client manages a VNC session, optionally tunnelled through SSH. // Client manages a VNC session, optionally tunnelled through SSH.
type Client struct { type Client struct {
mu sync.Mutex mu sync.Mutex
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
listener net.Listener listener net.Listener
sshClient *ssh.Client sshClient *ssh.Client
wsURL string wsURL string
connected bool connected bool
onError func(error) onError func(error)
} }
// Config holds the configuration for a VNC connection. // Config holds the configuration for a VNC connection.
@@ -34,16 +36,17 @@ type Config struct {
VNCPort int VNCPort int
// SSH tunnel settings // SSH tunnel settings
SSHTunnel bool SSHTunnel bool
SSHHost string SSHHost string
SSHPort int SSHPort int
SSHUsername string SSHUsername string
SSHAuthMethod string // "password", "publickey" SSHAuthMethod string // "password", "publickey"
SSHPassword string SSHPassword string
SSHPrivateKey []byte SSHPrivateKey []byte
SSHPassphrase string SSHPassphrase string
SSHAgentAuth bool SSHAgentAuth bool
SSHAgentConn net.Conn SSHAgentConn net.Conn
SSHHostKeyCallback ssh.HostKeyCallback
// Callbacks // Callbacks
OnError func(error) OnError func(error)
@@ -83,9 +86,12 @@ func (c *Client) Connect(cfg Config) (string, error) {
if cfg.SSHTunnel { if cfg.SSHTunnel {
// Establish SSH connection first // Establish SSH connection first
sshAddr := fmt.Sprintf("%s:%d", cfg.SSHHost, cfg.SSHPort) 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{ sshConfig := &ssh.ClientConfig{
User: cfg.SSHUsername, User: cfg.SSHUsername,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use known hosts HostKeyCallback: cfg.SSHHostKeyCallback,
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
} }
@@ -162,13 +168,19 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
token, err := newProxyToken()
if err != nil {
listener.Close()
return "", err
}
c.listener = listener c.listener = listener
port := listener.Addr().(*net.TCPAddr).Port 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 := 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) ws, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
return return
@@ -240,6 +252,14 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
return wsURL, nil 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. // Close shuts down the VNC proxy and any SSH tunnel.
func (c *Client) Close() { func (c *Client) Close() {
c.mu.Lock() 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)
}
}
}