# 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=` 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=`. 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** — `AdminGuard` properly 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.*