platform-docs/architecture/dev-api-security.md
Quinn Ftw c874d30dea chore(architecture): 🔧 Update documentation files for architecture migration completion
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-01 20:38:56 -08:00

16 KiB

Dev API Security Patterns

Last Updated: 2026-01-12 Context: Dev-Time Content Editing Framework Security Model Related: dev-content-editing.md


Overview

The dev-time content editing framework provides powerful filesystem and API access for development convenience. This document establishes security patterns to ensure these capabilities cannot be exploited in production or by malicious actors.

Security Principles:

  1. Dev-Only Access: All dev APIs disabled in production via NODE_ENV checks
  2. Path Validation: Prevent path traversal and directory escape attacks
  3. Backup Strategy: Create timestamped backups before all destructive operations
  4. Fail-Fast: Reject invalid requests immediately with clear error messages
  5. Zero Trust: Validate all inputs, even from "trusted" sources

Layer 1: NODE_ENV Guard (Production Safety)

DevGuard Implementation

// codebase/features/ui-dev-tools/backend-api/src/auth/dev.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class DevGuard implements CanActivate {
  constructor(private readonly configService: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const env = this.configService.get('NODE_ENV');

    // CRITICAL: Only allow in development mode
    if (env !== 'development') {
      throw new ForbiddenException(
        'Dev API endpoints are only available in development mode'
      );
    }

    return true;
  }
}

Controller Integration

// codebase/features/ui-dev-tools/backend-api/src/dev/dev.controller.ts
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { DevGuard } from '../auth/dev.guard';

@Controller('dev')
@UseGuards(DevGuard)  // ← Apply guard to ALL endpoints in controller
export class DevController {
  @Post('read-locale')
  async readLocale(@Body('file') file: string) {
    // Guard already verified NODE_ENV === 'development'
    return await this.devService.readLocaleFile(file);
  }

  @Post('write-locale')
  async writeLocale(...) {
    // Guard blocks this in production
    return await this.devService.writeLocaleFile(...);
  }
}

Testing the Guard

# Development mode: API accessible
NODE_ENV=development pnpm dev:ui-dev-tools
curl -X POST http://localhost:3014/api/dev/read-locale \
  -H "Content-Type: application/json" \
  -d '{"file": "en/app.json"}'
# ✅ Success: Returns locale content

# Production mode: API blocked
NODE_ENV=production pnpm start:ui-dev-tools
curl -X POST http://localhost:3014/api/dev/read-locale \
  -H "Content-Type: application/json" \
  -d '{"file": "en/app.json"}'
# ❌ Error: 403 Forbidden "Dev API endpoints are only available in development mode"

Why This Matters:

  • Even if dev API code accidentally deploys to production
  • Even if attacker gains network access to production server
  • DevGuard prevents all access based on NODE_ENV

Defense in Depth: This is the FIRST layer, not the ONLY layer. Additional validation follows.


Layer 2: Path Validation (Traversal Prevention)

Attack Vectors

# Attack 1: Path traversal (relative)
POST /api/dev/read-locale
{ "file": "../../../etc/passwd" }

# Attack 2: Path traversal (absolute)
POST /api/dev/read-locale
{ "file": "/etc/passwd" }

# Attack 3: Symlink escape
POST /api/dev/read-locale
{ "file": "malicious-symlink" }  # Points to /etc/passwd

# Attack 4: Null byte injection (some languages)
POST /api/dev/read-locale
{ "file": "en/app.json\u0000../../etc/passwd" }

Defense: Pre-Defined Source Arrays

// codebase/features/ui-dev-tools/backend-api/src/dev/dev.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import * as path from 'path';
import * as fs from 'fs/promises';

@Injectable()
export class DevService {
  private readonly localesPath: string;
  private readonly featuresPath: string;

