591 lines
16 KiB
Markdown
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
|