fix: harden SSH trust and dependency security
This commit is contained in:
@@ -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.
|
||||||
Generated
+122
-104
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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, "", " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ package vncclient
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -44,6 +46,7 @@ type Config struct {
|
|||||||
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user