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:
- Dev-Only Access: All dev APIs disabled in production via NODE_ENV checks
- Path Validation: Prevent path traversal and directory escape attacks
- Backup Strategy: Create timestamped backups before all destructive operations
- Fail-Fast: Reject invalid requests immediately with clear error messages
- 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:
- path.join() normalizes paths (resolves . and ..)
- startsWith() check ensures result is within allowed directory
- fs.lstat() detects symlinks (doesn't follow them)
- 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()andstats.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.DEVguards - Relative URLs for API calls (no hardcoded production URLs)
- Vite proxy configured for dev API endpoints
- ToastProvider for user-facing errors (no
alert()orconsole.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
- Architecture Overview:
docs/architecture/dev-content-editing.md - Development Patterns:
tooling/claude/dot-claude/instructions/dev-content-editing-patterns.md - NestJS Guards: https://docs.nestjs.com/guards
- Path Traversal Prevention: https://owasp.org/www-community/attacks/Path_Traversal
Last Updated: 2026-01-12 Status: Living document - update as security patterns evolve