changemaker.lite/api/src/modules/docs/docs-files.service.ts
bunker-admin c192c04c79 Security audit: fix 25 findings across API, nginx, and Docker
Addresses data exposure, access control, input validation, infrastructure
hardening, and supply chain security issues identified during audit.

Key changes:
- Strip internal fields from public campaign/profile/comment endpoints
- Restrict docs routes to CONTENT_ROLES, provisioning to SUPER_ADMIN
- Add SSE connection limits, social middleware fail-closed behavior
- Bind all non-nginx ports to 127.0.0.1, pin container image versions
- Add CSP header, conditional HSTS, token redaction in nginx logs
- Validate nav URLs, calendar schemas, video tracking batch events
- Reject default admin password placeholder, add SSRF protocol checks
- Exclude .env from Code Server, enforce RC admin password in compose
- Add Zod validation for achievement grant/revoke, webhook secret header
- Fix path traversal prefix attack, add calendar token expiry

Bunker Admin
2026-03-09 14:13:37 -06:00

333 lines
9.7 KiB
TypeScript

import { readdir, readFile, writeFile, mkdir, rm, rename, stat, copyFile } from 'fs/promises';
import { resolve as pathResolve, join, normalize, dirname, extname } from 'path';
import crypto from 'crypto';
import { env } from '../../config/env';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { cm_docs_cache_hits, cm_docs_cache_misses } from '../../utils/metrics';
export interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileNode[];
}
const DOCS_ROOT = pathResolve(env.MKDOCS_DOCS_PATH);
// Redis cache configuration
const CACHE_KEY_PREFIX = 'DOCS_CACHE:';
const TREE_CACHE_KEY = `${CACHE_KEY_PREFIX}tree`;
const TREE_CACHE_TTL = 30; // 30 seconds — short so external changes show quickly
const FILE_CONTENT_CACHE_TTL = 60 * 60; // 1 hour for file content
function hashFilePath(path: string): string {
return crypto.createHash('sha256').update(path).digest('hex').substring(0, 16);
}
/**
* Resolve and validate a relative path within MKDOCS_DOCS_PATH.
* Throws if the resolved path escapes the docs root.
*/
function safeResolve(relativePath: string): string {
const normalized = normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, '');
const resolved = pathResolve(DOCS_ROOT, normalized);
// Use DOCS_ROOT + sep to prevent prefix attacks (e.g., /mkdocs/docs-evil matching /mkdocs/docs)
if (resolved !== DOCS_ROOT && !resolved.startsWith(DOCS_ROOT + '/')) {
throw new PathTraversalError();
}
return resolved;
}
export class PathTraversalError extends Error {
constructor() {
super('Path traversal not allowed');
this.name = 'PathTraversalError';
}
}
export class FileNotFoundError extends Error {
constructor(filePath: string) {
super(`File not found: ${filePath}`);
this.name = 'FileNotFoundError';
}
}
/**
* Recursively list the file tree under MKDOCS_DOCS_PATH.
* Cached in Redis with 1-hour TTL for root calls.
*/
async function listTree(dir: string = DOCS_ROOT, relBase: string = ''): Promise<FileNode[]> {
// Try cache for root call only
if (dir === DOCS_ROOT && !relBase) {
try {
const cached = await redis.get(TREE_CACHE_KEY);
if (cached) {
cm_docs_cache_hits.inc({ type: 'tree' });
return JSON.parse(cached) as FileNode[];
}
cm_docs_cache_misses.inc({ type: 'tree' });
} catch (err) {
logger.warn('Failed to get cached docs tree:', err);
cm_docs_cache_misses.inc({ type: 'tree' });
}
}
const entries = await readdir(dir, { withFileTypes: true });
const sorted = entries
.filter(e => !e.name.startsWith('.'))
.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
// Parallel I/O instead of sequential for better performance
const nodes = await Promise.all(
sorted.map(async (entry) => {
const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
const children = await listTree(join(dir, entry.name), relPath);
return { name: entry.name, path: relPath, isDirectory: true, children };
} else {
return { name: entry.name, path: relPath, isDirectory: false };
}
})
);
// Cache root result
if (dir === DOCS_ROOT && !relBase) {
try {
await redis.setex(TREE_CACHE_KEY, TREE_CACHE_TTL, JSON.stringify(nodes));
} catch (err) {
logger.warn('Failed to cache docs tree:', err);
}
}
return nodes;
}
async function readFileContent(relativePath: string): Promise<string> {
const cacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(relativePath)}`;
// Try cache first
try {
const cached = await redis.get(cacheKey);
if (cached) {
cm_docs_cache_hits.inc({ type: 'file' });
return cached;
}
cm_docs_cache_misses.inc({ type: 'file' });
} catch (err) {
logger.warn('Failed to get cached file content:', err);
cm_docs_cache_misses.inc({ type: 'file' });
}
// Read from disk
const fullPath = safeResolve(relativePath);
try {
const content = await readFile(fullPath, 'utf-8');
// Cache the result
try {
await redis.setex(cacheKey, FILE_CONTENT_CACHE_TTL, content);
} catch (err) {
logger.warn('Failed to cache file content:', err);
}
return content;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new FileNotFoundError(relativePath);
}
throw err;
}
}
async function writeFileContent(relativePath: string, content: string): Promise<void> {
const fullPath = safeResolve(relativePath);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, content, 'utf-8');
// Invalidate file cache (content changed, structure unchanged)
const cacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(relativePath)}`;
try {
await redis.del(cacheKey);
} catch (err) {
logger.warn('Failed to invalidate file cache:', err);
}
}
async function createFile(relativePath: string, content?: string, isDirectory?: boolean): Promise<void> {
const fullPath = safeResolve(relativePath);
try {
await stat(fullPath);
throw new Error(`Already exists: ${relativePath}`);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
if ((err as Error).message?.startsWith('Already exists')) throw err;
throw err;
}
}
if (isDirectory) {
await mkdir(fullPath, { recursive: true });
} else {
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, content || '', 'utf-8');
}
// Invalidate tree cache (structure changed)
try {
await redis.del(TREE_CACHE_KEY);
} catch (err) {
logger.warn('Failed to invalidate tree cache:', err);
}
}
async function deleteFile(relativePath: string): Promise<void> {
const fullPath = safeResolve(relativePath);
try {
const info = await stat(fullPath);
if (info.isDirectory()) {
const entries = await readdir(fullPath);
if (entries.length > 0) {
throw new Error('Directory is not empty');
}
await rm(fullPath, { recursive: false });
} else {
await rm(fullPath);
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new FileNotFoundError(relativePath);
}
throw err;
}
// Invalidate both tree and file cache
const fileCacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(relativePath)}`;
try {
await Promise.all([
redis.del(TREE_CACHE_KEY),
redis.del(fileCacheKey),
]);
} catch (err) {
logger.warn('Failed to invalidate caches on delete:', err);
}
}
async function renameFile(fromPath: string, toPath: string): Promise<void> {
const fullFrom = safeResolve(fromPath);
const fullTo = safeResolve(toPath);
try {
await stat(fullFrom);
} catch {
throw new FileNotFoundError(fromPath);
}
await mkdir(dirname(fullTo), { recursive: true });
await rename(fullFrom, fullTo);
// Invalidate tree and both file paths
const fromCacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(fromPath)}`;
const toCacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(toPath)}`;
try {
await Promise.all([
redis.del(TREE_CACHE_KEY),
redis.del(fromCacheKey),
redis.del(toCacheKey),
]);
} catch (err) {
logger.warn('Failed to invalidate caches on rename:', err);
}
}
function isEditableFile(relativePath: string): boolean {
const ext = extname(relativePath).toLowerCase();
return ['.md', '.txt', '.yml', '.yaml', '.json', '.css', '.html', '.js'].includes(ext);
}
const ALLOWED_UPLOAD_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
'.pdf', '.zip',
]);
async function uploadFile(relativePath: string, sourcePath: string): Promise<void> {
const ext = extname(relativePath).toLowerCase();
if (!ALLOWED_UPLOAD_EXTENSIONS.has(ext)) {
throw new Error(`File type not allowed: ${ext}`);
}
const fullPath = safeResolve(relativePath);
await mkdir(dirname(fullPath), { recursive: true });
await copyFile(sourcePath, fullPath);
// Invalidate tree cache (structure changed)
try {
await redis.del(TREE_CACHE_KEY);
} catch (err) {
logger.warn('Failed to invalidate tree cache after upload:', err);
}
}
async function invalidateTreeCache(): Promise<void> {
try {
await redis.del(TREE_CACHE_KEY);
} catch (err) {
logger.warn('Failed to invalidate tree cache:', err);
}
}
/**
* Flatten a FileNode tree into file-only entries, then filter by
* case-insensitive query match on name or path. Name matches score
* higher so they sort first.
*/
async function searchFiles(
query: string,
limit = 5,
): Promise<{ name: string; path: string }[]> {
const tree = await listTree();
const q = query.toLowerCase();
const matches: { name: string; path: string; score: number }[] = [];
function walk(nodes: FileNode[]) {
for (const node of nodes) {
if (node.isDirectory) {
if (node.children) walk(node.children);
} else {
const nameLower = node.name.toLowerCase();
const pathLower = node.path.toLowerCase();
if (nameLower.includes(q)) {
matches.push({ name: node.name, path: node.path, score: 2 });
} else if (pathLower.includes(q)) {
matches.push({ name: node.name, path: node.path, score: 1 });
}
}
}
}
walk(tree);
matches.sort((a, b) => b.score - a.score);
return matches.slice(0, limit).map(({ name, path }) => ({ name, path }));
}
export const docsFilesService = {
listTree,
readFileContent,
writeFileContent,
createFile,
deleteFile,
renameFile,
uploadFile,
safeResolve,
isEditableFile,
invalidateTreeCache,
searchFiles,
};