Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ ANTHROPIC_API_KEY=sk-ant-...
# If set, all API requests must include X-API-Key header
AUTOMAKER_API_KEY=

# Restrict file operations to these directories (comma-separated)
# Important for security in multi-tenant environments
ALLOWED_PROJECT_DIRS=/home/user/projects,/var/www
# Root directory for projects and file operations
# If set, users can only create/open projects and files within this directory
# Recommended for sandboxed deployments (Docker, restricted environments)
# Example: ALLOWED_ROOT_DIRECTORY=/projects
ALLOWED_ROOT_DIRECTORY=

# CORS origin - which domains can access the API
# Use "*" for development, set specific origin for production
Expand All @@ -34,13 +36,6 @@ PORT=3008
# Data directory for sessions and metadata
DATA_DIR=./data

# ============================================
# OPTIONAL - Additional AI Providers
# ============================================

# Google API key (for future Gemini support)
GOOGLE_API_KEY=

# ============================================
# OPTIONAL - Terminal Access
# ============================================
Expand Down
8 changes: 8 additions & 0 deletions apps/server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ RUN npm run build --workspace=apps/server
# Production stage
FROM node:20-alpine

# Install git, curl, and GitHub CLI
RUN apk add --no-cache git curl && \
GH_VERSION=$(curl -s https://api.github.com/repos/cli/cli/releases/latest | grep '"tag_name"' | cut -d '"' -f 4 | sed 's/v//') && \
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o gh.tar.gz && \
tar -xzf gh.tar.gz && \
mv gh_*_linux_amd64/bin/gh /usr/local/bin/gh && \
rm -rf gh.tar.gz gh_*_linux_amd64

WORKDIR /app

# Create non-root user
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/lib/automaker-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* Directory creation is handled separately by ensure* functions.
*/

import fs from "fs/promises";
import * as secureFs from "./secure-fs.js";
import path from "path";

/**
Expand Down Expand Up @@ -149,7 +149,7 @@ export function getBranchTrackingPath(projectPath: string): string {
*/
export async function ensureAutomakerDir(projectPath: string): Promise<string> {
const automakerDir = getAutomakerDir(projectPath);
await fs.mkdir(automakerDir, { recursive: true });
await secureFs.mkdir(automakerDir, { recursive: true });
return automakerDir;
}

Expand Down Expand Up @@ -211,6 +211,6 @@ export function getProjectSettingsPath(projectPath: string): string {
* @returns Promise resolving to the created data directory path
*/
export async function ensureDataDir(dataDir: string): Promise<string> {
await fs.mkdir(dataDir, { recursive: true });
await secureFs.mkdir(dataDir, { recursive: true });
return dataDir;
}
8 changes: 4 additions & 4 deletions apps/server/src/lib/fs-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* File system utilities that handle symlinks safely
*/

import fs from "fs/promises";
import * as secureFs from "./secure-fs.js";
import path from "path";

/**
Expand All @@ -14,7 +14,7 @@ export async function mkdirSafe(dirPath: string): Promise<void> {

// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await fs.lstat(resolvedPath);
const stats = await secureFs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
return;
Expand All @@ -36,7 +36,7 @@ export async function mkdirSafe(dirPath: string): Promise<void> {

// Path doesn't exist, create it
try {
await fs.mkdir(resolvedPath, { recursive: true });
await secureFs.mkdir(resolvedPath, { recursive: true });
} catch (error: any) {
// Handle race conditions and symlink issues
if (error.code === "EEXIST" || error.code === "ELOOP") {
Expand All @@ -52,7 +52,7 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
*/
export async function existsSafe(filePath: string): Promise<boolean> {
try {
await fs.lstat(filePath);
await secureFs.lstat(filePath);
return true;
} catch (error: any) {
if (error.code === "ENOENT") {
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/lib/image-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* - Path resolution (relative/absolute)
*/

import fs from "fs/promises";
import * as secureFs from "./secure-fs.js";
import path from "path";

/**
Expand Down Expand Up @@ -63,7 +63,7 @@ export function getMimeTypeForImage(imagePath: string): string {
* @throws Error if file cannot be read
*/
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
const imageBuffer = await fs.readFile(imagePath);
const imageBuffer = await secureFs.readFile(imagePath) as Buffer;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the image-handler.ts file and check its imports
find . -name "image-handler.ts" -type f 2>/dev/null | head -5

Repository: AutoMaker-Org/automaker

Length of output: 105


🏁 Script executed:

#!/bin/bash
# Read the image-handler.ts file and look for secureFs import and usage
cat -n ./apps/server/src/lib/image-handler.ts | head -80

Repository: AutoMaker-Org/automaker

Length of output: 2633


🏁 Script executed:

#!/bin/bash
# Find and read the secure-fs.js file
find ./apps/server/src -name "secure-fs.js" -o -name "secure-fs.ts" | head -3

Repository: AutoMaker-Org/automaker

Length of output: 101


🏁 Script executed:

#!/bin/bash
# Read the secure-fs.ts file
cat -n ./apps/server/src/lib/secure-fs.ts

Repository: AutoMaker-Org/automaker

Length of output: 5361


Fix the secureFs.readFile type signature with function overloads.

The as Buffer cast at line 66 is unsafe because secureFs.readFile returns Promise<string | Buffer> without distinguishing between calls with and without encoding. Update secure-fs.ts to use function overloads matching Node's fs.readFile behavior:

export async function readFile(filePath: string): Promise<Buffer>;
export async function readFile(filePath: string, encoding: BufferEncoding): Promise<string>;
export async function readFile(
  filePath: string,
  encoding?: BufferEncoding
): Promise<string | Buffer> {
  const validatedPath = validatePath(filePath);
  if (encoding) {
    return fs.readFile(validatedPath, encoding);
  }
  return fs.readFile(validatedPath);
}

This eliminates the need for type assertion and lets TypeScript infer the correct return type based on whether encoding is provided.

🤖 Prompt for AI Agents
In apps/server/src/lib/image-handler.ts around line 66 the code unsafely casts
secureFs.readFile to Buffer; update the secure-fs.ts implementation to provide
TypeScript function overloads matching Node's fs.readFile (one overload
returning Promise<Buffer> for calls without encoding and one returning
Promise<string> when a BufferEncoding is passed), implement the body to validate
the path and call fs.readFile with or without encoding accordingly, then remove
the `as Buffer` cast in image-handler so the call without encoding correctly
infers a Buffer return type.

const base64Data = imageBuffer.toString("base64");
const mimeType = getMimeTypeForImage(imagePath);

Expand Down
156 changes: 156 additions & 0 deletions apps/server/src/lib/secure-fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Secure File System Adapter
*
* All file I/O operations must go through this adapter to enforce
* ALLOWED_ROOT_DIRECTORY restrictions at the actual access point,
* not just at the API layer. This provides defense-in-depth security.
*/

import fs from "fs/promises";
import path from "path";
import { validatePath } from "./security.js";

/**
* Wrapper around fs.access that validates path first
*/
export async function access(filePath: string, mode?: number): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.access(validatedPath, mode);
}

/**
* Wrapper around fs.readFile that validates path first
*/
export async function readFile(
filePath: string,
encoding?: BufferEncoding
): Promise<string | Buffer> {
const validatedPath = validatePath(filePath);
if (encoding) {
return fs.readFile(validatedPath, encoding);
}
return fs.readFile(validatedPath);
}

/**
* Wrapper around fs.writeFile that validates path first
*/
export async function writeFile(
filePath: string,
data: string | Buffer,
encoding?: BufferEncoding
): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.writeFile(validatedPath, data, encoding as any);
}

/**
* Wrapper around fs.mkdir that validates path first
*/
export async function mkdir(
dirPath: string,
options?: { recursive?: boolean; mode?: number }
): Promise<string | undefined> {
const validatedPath = validatePath(dirPath);
return fs.mkdir(validatedPath, options);
}

/**
* Wrapper around fs.readdir that validates path first
*/
export async function readdir(
dirPath: string,
options?: { withFileTypes?: boolean; encoding?: BufferEncoding }
): Promise<string[] | any[]> {
const validatedPath = validatePath(dirPath);
return fs.readdir(validatedPath, options as any);
}

/**
* Wrapper around fs.stat that validates path first
*/
export async function stat(filePath: string): Promise<any> {
const validatedPath = validatePath(filePath);
return fs.stat(validatedPath);
}

/**
* Wrapper around fs.rm that validates path first
*/
export async function rm(
filePath: string,
options?: { recursive?: boolean; force?: boolean }
): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.rm(validatedPath, options);
}

/**
* Wrapper around fs.unlink that validates path first
*/
export async function unlink(filePath: string): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.unlink(validatedPath);
}

