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>
This commit is contained in:
Vantz Stockwell 2026-03-14 14:24:35 -04:00
parent 39825f5295
commit 93811b59cb
21 changed files with 663 additions and 103 deletions

View File

@ -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",

View File

@ -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('*');
}
}

View File

@ -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<string, { userId: number; email: string; role: string; expires: number }>();
// 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=<nonce> 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');
}
}

View File

@ -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],

View File

@ -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<string> {
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<boolean> {
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<string | null> {
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' };
}

View File

@ -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,
});

View File

@ -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 {
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 token = url.searchParams.get('token');
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 {

View File

@ -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' },
});
}

View File

@ -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=<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.*

View File

@ -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 = []
}

View File

@ -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

View File

@ -12,8 +12,10 @@ export function useSftp(sessionId: Ref<string | null>) {
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 = () => {

View File

@ -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

View File

@ -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 {

View File

@ -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<string, string>
settings.value = await $fetch('/api/settings') as Record<string, string>
// 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

View File

@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
if (!auth.isAdmin) {
return navigateTo('/')
}
})

View File

@ -1,10 +1,7 @@
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const auth = useAuthStore()
if (!auth.isAdmin) {
navigateTo('/')
}
interface ManagedUser {
id: number
email: string
@ -40,15 +37,11 @@ const resetPasswordLoading = ref(false)
const deletingUser = ref<ManagedUser | null>(null)
const deleteLoading = ref(false)
function headers() {
return { Authorization: `Bearer ${auth.token}` }
}
async function fetchUsers() {
loading.value = true
error.value = ''
try {
users.value = await $fetch('/api/auth/users', { headers: headers() })
users.value = await $fetch('/api/auth/users')
} catch (e: any) {
error.value = e.data?.message || 'Failed to load users'
} finally {
@ -63,7 +56,6 @@ async function createUser() {
await $fetch('/api/auth/users', {
method: 'POST',
body: createForm.value,
headers: headers(),
})
showCreateForm.value = false
createForm.value = { email: '', password: '', displayName: '', role: 'user' }
@ -90,7 +82,6 @@ async function saveEdit() {
await $fetch(`/api/auth/users/${editingUser.value.id}`, {
method: 'PUT',
body: editForm.value,
headers: headers(),
})
editingUser.value = null
showSuccess('User updated')
@ -109,7 +100,6 @@ async function resetPassword() {
await $fetch(`/api/auth/users/${resetPasswordUser.value.id}/reset-password`, {
method: 'POST',
body: { newPassword: resetPasswordValue.value },
headers: headers(),
})
resetPasswordUser.value = null
resetPasswordValue.value = ''
@ -126,7 +116,6 @@ async function resetTotp(user: ManagedUser) {
try {
await $fetch(`/api/auth/users/${user.id}/reset-totp`, {
method: 'POST',
headers: headers(),
})
showSuccess(`TOTP reset for ${user.email}`)
await fetchUsers()
@ -141,7 +130,6 @@ async function deleteUser() {
try {
await $fetch(`/api/auth/users/${deletingUser.value.id}`, {
method: 'DELETE',
headers: headers(),
})
deletingUser.value = null
showSuccess('User deleted')

View File

@ -19,15 +19,9 @@ const totpMsg = ref('')
const totpError = ref('')
const totpLoading = ref(false)
function headers() {
return { Authorization: `Bearer ${auth.token}` }
}
onMounted(async () => {
try {
const profile = await $fetch<{ id: number; email: string; displayName: string | null; totpEnabled: boolean }>('/api/auth/profile', {
headers: headers(),
})
const profile = await $fetch<{ id: number; email: string; displayName: string | null; totpEnabled: boolean }>('/api/auth/profile')
email.value = profile.email
displayName.value = profile.displayName || ''
totpEnabled.value = profile.totpEnabled
@ -58,7 +52,6 @@ async function saveProfile() {
const res = await $fetch<{ message: string }>('/api/auth/profile', {
method: 'PUT',
body,
headers: headers(),
})
profileMsg.value = res.message
currentPassword.value = ''
@ -80,7 +73,6 @@ async function setupTotp() {
try {
totpSetupData.value = await $fetch('/api/auth/totp/setup', {
method: 'POST',
headers: headers(),
})
} catch (e: any) {
totpError.value = e.data?.message || 'Failed to start TOTP setup'
@ -97,7 +89,6 @@ async function verifyTotp() {
const res = await $fetch<{ message: string }>('/api/auth/totp/verify', {
method: 'POST',
body: { code: totpCode.value },
headers: headers(),
})
totpMsg.value = res.message
totpEnabled.value = true
@ -118,7 +109,6 @@ async function disableTotp() {
const res = await $fetch<{ message: string }>('/api/auth/totp/disable', {
method: 'POST',
body: { password: totpDisablePassword.value },
headers: headers(),
})
totpMsg.value = res.message
totpEnabled.value = false

View File

@ -0,0 +1,12 @@
/**
* Auth bootstrap plugin runs before any page renders.
* Restores the auth session from the httpOnly cookie on page load/refresh.
* If the cookie is valid, fetchProfile() populates auth.user.
* If expired or missing, auth.user stays null layout guard redirects to /login.
*/
export default defineNuxtPlugin(async () => {
const auth = useAuthStore()
if (!auth.user) {
await auth.fetchProfile()
}
})

View File

@ -9,18 +9,17 @@ interface User {
}
interface LoginResponse {
access_token?: string
user?: User
requires_totp?: boolean
}
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('wraith_token') || '',
// C-2: No more token in state or localStorage — JWT lives in httpOnly cookie
user: null as User | null,
}),
getters: {
isAuthenticated: (state) => !!state.token,
isAuthenticated: (state) => !!state.user,
isAdmin: (state) => state.user?.role === 'admin',
},
actions: {
@ -37,26 +36,32 @@ export const useAuthStore = defineStore('auth', {
return { requires_totp: true }
}
this.token = res.access_token!
// Server set httpOnly cookie — we just store the user info
this.user = res.user!
localStorage.setItem('wraith_token', res.access_token!)
return res
},
logout() {
this.token = ''
async logout() {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
} catch { /* server may be unreachable */ }
this.user = null
localStorage.removeItem('wraith_token')
navigateTo('/login')
},
async fetchProfile() {
if (!this.token) return
try {
this.user = await $fetch('/api/auth/profile', {
headers: { Authorization: `Bearer ${this.token}` },
})
// Cookie sent automatically on same-origin request
this.user = await $fetch('/api/auth/profile')
} catch {
this.logout()
this.user = null
}
},
/**
* Get a short-lived WS ticket for WebSocket connections (C-3).
* The ticket replaces putting the JWT in the URL query string.
*/
async getWsTicket(): Promise<string> {
const res = await $fetch<{ ticket: string }>('/api/auth/ws-ticket', { method: 'POST' })
return res.ticket
},
},
})

View File

@ -1,5 +1,4 @@
import { defineStore } from 'pinia'
import { useAuthStore } from './auth.store'
interface Host {
id: number
@ -32,26 +31,22 @@ export const useConnectionStore = defineStore('connections', {
loading: false,
}),
actions: {
headers() {
const auth = useAuthStore()
return { Authorization: `Bearer ${auth.token}` }
},
// C-2: No more manual Authorization headers — httpOnly cookie sent automatically
async fetchHosts() {
this.loading = true
try {
this.hosts = await $fetch('/api/hosts', { headers: this.headers() })
this.hosts = await $fetch('/api/hosts')
} finally {
this.loading = false
}
},
async fetchTree() {
this.groups = await $fetch('/api/groups/tree', { headers: this.headers() })
this.groups = await $fetch('/api/groups/tree')
},
async createHost(data: Partial<Host>) {
const host = await $fetch<Host>('/api/hosts', {
method: 'POST',
body: data,
headers: this.headers(),
})
await this.fetchHosts()
return host
@ -60,14 +55,12 @@ export const useConnectionStore = defineStore('connections', {
await $fetch(`/api/hosts/${id}`, {
method: 'PUT',
body: data,
headers: this.headers(),
})
await this.fetchHosts()
},
async deleteHost(id: number) {
await $fetch(`/api/hosts/${id}`, {
method: 'DELETE',
headers: this.headers(),
})
await this.fetchHosts()
},
@ -75,7 +68,6 @@ export const useConnectionStore = defineStore('connections', {
await $fetch('/api/groups', {
method: 'POST',
body: data,
headers: this.headers(),
})
await this.fetchTree()
},
@ -83,14 +75,12 @@ export const useConnectionStore = defineStore('connections', {
await $fetch(`/api/groups/${id}`, {
method: 'PUT',
body: data,
headers: this.headers(),
})
await this.fetchTree()
},
async deleteGroup(id: number) {
await $fetch(`/api/groups/${id}`, {
method: 'DELETE',
headers: this.headers(),
})
await this.fetchTree()
},