wraith/docs/SECURITY-AUDIT-2026-03-14.md
Vantz Stockwell 93811b59cb fix(security): auth hardening — httpOnly cookies, Argon2id passwords, TOTP encryption, rate limiting
C-2: JWT moved from localStorage to httpOnly cookie (eliminates XSS token theft)
C-3: WebSocket auth via short-lived single-use tickets (JWT no longer in URLs)
H-1: JWT expiry reduced from 7 days to 4 hours
H-3: TOTP secrets encrypted at rest with vault EncryptionService (auto-migrates plaintext)
H-6: Rate limiting via @nestjs/throttler (60 req/min global, tighten on auth)
H-8: Constant-time login — Argon2id verify runs against dummy hash for non-existent users
H-9: Password hashing upgraded from bcrypt(10) to Argon2id (auto-upgrades on login)
H-10: Credential list API no longer returns encrypted blobs
H-16: Admin pages use Nuxt route middleware instead of client-side guard
Plus: auth bootstrap plugin, cookie-parser middleware, all frontend Authorization headers removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:24:35 -04:00

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.

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 isolationdecryptForConnection() 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 backendAdminGuard 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.