/**
* Wrapper around fs.copyFile that validates both paths first
*/
export async function copyFile(
src: string,
dest: string,
mode?: number
): Promise<void> {
const validatedSrc = validatePath(src);
const validatedDest = validatePath(dest);
return fs.copyFile(validatedSrc, validatedDest, mode);
}

/**
* Wrapper around fs.appendFile that validates path first
*/
export async function appendFile(
filePath: string,
data: string | Buffer,
encoding?: BufferEncoding
): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.appendFile(validatedPath, data, encoding as any);
}

/**
* Wrapper around fs.rename that validates both paths first
*/
export async function rename(
oldPath: string,
newPath: string
): Promise<void> {
const validatedOldPath = validatePath(oldPath);
const validatedNewPath = validatePath(newPath);
return fs.rename(validatedOldPath, validatedNewPath);
}

/**
* Wrapper around fs.lstat that validates path first
* Returns file stats without following symbolic links
*/
export async function lstat(filePath: string): Promise<any> {
const validatedPath = validatePath(filePath);
return fs.lstat(validatedPath);
}

/**
* Wrapper around path.join that returns resolved path
* Does NOT validate - use this for path construction, then pass to other operations
*/
export function joinPath(...pathSegments: string[]): string {
return path.join(...pathSegments);
}

/**
* Wrapper around path.resolve that returns resolved path
* Does NOT validate - use this for path construction, then pass to other operations
*/
export function resolvePath(...pathSegments: string[]): string {
return path.resolve(...pathSegments);
}
Loading