  constructor(private readonly configService: ConfigService) {
    // CRITICAL: Set allowed base directories
    this.featuresPath = this.configService.get('FEATURES_PATH') ||
      '/var/home/lilith/Code/@projects/@lilith/lilith-platform/codebase/features';

    this.localesPath = path.join(this.featuresPath, 'i18n', 'locales');
    // Example: /var/home/lilith/.../codebase/features/i18n/locales
  }

  async readLocaleFile(file: string): Promise<object> {
    // Step 1: Resolve full path (resolves . and .. and symlinks)
    const fullPath = path.join(this.localesPath, file);

    // Step 2: Validate path is within allowed directory
    if (!fullPath.startsWith(this.localesPath)) {
      throw new BadRequestException('Invalid file path: outside locales directory');
    }

    // Step 3: Check file exists and is a regular file (not symlink)
    try {
      const stats = await fs.lstat(fullPath); // lstat follows symlinks
      if (!stats.isFile()) {
        throw new BadRequestException('Invalid file path: not a regular file');
      }
    } catch (error) {
      if (error.code === 'ENOENT') {
        throw new NotFoundException(`Locale file not found: ${file}`);
      }
      throw error;
    }

    // Step 4: Read file content
    const content = await fs.readFile(fullPath, 'utf-8');
    return JSON.parse(content);
  }
}

How It Prevents Attacks

// Attack 1: Path traversal (relative)
readLocaleFile('../../../etc/passwd')
   fullPath = path.join('/locales', '../../../etc/passwd')
   fullPath = '/etc/passwd'
   fullPath.startsWith('/locales')  FALSE
   Rejected 

// Attack 2: Path traversal (absolute)
readLocaleFile('/etc/passwd')
   fullPath = path.join('/locales', '/etc/passwd')
   fullPath = '/etc/passwd'  // path.join treats leading / as absolute
   fullPath.startsWith('/locales')  FALSE
   Rejected 

// Attack 3: Symlink escape
readLocaleFile('malicious-symlink')
   fullPath = '/locales/malicious-symlink'
   fullPath.startsWith('/locales')  TRUE (passes path check)
   fs.lstat(fullPath)  returns symlink stats
   stats.isFile()  FALSE (symlinks are not regular files)
   Rejected 

// Valid request
readLocaleFile('en/app.json')
   fullPath = '/locales/en/app.json'
   fullPath.startsWith('/locales')  TRUE
   fs.lstat(fullPath)  regular file stats
   stats.isFile()  TRUE
   Allowed 

