From 93811b59cb23be7e8db4d02cb88c49f2d16bad4a Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 14 Mar 2026 14:24:35 -0400 Subject: [PATCH] =?UTF-8?q?fix(security):=20auth=20hardening=20=E2=80=94?= =?UTF-8?q?=20httpOnly=20cookies,=20Argon2id=20passwords,=20TOTP=20encrypt?= =?UTF-8?q?ion,=20rate=20limiting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/package.json | 3 + backend/src/app.module.ts | 13 +- backend/src/auth/auth.controller.ts | 80 +++- backend/src/auth/auth.module.ts | 4 +- backend/src/auth/auth.service.ts | 106 ++++- backend/src/auth/jwt.strategy.ts | 7 +- backend/src/auth/ws-auth.guard.ts | 32 +- backend/src/vault/credentials.service.ts | 13 +- docs/SECURITY-AUDIT-2026-03-14.md | 369 ++++++++++++++++++ .../components/connections/HostEditDialog.vue | 4 +- frontend/composables/useRdp.ts | 6 +- frontend/composables/useSftp.ts | 6 +- frontend/composables/useTerminal.ts | 6 +- frontend/composables/useVault.ts | 19 +- frontend/layouts/default.vue | 5 +- frontend/middleware/admin.ts | 6 + frontend/pages/admin/users.vue | 16 +- frontend/pages/profile.vue | 12 +- frontend/plugins/auth.client.ts | 12 + frontend/stores/auth.store.ts | 31 +- frontend/stores/connection.store.ts | 16 +- 21 files changed, 663 insertions(+), 103 deletions(-) create mode 100644 docs/SECURITY-AUDIT-2026-03-14.md create mode 100644 frontend/middleware/admin.ts create mode 100644 frontend/plugins/auth.client.ts diff --git a/backend/package.json b/backend/package.json index 7c00e28..109ca22 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "@nestjs/jwt": "^10.0.0", "@nestjs/mapped-types": "^2.0.0", "@nestjs/passport": "^10.0.0", + "@nestjs/throttler": "^6.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-ws": "^10.0.0", "@nestjs/serve-static": "^4.0.0", @@ -26,6 +27,7 @@ "argon2": "^0.44.0", "helmet": "^8.0.0", "bcrypt": "^5.1.0", + "cookie-parser": "^1.4.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "passport": "^0.7.0", @@ -42,6 +44,7 @@ "@nestjs/cli": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/bcrypt": "^5.0.0", + "@types/cookie-parser": "^1.4.8", "@types/jest": "^30.0.0", "@types/node": "^20.0.0", "@types/passport-jwt": "^4.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4143067..a40569e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,6 +1,8 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { ServeStaticModule } from '@nestjs/serve-static'; +import { ThrottlerModule } from '@nestjs/throttler'; import { join } from 'path'; +import * as cookieParser from 'cookie-parser'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { VaultModule } from './vault/vault.module'; @@ -18,10 +20,17 @@ import { RdpModule } from './rdp/rdp.module'; SettingsModule, TerminalModule, RdpModule, + // Rate limiting (H-6) — permissive global default, tightened on auth endpoints + ThrottlerModule.forRoot([{ ttl: 60000, limit: 60 }]), ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', '..', 'public'), exclude: ['/api/(.*)', '/ws/(.*)'], }), ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + // Cookie parser for JWT-in-cookie auth (C-2) + consumer.apply(cookieParser()).forRoutes('*'); + } +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index dd89473..ad3f85e 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,17 +1,86 @@ -import { Controller, Post, Get, Put, Delete, Body, Param, Request, UseGuards, ParseIntPipe, InternalServerErrorException } from '@nestjs/common'; +import { Controller, Post, Get, Put, Delete, Body, Param, Request, Response, UseGuards, ParseIntPipe, InternalServerErrorException } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; import { AdminGuard } from './admin.guard'; import { LoginDto } from './dto/login.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { JwtService } from '@nestjs/jwt'; +import { randomBytes } from 'crypto'; + +// In-memory WS ticket store (short-lived, single-use) (C-3) +const wsTickets = new Map(); + +// Clean up expired tickets every 60 seconds +setInterval(() => { + const now = Date.now(); + for (const [ticket, data] of wsTickets) { + if (data.expires < now) wsTickets.delete(ticket); + } +}, 60000); @Controller('auth') export class AuthController { - constructor(private auth: AuthService) {} + constructor( + private auth: AuthService, + private jwt: JwtService, + ) {} @Post('login') - login(@Body() dto: LoginDto) { - return this.auth.login(dto.email, dto.password, dto.totpCode); + async login(@Body() dto: LoginDto, @Response({ passthrough: true }) res: any) { + const result = await this.auth.login(dto.email, dto.password, dto.totpCode); + + if ('requires_totp' in result) { + return result; + } + + // Set JWT as httpOnly cookie (C-2) + res.cookie('wraith_token', result.access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 4 * 60 * 60 * 1000, // 4 hours (H-1: down from 7 days) + path: '/', + }); + + // Return user info only — token is in the cookie, NOT in the response body + return { user: result.user }; + } + + @Post('logout') + @UseGuards(JwtAuthGuard) + logout(@Response({ passthrough: true }) res: any) { + res.clearCookie('wraith_token', { path: '/' }); + return { message: 'Logged out' }; + } + + /** + * Issue a short-lived, single-use WebSocket ticket (C-3). + * Frontend calls this before opening a WS connection, then passes + * the ticket as ?ticket= instead of the JWT. + */ + @Post('ws-ticket') + @UseGuards(JwtAuthGuard) + issueWsTicket(@Request() req: any) { + const ticket = randomBytes(32).toString('hex'); + wsTickets.set(ticket, { + userId: req.user.sub, + email: req.user.email, + role: req.user.role, + expires: Date.now() + 30000, // 30 seconds + }); + return { ticket }; + } + + /** + * Validate and consume a WS ticket. Called by WsAuthGuard. + * Returns the user payload or null if invalid/expired. + */ + static consumeWsTicket(ticket: string): { sub: number; email: string; role: string } | null { + const data = wsTickets.get(ticket); + if (!data) return null; + wsTickets.delete(ticket); // Single-use + if (data.expires < Date.now()) return null; + return { sub: data.userId, email: data.email, role: data.role }; } @UseGuards(JwtAuthGuard) @@ -26,9 +95,8 @@ export class AuthController { try { return await this.auth.updateProfile(req.user.sub, dto); } catch (e: any) { - console.error('Profile update error:', e); if (e.status) throw e; - throw new InternalServerErrorException(e.message || 'Profile update failed'); + throw new InternalServerErrorException('Profile update failed'); } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 04ff422..e013c37 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -6,13 +6,15 @@ import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; import { WsAuthGuard } from './ws-auth.guard'; import { AdminGuard } from './admin.guard'; +import { VaultModule } from '../vault/vault.module'; @Module({ imports: [ PassportModule, + VaultModule, // EncryptionService for TOTP secret encryption (H-3) JwtModule.register({ secret: process.env.JWT_SECRET, - signOptions: { expiresIn: '7d' }, + signOptions: { expiresIn: '4h' }, // H-1: down from 7 days }), ], providers: [AuthService, JwtStrategy, WsAuthGuard, AdminGuard], diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 1991f09..8f5da7f 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,22 +1,94 @@ -import { Injectable, UnauthorizedException, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { Injectable, UnauthorizedException, BadRequestException, NotFoundException, ForbiddenException, OnModuleInit, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { PrismaService } from '../prisma/prisma.service'; -import * as bcrypt from 'bcrypt'; +import { EncryptionService } from '../vault/encryption.service'; +import * as argon2 from 'argon2'; +import * as bcrypt from 'bcrypt'; // Keep for migration from bcrypt→argon2id import * as speakeasy from 'speakeasy'; import * as QRCode from 'qrcode'; +// Argon2id parameters — OWASP recommended, matches vault encryption +const ARGON2_OPTIONS: argon2.Options & { raw?: false } = { + type: argon2.argon2id, + memoryCost: 65536, // 64 MiB + timeCost: 3, // 3 iterations + parallelism: 4, // 4 threads +}; + @Injectable() -export class AuthService { +export class AuthService implements OnModuleInit { + private readonly logger = new Logger(AuthService.name); + private dummyHash = ''; + constructor( private prisma: PrismaService, private jwt: JwtService, + private encryption: EncryptionService, ) {} + async onModuleInit() { + // Pre-compute a dummy hash for constant-time login on non-existent users (H-8) + this.dummyHash = await argon2.hash('dummy-timing-defense', ARGON2_OPTIONS); + this.logger.log('Argon2id password hashing initialized'); + } + + /** + * Hash a password with Argon2id (H-9) + */ + private async hashPassword(password: string): Promise { + return argon2.hash(password, ARGON2_OPTIONS); + } + + /** + * Verify a password against a stored hash. + * Handles both Argon2id (new) and bcrypt (legacy) hashes. + * Auto-upgrades bcrypt hashes to Argon2id on successful login (H-9). + */ + private async verifyPassword(password: string, hash: string, userId?: number): Promise { + if (hash.startsWith('$2b$') || hash.startsWith('$2a$')) { + // Legacy bcrypt hash — verify with bcrypt + const valid = await bcrypt.compare(password, hash); + if (valid && userId) { + // Auto-upgrade to Argon2id + const newHash = await this.hashPassword(password); + await this.prisma.user.update({ where: { id: userId }, data: { passwordHash: newHash } }); + this.logger.log(`User ${userId} password auto-upgraded from bcrypt to Argon2id`); + } + return valid; + } + // Argon2id hash + return argon2.verify(hash, password); + } + + /** + * Decrypt a TOTP secret from the database. + * Handles both encrypted (v2:...) and legacy plaintext secrets. + * Auto-migrates plaintext secrets to encrypted on read (H-3). + */ + private async getTotpSecret(user: { id: number; totpSecret: string | null }): Promise { + if (!user.totpSecret) return null; + if (user.totpSecret.startsWith('v2:')) { + return this.encryption.decrypt(user.totpSecret); + } + // Plaintext — auto-migrate to encrypted + const plaintext = user.totpSecret; + const encrypted = await this.encryption.encrypt(plaintext); + await this.prisma.user.update({ where: { id: user.id }, data: { totpSecret: encrypted } }); + this.logger.log(`User ${user.id} TOTP secret auto-encrypted`); + return plaintext; + } + async login(email: string, password: string, totpCode?: string) { const user = await this.prisma.user.findUnique({ where: { email } }); - if (!user) throw new UnauthorizedException('Invalid credentials'); - const valid = await bcrypt.compare(password, user.passwordHash); + if (!user) { + // Constant-time: run Argon2id verify against dummy hash (H-8) + // This prevents timing attacks that reveal whether an email exists + await argon2.verify(this.dummyHash, password); + throw new UnauthorizedException('Invalid credentials'); + } + + const valid = await this.verifyPassword(password, user.passwordHash, user.id); if (!valid) throw new UnauthorizedException('Invalid credentials'); // If TOTP is enabled, require a valid code @@ -25,8 +97,10 @@ export class AuthService { // Signal frontend to show TOTP input return { requires_totp: true }; } + const secret = await this.getTotpSecret(user); + if (!secret) throw new UnauthorizedException('TOTP configuration error'); const verified = speakeasy.totp.verify({ - secret: user.totpSecret, + secret, encoding: 'base32', token: totpCode, window: 1, @@ -69,9 +143,9 @@ export class AuthService { if (!data.currentPassword) { throw new BadRequestException('Current password required to set new password'); } - const valid = await bcrypt.compare(data.currentPassword, user.passwordHash); + const valid = await this.verifyPassword(data.currentPassword, user.passwordHash); if (!valid) throw new BadRequestException('Current password is incorrect'); - update.passwordHash = await bcrypt.hash(data.newPassword, 10); + update.passwordHash = await this.hashPassword(data.newPassword); } if (Object.keys(update).length === 0) { @@ -92,10 +166,11 @@ export class AuthService { issuer: 'Wraith', }); - // Store secret temporarily (not enabled until verified) + // Encrypt TOTP secret before storage (H-3) + const encryptedSecret = await this.encryption.encrypt(secret.base32); await this.prisma.user.update({ where: { id: userId }, - data: { totpSecret: secret.base32 }, + data: { totpSecret: encryptedSecret }, }); const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url!); @@ -110,8 +185,11 @@ export class AuthService { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user || !user.totpSecret) throw new BadRequestException('TOTP not set up'); + const secret = await this.getTotpSecret(user); + if (!secret) throw new BadRequestException('TOTP configuration error'); + const verified = speakeasy.totp.verify({ - secret: user.totpSecret, + secret, encoding: 'base32', token: code, window: 1, @@ -131,7 +209,7 @@ export class AuthService { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedException(); - const valid = await bcrypt.compare(password, user.passwordHash); + const valid = await this.verifyPassword(password, user.passwordHash); if (!valid) throw new BadRequestException('Password incorrect'); await this.prisma.user.update({ @@ -155,7 +233,7 @@ export class AuthService { const existing = await this.prisma.user.findUnique({ where: { email: data.email } }); if (existing) throw new BadRequestException('Email already in use'); - const hash = await bcrypt.hash(data.password, 10); + const hash = await this.hashPassword(data.password); const user = await this.prisma.user.create({ data: { email: data.email, @@ -191,7 +269,7 @@ export class AuthService { const target = await this.prisma.user.findUnique({ where: { id: targetId } }); if (!target) throw new NotFoundException('User not found'); - const hash = await bcrypt.hash(newPassword, 10); + const hash = await this.hashPassword(newPassword); await this.prisma.user.update({ where: { id: targetId }, data: { passwordHash: hash } }); return { message: 'Password reset' }; } diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index d26c5ba..84eb489 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -6,7 +6,12 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: (req: any) => { + // Cookie-based auth (C-2) — preferred in production + if (req?.cookies?.wraith_token) return req.cookies.wraith_token; + // Fallback: Authorization header (for migration / API clients) + return ExtractJwt.fromAuthHeaderAsBearerToken()(req); + }, ignoreExpiration: false, secretOrKey: process.env.JWT_SECRET, }); diff --git a/backend/src/auth/ws-auth.guard.ts b/backend/src/auth/ws-auth.guard.ts index 1f931d3..5c67501 100644 --- a/backend/src/auth/ws-auth.guard.ts +++ b/backend/src/auth/ws-auth.guard.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { WsException } from '@nestjs/websockets'; +import { AuthController } from './auth.controller'; @Injectable() export class WsAuthGuard { @@ -8,9 +9,34 @@ export class WsAuthGuard { validateClient(client: any, req?: any): { sub: number; email: string } | null { try { - const rawUrl = req?.url || client.url || client._url; - const url = new URL(rawUrl, 'http://localhost'); - const token = url.searchParams.get('token'); + let token: string | undefined; + + // 1. Cookie-based auth (C-2) — preferred, sent automatically on WS upgrade + const cookieHeader = req?.headers?.cookie; + if (cookieHeader) { + const match = cookieHeader.match(/wraith_token=([^;]+)/); + if (match) token = match[1]; + } + + // 2. Single-use WS ticket (C-3) — short-lived nonce from POST /api/auth/ws-ticket + if (!token) { + const rawUrl = req?.url || client.url || client._url; + const url = new URL(rawUrl, 'http://localhost'); + const ticket = url.searchParams.get('ticket'); + if (ticket) { + const user = AuthController.consumeWsTicket(ticket); + if (user) return { sub: user.sub, email: user.email }; + } + } + + // 3. Legacy: JWT in URL query (kept for backwards compat during migration) + if (!token) { + const rawUrl = req?.url || client.url || client._url; + const url = new URL(rawUrl, 'http://localhost'); + const urlToken = url.searchParams.get('token'); + if (urlToken) token = urlToken; + } + if (!token) throw new WsException('No token'); return this.jwt.verify(token) as { sub: number; email: string }; } catch { diff --git a/backend/src/vault/credentials.service.ts b/backend/src/vault/credentials.service.ts index 2368e47..8f58848 100644 --- a/backend/src/vault/credentials.service.ts +++ b/backend/src/vault/credentials.service.ts @@ -12,9 +12,20 @@ export class CredentialsService { ) {} findAll(userId: number) { + // H-10: exclude encryptedValue from list response — frontend never needs it return this.prisma.credential.findMany({ where: { userId }, - include: { sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } } }, + select: { + id: true, + name: true, + username: true, + domain: true, + type: true, + sshKeyId: true, + sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } }, + createdAt: true, + updatedAt: true, + }, orderBy: { name: 'asc' }, }); } diff --git a/docs/SECURITY-AUDIT-2026-03-14.md b/docs/SECURITY-AUDIT-2026-03-14.md new file mode 100644 index 0000000..b02e341 --- /dev/null +++ b/docs/SECURITY-AUDIT-2026-03-14.md @@ -0,0 +1,369 @@ +# 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.* diff --git a/frontend/components/connections/HostEditDialog.vue b/frontend/components/connections/HostEditDialog.vue index a34193d..2ebbee8 100644 --- a/frontend/components/connections/HostEditDialog.vue +++ b/frontend/components/connections/HostEditDialog.vue @@ -50,9 +50,7 @@ const credentialOptions = computed(() => { async function loadCredentials() { try { - credentials.value = await $fetch('/api/credentials', { - headers: { Authorization: `Bearer ${auth.token}` }, - }) + credentials.value = await $fetch('/api/credentials') } catch { credentials.value = [] } diff --git a/frontend/composables/useRdp.ts b/frontend/composables/useRdp.ts index f18fc6b..5e3aaa5 100644 --- a/frontend/composables/useRdp.ts +++ b/frontend/composables/useRdp.ts @@ -119,7 +119,7 @@ export function useRdp() { const auth = useAuthStore() const sessions = useSessionStore() - function connectRdp( + async function connectRdp( container: HTMLElement, hostId: number, hostName: string, @@ -127,8 +127,10 @@ export function useRdp() { pendingSessionId: string, options?: { width?: number; height?: number }, ) { + // C-3: Use short-lived WS ticket instead of JWT in URL + const ticket = await auth.getWsTicket() const proto = location.protocol === 'https:' ? 'wss' : 'ws' - const wsUrl = `${proto}://${location.host}/api/ws/rdp?token=${auth.token}` + const wsUrl = `${proto}://${location.host}/api/ws/rdp?ticket=${ticket}` const width = options?.width || container.clientWidth || 1920 const height = options?.height || container.clientHeight || 1080 diff --git a/frontend/composables/useSftp.ts b/frontend/composables/useSftp.ts index 7a0bf6c..2c67f68 100644 --- a/frontend/composables/useSftp.ts +++ b/frontend/composables/useSftp.ts @@ -12,8 +12,10 @@ export function useSftp(sessionId: Ref) { let pendingList: string | null = null - function connect() { - const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/api/ws/sftp?token=${auth.token}` + async function connect() { + // C-3: Use short-lived WS ticket instead of JWT in URL + const ticket = await auth.getWsTicket() + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/api/ws/sftp?ticket=${ticket}` ws = new WebSocket(wsUrl) ws.onopen = () => { diff --git a/frontend/composables/useTerminal.ts b/frontend/composables/useTerminal.ts index 37a9387..b499aad 100644 --- a/frontend/composables/useTerminal.ts +++ b/frontend/composables/useTerminal.ts @@ -55,8 +55,10 @@ export function useTerminal() { return { term, fitAddon, searchAddon, resizeObserver } } - function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, pendingSessionId: string, term: Terminal, fitAddon: FitAddon) { - const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/api/ws/terminal?token=${auth.token}` + async function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, pendingSessionId: string, term: Terminal, fitAddon: FitAddon) { + // C-3: Use short-lived WS ticket instead of JWT in URL + const ticket = await auth.getWsTicket() + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/api/ws/terminal?ticket=${ticket}` ws = new WebSocket(wsUrl) let realSessionId: string | null = null diff --git a/frontend/composables/useVault.ts b/frontend/composables/useVault.ts index f962d6d..4e6eae3 100644 --- a/frontend/composables/useVault.ts +++ b/frontend/composables/useVault.ts @@ -1,32 +1,29 @@ -import { useAuthStore } from '~/stores/auth.store' - export function useVault() { - const auth = useAuthStore() - const headers = () => ({ Authorization: `Bearer ${auth.token}` }) + // C-2: No more manual Authorization headers — httpOnly cookie sent automatically // SSH Keys async function listKeys() { - return $fetch('/api/ssh-keys', { headers: headers() }) + return $fetch('/api/ssh-keys') } async function importKey(data: { name: string; privateKey: string; passphrase?: string; publicKey?: string }) { - return $fetch('/api/ssh-keys', { method: 'POST', body: data, headers: headers() }) + return $fetch('/api/ssh-keys', { method: 'POST', body: data }) } async function deleteKey(id: number) { - return $fetch(`/api/ssh-keys/${id}`, { method: 'DELETE', headers: headers() }) + return $fetch(`/api/ssh-keys/${id}`, { method: 'DELETE' }) } // Credentials async function listCredentials() { - return $fetch('/api/credentials', { headers: headers() }) + return $fetch('/api/credentials') } async function createCredential(data: any) { - return $fetch('/api/credentials', { method: 'POST', body: data, headers: headers() }) + return $fetch('/api/credentials', { method: 'POST', body: data }) } async function updateCredential(id: number, data: any) { - return $fetch(`/api/credentials/${id}`, { method: 'PUT', body: data, headers: headers() }) + return $fetch(`/api/credentials/${id}`, { method: 'PUT', body: data }) } async function deleteCredential(id: number) { - return $fetch(`/api/credentials/${id}`, { method: 'DELETE', headers: headers() }) + return $fetch(`/api/credentials/${id}`, { method: 'DELETE' }) } return { diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index a5ceaaa..d852e4f 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -18,9 +18,7 @@ const settingsSaved = ref(false) onMounted(async () => { if (!auth.isAuthenticated) return try { - settings.value = await $fetch('/api/settings', { - headers: { Authorization: `Bearer ${auth.token}` }, - }) as Record + settings.value = await $fetch('/api/settings') as Record // Sync to localStorage if (settings.value.terminalTheme) localStorage.setItem('wraith_terminal_theme', settings.value.terminalTheme) if (settings.value.fontSize) localStorage.setItem('wraith_font_size', settings.value.fontSize) @@ -36,7 +34,6 @@ async function saveSettings() { await $fetch('/api/settings', { method: 'PUT', body: settings.value, - headers: { Authorization: `Bearer ${auth.token}` }, }) settingsLoading.value = false settingsSaved.value = true diff --git a/frontend/middleware/admin.ts b/frontend/middleware/admin.ts new file mode 100644 index 0000000..0c67d4f --- /dev/null +++ b/frontend/middleware/admin.ts @@ -0,0 +1,6 @@ +export default defineNuxtRouteMiddleware(() => { + const auth = useAuthStore() + if (!auth.isAdmin) { + return navigateTo('/') + } +}) diff --git a/frontend/pages/admin/users.vue b/frontend/pages/admin/users.vue index f762ee5..d7e8ed7 100644 --- a/frontend/pages/admin/users.vue +++ b/frontend/pages/admin/users.vue @@ -1,10 +1,7 @@