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