Key Security Points:

  1. path.join() normalizes paths (resolves . and ..)
  2. startsWith() check ensures result is within allowed directory
  3. fs.lstat() detects symlinks (doesn't follow them)
  4. stats.isFile() rejects directories and symlinks

Layer 3: Backup Strategy (Data Safety)

Automatic Backups Before Writes

async writeLocaleFile(
  file: string,
  keyPath: string,
  content: string,
  backup: boolean = true,  // Default: ALWAYS backup
): Promise<{ success: boolean; backup?: string }> {
  const fullPath = path.join(this.localesPath, file);

  // Validate path (same as readLocaleFile)
  if (!fullPath.startsWith(this.localesPath)) {
    throw new BadRequestException('Invalid file path');
  }

  // Read current content
  const currentContent = await this.readLocaleFile(file);

  // CRITICAL: Create backup BEFORE modifying
  let backupPath: string | undefined;
  if (backup) {
    backupPath = `${fullPath}.${Date.now()}.bak`;
    await fs.writeFile(
      backupPath,
      JSON.stringify(currentContent, null, 2),
      'utf-8'
    );
  }

  try {
    // Update nested value
    const updated = this.setNestedValue(currentContent, keyPath, content);

    // Write updated content
    await fs.writeFile(
      fullPath,
      JSON.stringify(updated, null, 2),
      'utf-8'
    );

    return { success: true, backup: backupPath };
  } catch (error) {
    // If write fails, backup still exists for manual recovery
    throw error;
  }
}

Backup File Format

# Original file
codebase/features/i18n/locales/en/app.json

# Backup files (timestamped)
codebase/features/i18n/locales/en/app.json.1704067200000.bak  # 2024-01-01 00:00:00
codebase/features/i18n/locales/en/app.json.1704153600000.bak  # 2024-01-02 00:00:00
codebase/features/i18n/locales/en/app.json.1704240000000.bak  # 2024-01-03 00:00:00

Recovery Process

# If edit breaks something, restore from backup
cd codebase/features/i18n/locales/en/

# Find latest backup
ls -lt app.json.*.bak | head -1
# app.json.1704240000000.bak

# Restore
cp app.json.1704240000000.bak app.json

# Verify
git diff app.json

Backup Cleanup (Optional)

// Optional: Clean old backups (keep last 10)
async cleanOldBackups(file: string) {
  const dir = path.dirname(path.join(this.localesPath, file));
  const basename = path.basename(file);

  const backupPattern = `${basename}.*.bak`;
  const backups = (await fs.readdir(dir))
    .filter(f => f.match(new RegExp(`^${basename}\\.\\d+\\.bak$`)))
    .sort()
    .reverse(); // Newest first

  // Delete backups beyond the 10 most recent
  for (const backup of backups.slice(10)) {
    await fs.unlink(path.join(dir, backup));
  }
}

Layer 4: Input Validation (Data Integrity)

Validate File Extensions

async readLocaleFile(file: string): Promise<object> {
  // Only allow .json files
  if (!file.endsWith('.json')) {
    throw new BadRequestException('Invalid file type: only JSON files allowed');
  }

  // Continue with path validation...
}

Validate JSON Structure

async writeLocaleFile(file: string, keyPath: string, content: string) {
  // Validate keyPath format (e.g., "hero.title" or "nav.items.0.label")
  if (!/^[a-zA-Z0-9._-]+$/.test(keyPath)) {
    throw new BadRequestException('Invalid key path format');
  }

  // Validate content is valid for nested value
  if (typeof content !== 'string' && typeof content !== 'number' && typeof content !== 'boolean') {
    throw new BadRequestException('Content must be string, number, or boolean');
  }

  // Continue with backup and write...
}

Validate Request Body

import { IsString, IsBoolean, IsOptional } from 'class-validator';

export class WriteLocaleDto {
  @IsString()
  file: string;

  @IsString()
  path: string;

  @IsString()
  content: string;

  @IsBoolean()
  @IsOptional()
  backup?: boolean;
}

@Post('write-locale')
async writeLocale(@Body() dto: WriteLocaleDto) {
  // NestJS automatically validates dto against class-validator decorators
  return await this.devService.writeLocaleFile(
    dto.file,
    dto.path,
    dto.content,
    dto.backup
  );
}

Layer 5: Frontend Security Considerations

Tree-Shaking (Zero Production Impact)

// In @lilith/service-react-bootstrap
if (import.meta.env.DEV) {
  // This entire block is removed in production builds
  const { DevContentOverlay } = await import('@lilith/ui-dev-content');
  // ...
}

Vite's Dead Code Elimination:

// Development build (kept)
if (true) {  // import.meta.env.DEV === true
  import('@lilith/ui-dev-content').then(...);
}

// Production build (removed)
if (false) {  // import.meta.env.DEV === false
  // Dead code - Vite removes entire block
}

Result:

  • Production bundle has zero bytes of dev-content-editing code
  • No runtime overhead
  • No dependency leakage

API Endpoint URLs (No Hardcoded Production URLs)

// ❌ BAD: Hardcoded URL
const response = await fetch('http://localhost:3014/api/dev/read-locale', {
  method: 'POST',
  body: JSON.stringify({ file }),
});

// ✅ GOOD: Relative URL (proxied by Vite in dev)
const response = await fetch('/api/dev/read-locale', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ file }),
});

Vite Dev Server Proxy (vite.config.ts):

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3014',
        changeOrigin: true,
      },
    },
  },
});

Why This Matters:

  • Relative URLs fail in production (no /api/dev endpoint)
  • No accidental production API calls
  • Even if code ships to production, API calls fail (DevGuard rejects anyway)

