Go + Wails v3 + Vue 3 + SQLite + FreeRDP3 (purego) 183 tests, 76 source files, 9,910 lines of code Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
20 KiB
Wraith Remote — Security Audit Report
Date: 2026-03-14 Auditor: Claude (Opus 4.6) — secure-code-guardian + security-reviewer + ISO 27001 frameworks Scope: Full-stack — Auth, Vault, WebSocket/SSH/SFTP/RDP, Frontend, Infrastructure, ISO 27001 gap assessment Codebase: RDP-SSH-Client (Nuxt 3 + NestJS + guacd)
Executive Summary
54 unique findings across 4 audit domains after deduplication.
| Severity | Count |
|---|---|
| CRITICAL | 8 |
| HIGH | 16 |
| MEDIUM | 18 |
| LOW | 12 |
The platform has a solid encryption foundation (Argon2id vault is well-implemented) but has significant gaps in transport security, session management, infrastructure hardening, and real-time channel authorization. The most urgent issues are unauthenticated guacd exposure, JWT in localStorage/URLs, and missing session ownership checks on WebSocket channels.
CRITICAL Findings (8)
C-1. guacd exposed on all interfaces via network_mode: host
Location: docker-compose.yml:23
Domain: Infrastructure
guacd runs with network_mode: host and binds to 0.0.0.0:4822. guacd is an unauthenticated service — anyone who can reach port 4822 can initiate RDP/VNC connections to any host reachable from the Docker host. This completely bypasses all application-level authentication.
Impact: Full unauthenticated RDP/VNC access to every target host in the environment.
Fix: Remove network_mode: host. Place guacd on the internal Docker network. Bind to 127.0.0.1. The app container connects via the Docker service name guacd over the internal network.
C-2. JWT stored in localStorage (XSS → full account takeover)
Location: frontend/stores/auth.store.ts:19,42
Domain: Auth / Frontend
wraith_token JWT stored in localStorage. Any XSS payload, browser extension, or injected script can read it. The token has a 7-day lifetime with no revocation mechanism — a stolen token is valid for up to a week with no way to invalidate it.
Impact: Single XSS vulnerability → 7-day persistent access to the victim's account, including all SSH/RDP sessions and stored credentials.
Fix: Issue JWT via Set-Cookie: httpOnly; Secure; SameSite=Strict. Remove all localStorage token operations. Browser automatically attaches the cookie to every request.
C-3. JWT passed in WebSocket URL query parameters
Location: backend/src/auth/ws-auth.guard.ts:11-13, all three WS gateways
Domain: Auth / WebSocket
All WebSocket connections (/api/ws/terminal, /api/ws/sftp, /api/ws/rdp) accept JWT via ?token=<jwt> in the URL. Query parameters are logged by: web server access logs, browser history, Referrer headers, network proxies, and the application itself (main.ts:75 logs req.url).
Impact: JWT exposure in every log and monitoring system in the path. Combined with C-2, this creates multiple extraction vectors for a 7-day-lived credential.
Fix: Issue short-lived (30-second) single-use WebSocket tickets via an authenticated REST endpoint. Frontend exchanges JWT for a ticket, connects WS with ?ticket=<nonce>. Server validates and destroys the ticket on use.
C-4. No HTTPS/TLS anywhere in the stack
Location: docker-compose.yml, backend/src/main.ts
Domain: Infrastructure
No TLS termination configured. No nginx reverse proxy. No helmet() middleware. The application serves over plain HTTP. JWT tokens, SSH passwords, and TOTP codes all transit in cleartext.
Impact: Any network observer (same Wi-Fi, ISP, network tap) can intercept credentials, tokens, and terminal data.
Fix: Add nginx with TLS termination in front of the app. Install helmet() in NestJS for security headers (HSTS, X-Frame-Options, X-Content-Type-Options). Enforce HTTPS-only.
C-5. SSH host key verification auto-accepts all keys (MITM blind spot)
Location: terminal.gateway.ts:61, ssh-connection.service.ts:98-119
Domain: SSH
hostVerifier callback returns true unconditionally. New fingerprints are silently accepted. Changed fingerprints (active MITM) are also silently accepted and overwrite the stored fingerprint.
Impact: Man-in-the-middle attacker between the Wraith server and SSH target is completely invisible. Attacker gets the decrypted credentials and a live shell.
Fix: Block connections to hosts with changed fingerprints. Require explicit user acceptance via a WS round-trip for new hosts. Never auto-accept changed fingerprints.
C-6. SFTP gateway has no session ownership check (horizontal privilege escalation)
Location: sftp.gateway.ts:36-215
Domain: SFTP / Authorization
SftpGateway.handleMessage() looks up sessions by caller-supplied sessionId without verifying the requesting WebSocket client owns that session. User B can supply User A's sessionId and get full filesystem access on User A's server.
Impact: Any authenticated user can read/write/delete files on any other user's active SSH session.
Fix: Maintain a clientSessions map in SftpGateway (same pattern as TerminalGateway). Verify session ownership before every SFTP operation.
C-7. Raw Guacamole instructions forwarded to guacd without validation
Location: rdp.gateway.ts:43-47
Domain: RDP
When msg.type === 'guac', raw msg.instruction is written directly to the guacd TCP socket. Zero parsing, validation, or opcode whitelisting. The Guacamole protocol supports file, put, pipe, disconnect, and other instructions.
Impact: Authenticated user can inject arbitrary Guacamole protocol instructions — write files via guacd file transfer, crash guacd via malformed instructions, or cause protocol desync.
Fix: Parse incoming instructions via guacamole.service.ts decode(). Whitelist permitted opcodes (input, mouse, key, size, sync, disconnect). Enforce per-message size limit. Reject anything that doesn't parse.
C-8. PostgreSQL port exposed to host network
Location: docker-compose.yml:27
Domain: Infrastructure
ports: ["4211:5432"] maps PostgreSQL to the host. Without a host-level firewall rule, the database is network-accessible. Contains encrypted credentials, SSH private keys, TOTP secrets, password hashes.
Impact: Direct database access from the network. Even with password auth, the attack surface is unnecessary.
Fix: Remove the ports mapping. Only the app container needs DB access via the internal Docker network. Use docker exec for admin access.
HIGH Findings (16)
H-1. 7-day JWT with no revocation mechanism
Location: backend/src/auth/auth.module.ts:14
Domain: Auth
JWTs signed with 7-day expiry. No token blocklist, no session table, no refresh token pattern. Admin password reset, TOTP reset, and role changes do not invalidate existing tokens.
Fix: Short-lived access token (15min) + refresh token in httpOnly cookie. Or: Redis-backed blocklist checked on every request.
H-2. RDP certificate verification hardcoded to disabled
Location: rdp.gateway.ts:90, guacamole.service.ts:142
Domain: RDP
ignoreCert: true hardcoded unconditionally. Every RDP connection accepts any certificate — MITM attacks are invisible.
Fix: Store ignoreCert as a per-host setting (default false). Surface a UI warning when enabled.
H-3. TOTP secret stored as plaintext in database
Location: users table, totp_secret column
Domain: Auth / Vault
TOTP secrets stored unencrypted. If the database is compromised (C-8 makes this plausible), attacker can generate valid TOTP codes for every user with 2FA enabled, completely defeating the second factor.
Fix: Encrypt TOTP secrets using the vault's EncryptionService (Argon2id v2) before storage. Decrypt only when validating a TOTP code.
H-4. SSH private key material logged in cleartext
Location: ssh-connection.service.ts:126-129
Domain: SSH / Logging
First 40 characters of decrypted private key, key length, and passphrase existence boolean logged to stdout. Docker routes stdout to docker logs, which may be shipped to external log aggregation.
Fix: Remove lines 126-129 entirely. Log only key type and fingerprint.
H-5. Terminal keystroke data logged (passwords in sudo prompts)
Location: terminal.gateway.ts:31
Domain: WebSocket / Logging
JSON.stringify(msg).substring(0, 200) logs raw terminal keystrokes including passwords typed at sudo prompts. 200-char truncation still captures most passwords.
Fix: For msg.type === 'data', log only { type: 'data', sessionId, bytes: msg.data?.length }.
H-6. No rate limiting on authentication endpoints
Location: Entire backend — no throttler installed Domain: Auth / Infrastructure
No @nestjs/throttler, no express-rate-limit. Login endpoint accepts unlimited attempts. Combined with 6-character minimum password = viable online brute-force.
Fix: Install @nestjs/throttler. Apply tight limit on auth endpoints (10 req/min/IP). Add account lockout after repeated failures.
H-7. Container runs as root
Location: Dockerfile:19-28
Domain: Infrastructure
Final Docker stage runs as root. Any code execution vulnerability (path traversal, injection) gives root access inside the container.
Fix: Add RUN addgroup -S wraith && adduser -S wraith -G wraith and USER wraith before CMD.
H-8. Timing attack on login (bcrypt comparison)
Location: auth.service.ts login handler
Domain: Auth
Failed login for non-existent user returns faster than for existing user (skips bcrypt comparison). Enables username enumeration via timing analysis.
Fix: Always run bcrypt.compare() against a dummy hash when user not found, ensuring constant-time response.
H-9. bcrypt cost factor is 10 (below modern recommendations)
Location: auth.service.ts
Domain: Auth
bcrypt cost 10 = ~100ms on modern hardware. OWASP recommends 12+ for password hashing.
Fix: Increase to bcrypt.hash(password, 12). Existing hashes auto-upgrade on next login.
H-10. findAll credentials endpoint leaks encrypted blobs
Location: credentials.service.ts / credentials.controller.ts
Domain: Vault
The GET /api/credentials response includes encryptedValue fields. While encrypted, exposing ciphertext over the API gives attackers material for offline analysis and is unnecessary — the frontend never needs the encrypted blob.
Fix: Add select clause to exclude encryptedValue from list responses.
H-11. No upload size limit on SFTP
Location: sftp.gateway.ts:130-138
Domain: SFTP
upload handler does Buffer.from(msg.data, 'base64') with no size check. An authenticated user can send multi-gigabyte payloads, exhausting server memory.
Fix: Check msg.data.length before Buffer.from(). Enforce max (e.g., 50MB base64 = ~37MB file). Set maxPayload on WebSocket server config.
H-12. No write size limit on SFTP file editor
Location: sftp.gateway.ts:122-128
Domain: SFTP
write handler (save from Monaco editor) has no size check. MAX_EDIT_SIZE exists but is only applied to read.
Fix: Apply MAX_EDIT_SIZE check on the write path.
H-13. Shell integration injected into remote sessions without consent
Location: ssh-connection.service.ts:59-65
Domain: SSH
PROMPT_COMMAND / precmd_functions modification injected into every SSH shell for CWD tracking. Users are not informed. If this injection were modified (supply chain, code change), it would execute on every connected host.
Fix: Make opt-in. Document the behavior. Scope injection to minimum needed.
H-14. Password auth credentials logged with username and host
Location: ssh-connection.service.ts:146
Domain: SSH / Logging
Logs username@host:port for every password-authenticated connection. Creates a persistent record correlating users to targets.
Fix: Log at DEBUG only. Use hostId instead of hostname.
H-15. guacd routing via host.docker.internal bypasses container isolation
Location: docker-compose.yml:9
Domain: Infrastructure
App-to-guacd traffic routes out of the container network, through the host, and back. Unnecessary external routing path.
Fix: After fixing C-1, both services on the same Docker network. Use service name guacd as hostname.
H-16. Client-side-only admin guard
Location: frontend/pages/admin/users.vue:4-6
Domain: Frontend
if (!auth.isAdmin) navigateTo('/') is a UI redirect, not access control. Can be bypassed during hydration gaps.
Fix: Backend AdminGuard handles the real enforcement. Add proper route middleware (definePageMeta({ middleware: 'admin' })) for consistent frontend behavior.
MEDIUM Findings (18)
| # | Finding | Location | Domain |
|---|---|---|---|
| M-1 | Terminal gateway no session ownership check on data/resize/disconnect |
terminal.gateway.ts:76-79 |
WebSocket |
| M-2 | TOTP replay possible (no used-code tracking) | auth.service.ts |
Auth |
| M-3 | Email change has no verification step | users.controller.ts |
Auth |
| M-4 | Email uniqueness not enforced at DB level | users table |
Auth |
| M-5 | Password minimum length is 6 chars (NIST says 8+, OWASP says 12+) | Frontend + backend DTOs | Auth |
| M-6 | JWT_SECRET has no startup validation | auth.module.ts |
Auth |
| M-7 | TOTP secret returned in setup response (exposure window) | auth.controller.ts |
Auth |
| M-8 | Mass assignment via object spread in update endpoints | Multiple controllers | API |
| M-9 | CORS config may not behave as expected in production | main.ts:24-27 |
Infrastructure |
| M-10 | Weak .env.example defaults (DB_PASSWORD=changeme) |
.env.example |
Infrastructure |
| M-11 | Seed script runs on every container start | Dockerfile:28 |
Infrastructure |
| M-12 | File paths logged for every SFTP operation | sftp.gateway.ts:27 |
Logging |
| M-13 | SFTP delete falls through from unlink to rmdir silently |
sftp.gateway.ts:154-165 |
SFTP |
| M-14 | Unbounded TCP buffer for guacd stream (no max size) | rdp.gateway.ts:100-101 |
RDP |
| M-15 | Connection log updateMany closes sibling sessions |
ssh-connection.service.ts:178-181 |
SSH |
| M-16 | RDP security/width/height/dpi params not validated |
rdp.gateway.ts:85-89 |
RDP |
| M-17 | Frontend file upload has no client-side size validation | SftpSidebar.vue:64-73 |
Frontend |
| M-18 | Error messages from server reflected to UI verbatim | login.vue:64 |
Frontend |
LOW Findings (12)
| # | Finding | Location | Domain |
|---|---|---|---|
| L-1 | No Content Security Policy header | main.ts |
Frontend |
| L-2 | No WebSocket connection limit per user | terminal.gateway.ts:8 |
WebSocket |
| L-3 | Internal error messages forwarded to WS clients | terminal.gateway.ts:34-35, rdp.gateway.ts:51 |
WebSocket |
| L-4 | Server timezone leaked in Guacamole CONNECT | guacamole.service.ts:81-85 |
RDP |
| L-5 | SFTP event listeners re-registered on every message | sftp.gateway.ts:53-58 |
SFTP |
| L-6 | Default SSH username falls back to root |
ssh-connection.service.ts:92 |
SSH |
| L-7 | Weak seed password for default admin | seed.js |
Infrastructure |
| L-8 | SSH fingerprint derived from private key (should use public) | ssh-keys.service.ts |
Vault |
| L-9 | console.error used instead of structured logger |
Multiple files | Logging |
| L-10 | confirm() used for SFTP delete |
SftpSidebar.vue:210 |
Frontend |
| L-11 | Settings mirrored to localStorage unnecessarily | default.vue:25-27 |
Frontend |
| L-12 | No DTO validation on admin password reset | auth.controller.ts |
Auth |
ISO 27001:2022 Gap Assessment
| Control | Status | Gap |
|---|---|---|
| A.5 — Security Policies | MISSING | No security policies, incident response plan, or vulnerability disclosure process |
| A.6 — Security Roles | MISSING | No defined security responsibilities or RACI for incidents |
| A.8.1 — Asset Management | MISSING | No data classification scheme (SSH keys, TOTP secrets, credentials treated uniformly) |
| A.8.5 — Access Control | PARTIAL | Auth exists but: no brute-force protection, no account lockout, no session revocation, only 2 roles (admin/user) with no least-privilege granularity |
| A.8.9 — Configuration Mgmt | FAIL | guacd on host network, DB port exposed, no security headers |
| A.8.15 — Logging | FAIL | No structured audit log. Sensitive data IN logs. No failed login tracking |
| A.8.16 — Monitoring | MISSING | No anomaly detection, no alerting on repeated auth failures |
| A.8.24 — Cryptography | PARTIAL | Vault encryption is excellent (Argon2id). But: no TLS, tokens in URLs, TOTP unencrypted, keys in logs |
| A.8.25 — Secure Development | MISSING | No SAST, no dependency scanning, no security testing |
| A.8.28 — Secure Coding | MISSING | No documented coding standard, no input validation framework |
Prioritized Remediation Roadmap
Phase 1 — Stop the Bleeding (do this week)
| Priority | Finding | Effort | Impact |
|---|---|---|---|
| 1 | C-1: Fix guacd network_mode: host |
30 min | Closes unauthenticated backdoor to entire infrastructure |
| 2 | C-8: Remove PostgreSQL port exposure | 5 min | Closes direct DB access from network |
| 3 | C-6: Add session ownership to SFTP gateway | 1 hr | Blocks cross-user file access |
| 4 | H-4: Remove private key logging | 15 min | Stop bleeding secrets to logs |
| 5 | H-5: Stop logging terminal keystroke data | 15 min | Stop logging passwords |
| 6 | H-11: Add upload size limit | 15 min | Block memory exhaustion DoS |
Phase 2 — Auth Hardening (next sprint)
| Priority | Finding | Effort | Impact |
|---|---|---|---|
| 7 | C-2 + C-3: Move JWT to httpOnly cookie + WS ticket auth | 4 hr | Eliminates primary token theft vectors |
| 8 | C-4: Add TLS termination (nginx + Let's Encrypt) | 2 hr | Encrypts all traffic |
| 9 | H-1: Short-lived access + refresh token | 3 hr | Limits exposure window of stolen tokens |
| 10 | H-6: Rate limiting on auth endpoints | 1 hr | Blocks brute-force |
| 11 | H-3: Encrypt TOTP secrets in DB | 1 hr | Protects 2FA if DB compromised |
| 12 | M-5: Increase password minimum to 12 chars | 15 min | NIST/OWASP compliance |
Phase 3 — Channel Hardening (following sprint)
| Priority | Finding | Effort | Impact |
|---|---|---|---|
| 13 | C-5: SSH host key verification (block changed fingerprints) | 3 hr | Blocks MITM on SSH |
| 14 | C-7: Guacamole instruction validation + opcode whitelist | 2 hr | Blocks protocol injection |
| 15 | H-2: RDP cert validation (per-host configurable) | 2 hr | Blocks MITM on RDP |
| 16 | M-1: Terminal gateway session ownership check | 30 min | Blocks cross-user terminal access |
| 17 | H-7: Run container as non-root | 30 min | Limits blast radius of any RCE |
Phase 4 — Hardening & Compliance (ongoing)
Everything in MEDIUM and LOW, plus ISO 27001 documentation gaps. Most are incremental improvements that can be addressed as part of normal development.
What's Actually Good
Credit where due — these areas are solid:
- Vault encryption (Argon2id v2) — OWASP-recommended parameters, per-record salts, backwards-compatible versioning, migration endpoint. This is production-grade.
- Credential isolation —
decryptForConnection()is internal-only, never exposed over API. Correct pattern. - Per-user data isolation — Users can only see their own credentials and SSH keys (ownership checks in vault services).
- TOTP 2FA implementation — Correct TOTP flow with QR code generation (aside from the plaintext storage issue).
- Password hashing — bcrypt is correct choice (cost factor should increase, but the algorithm is right).
- Admin guards on backend —
AdminGuardproperly enforces server-side. Not just frontend checks.
Report generated by 4 parallel audit agents covering Auth/JWT/Session, Vault/Encryption/DB, WebSocket/SSH/SFTP/RDP, and Frontend/Infrastructure/ISO 27001. Deduplicated from 70+ raw findings to 54 unique issues.