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

591 lines
16 KiB
Markdown

# 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
```typescript
// 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
```typescript
// 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
```bash
# 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
```bash
# 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
```typescript
// 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
```typescript
// 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
```typescript
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
```bash
# 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
```bash
# 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)
```typescript
// 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
```typescript
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
```typescript
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
```typescript
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)
```typescript
// 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**:
```javascript
// 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)
```typescript
// ❌ 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):
```typescript
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)
```typescript
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)
```typescript
// 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
```bash
# 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