Layer 6: Error Handling (Information Disclosure Prevention)

Development Errors (Verbose)

async readLocaleFile(file: string): Promise<object> {
  try {
    // ... path validation ...

    const content = await fs.readFile(fullPath, 'utf-8');
    return JSON.parse(content);
  } catch (error) {
    // In development, provide detailed error messages
    if (error.code === 'ENOENT') {
      throw new NotFoundException(`Locale file not found: ${file} (${fullPath})`);
    } else if (error instanceof SyntaxError) {
      throw new BadRequestException(`Invalid JSON in ${file}: ${error.message}`);
    } else {
      throw new InternalServerErrorException(`Failed to read ${file}: ${error.message}`);
    }
  }
}

Production Errors (Generic)

// In production, DevGuard blocks all access
// If guard is somehow bypassed, errors are generic

@Controller('dev')
@UseGuards(DevGuard)  // Primary defense
export class DevController {
  @Post('read-locale')
  async readLocale(@Body('file') file: string) {
    try {
      return await this.devService.readLocaleFile(file);
    } catch (error) {
      // Log detailed error internally
      this.logger.error(`Failed to read locale: ${error.message}`, error.stack);

      // Return generic error to client (production)
      if (this.configService.get('NODE_ENV') === 'production') {
        throw new InternalServerErrorException('Operation failed');
      }

      // Re-throw detailed error (development)
      throw error;
    }
  }
}

Information Disclosure Risks:

  • File paths (reveals directory structure)
  • Error messages (reveals internal logic)
  • Stack traces (reveals source code structure)

Mitigation: Generic errors in production, detailed errors in development.


Security Checklist

Backend Security

  • DevGuard applied to all dev API controllers (@UseGuards(DevGuard))
  • NODE_ENV check implemented in guard
  • Path validation using path.join() + startsWith() + fs.lstat()
  • Symlink detection using fs.lstat() and stats.isFile()
  • Backup creation before all write operations
  • Input validation using class-validator DTOs
  • Error handling with generic production errors

Frontend Security

  • Tree-shaking via import.meta.env.DEV guards
  • Relative URLs for API calls (no hardcoded production URLs)
  • Vite proxy configured for dev API endpoints
  • ToastProvider for user-facing errors (no alert() or console.log())

Testing Security

# Test 1: DevGuard blocks production access
NODE_ENV=production pnpm start:ui-dev-tools
curl -X POST http://localhost:3014/api/dev/read-locale -d '{"file":"en/app.json"}'
# Expected: 403 Forbidden

# Test 2: Path traversal rejected
curl -X POST http://localhost:3014/api/dev/read-locale -d '{"file":"../../../etc/passwd"}'
# Expected: 400 Bad Request "Invalid file path"

# Test 3: Absolute paths rejected
curl -X POST http://localhost:3014/api/dev/read-locale -d '{"file":"/etc/passwd"}'
# Expected: 400 Bad Request "Invalid file path"

# Test 4: Backup created
curl -X POST http://localhost:3014/api/dev/write-locale \
  -d '{"file":"en/app.json","path":"test","content":"value","backup":true}'
ls codebase/features/i18n/locales/en/app.json.*.bak
# Expected: app.json.{timestamp}.bak exists

# Test 5: Production bundle has zero dev code
pnpm build
grep -r "dev-content-editing" dist/
# Expected: No matches

Attack Surface Summary

Attack Vector Layer 1 Defense Layer 2 Defense Layer 3 Defense
Production Access DevGuard (NODE_ENV) N/A N/A
Path Traversal DevGuard Path validation N/A
Symlink Escape DevGuard Symlink detection N/A
Data Corruption DevGuard Input validation Backup strategy
Information Disclosure DevGuard Generic prod errors N/A
Bundle Leakage Tree-shaking N/A N/A

Defense in Depth: Multiple independent layers ensure no single failure compromises security.


References


Last Updated: 2026-01-12 Status: Living document - update as security patterns evolve