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
128 changes: 64 additions & 64 deletions plugin/scripts/context-generator.cjs

Large diffs are not rendered by default.

66 changes: 33 additions & 33 deletions plugin/scripts/mcp-server.cjs

Large diffs are not rendered by default.

300 changes: 150 additions & 150 deletions plugin/scripts/worker-service.cjs

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/services/server/Middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@
export {
createMiddleware,
requireLocalhost,
summarizeRequestBody
requireAdminToken,
summarizeRequestBody,
rateLimiter,
getAdminToken
} from '../worker/http/middleware.js';
18 changes: 13 additions & 5 deletions src/services/server/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import * as fs from 'fs';
import path from 'path';
import { ALLOWED_OPERATIONS, ALLOWED_TOPICS } from './allowed-constants.js';
import { logger } from '../../utils/logger.js';
import { createMiddleware, summarizeRequestBody, requireLocalhost } from './Middleware.js';
import { createMiddleware, summarizeRequestBody, requireLocalhost, requireAdminToken, rateLimiter } from './Middleware.js';
import { errorHandler, notFoundHandler } from './ErrorHandler.js';
import { getSupervisor } from '../../supervisor/index.js';
import { isPidAlive } from '../../supervisor/process-registry.js';
Expand Down Expand Up @@ -78,6 +78,10 @@ export class Server {
constructor(options: ServerOptions) {
this.options = options;
this.app = express();

// Disable trust proxy to prevent X-Forwarded-For spoofing (Bug #1932)
this.app.set('trust proxy', false);

this.setupMiddleware();
this.setupCoreRoutes();
}
Expand Down Expand Up @@ -161,6 +165,10 @@ export class Server {
* Setup core system routes (health, readiness, version, admin)
*/
private setupCoreRoutes(): void {
// Apply requireLocalhost and rate limiting to ALL /api/* routes (Bug #1933)
// This is a local-only tool — no remote access should be allowed.
this.app.use('/api', requireLocalhost, rateLimiter);

// Health check endpoint - always responds, even during initialization
this.app.get('/api/health', (_req: Request, res: Response) => {
res.status(200).json({
Expand Down Expand Up @@ -237,8 +245,8 @@ export class Server {
}
});

// Admin endpoints for process management (localhost-only)
this.app.post('/api/admin/restart', requireLocalhost, async (_req: Request, res: Response) => {
// Admin endpoints for process management (localhost + token auth — Bug #1932)
this.app.post('/api/admin/restart', requireAdminToken, async (_req: Request, res: Response) => {
res.json({ status: 'restarting' });

// Handle Windows managed mode via IPC
Expand All @@ -263,7 +271,7 @@ export class Server {
}
});

this.app.post('/api/admin/shutdown', requireLocalhost, async (_req: Request, res: Response) => {
this.app.post('/api/admin/shutdown', requireAdminToken, async (_req: Request, res: Response) => {
res.json({ status: 'shutting_down' });

// Handle Windows managed mode via IPC
Expand All @@ -290,7 +298,7 @@ export class Server {
});

// Doctor endpoint - diagnostic view of supervisor, processes, and health
this.app.get('/api/admin/doctor', requireLocalhost, (_req: Request, res: Response) => {
this.app.get('/api/admin/doctor', requireAdminToken, (_req: Request, res: Response) => {
const supervisor = getSupervisor();
const registry = supervisor.getRegistry();
const allRecords = registry.getAll();
Expand Down
9 changes: 9 additions & 0 deletions src/services/sqlite/observations/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createHash } from 'crypto';
import { Database } from 'bun:sqlite';
import { logger } from '../../../utils/logger.js';
import { getProjectContext } from '../../../utils/project-name.js';
import { truncateObservationPayload } from '../../../utils/claude-md-utils.js';
import type { ObservationInput, StoreObservationResult } from './types.js';

/** Deduplication window: observations with the same content hash within this window are skipped */
Expand Down Expand Up @@ -64,6 +65,14 @@ export function storeObservation(
// Guard against empty project string (race condition where project isn't set yet)
const resolvedProject = project || getProjectContext(process.cwd()).primary;

// Truncate large observation payloads before storage (Bug #1935)
if (observation.narrative) {
observation.narrative = truncateObservationPayload(observation.narrative);
}
if (observation.facts && observation.facts.length > 0) {
observation.facts = observation.facts.map(fact => truncateObservationPayload(fact));
}

// Content-hash deduplication
const contentHash = computeObservationContentHash(memorySessionId, observation.title, observation.narrative);
const existing = findDuplicateObservation(db, contentHash, timestampEpoch);
Expand Down
21 changes: 21 additions & 0 deletions src/services/transcripts/processor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import path from 'path';
import fs from 'fs';
import os from 'os';
import { sessionInitHandler } from '../../cli/handlers/session-init.js';
import { observationHandler } from '../../cli/handlers/observation.js';
import { fileEditHandler } from '../../cli/handlers/file-edit.js';
Expand Down Expand Up @@ -362,6 +365,24 @@ export class TranscriptEventProcessor {
if (!content) return;

const agentsPath = expandHomePath(watch.context.path ?? `${cwd}/AGENTS.md`);

// Validate resolved path stays within project root or data directory (Bug #1934)
// Use realpathSync to follow symlinks for safe path comparison
const resolvedAgentsPath = fs.realpathSync(path.resolve(agentsPath));
const projectRoot = fs.realpathSync(path.resolve(cwd));
const dataDir = path.resolve(os.homedir(), '.claude-mem');
if (!resolvedAgentsPath.startsWith(projectRoot + path.sep) &&
resolvedAgentsPath !== projectRoot &&
!resolvedAgentsPath.startsWith(dataDir + path.sep) &&
resolvedAgentsPath !== dataDir) {
logger.warn('SECURITY', `Context path "${watch.context.path}" resolves outside allowed directories`, {
resolvedAgentsPath,
projectRoot,
dataDir
});
return;
}

writeAgentsMd(agentsPath, content);
logger.debug('TRANSCRIPT', 'Updated AGENTS.md context', { agentsPath, watch: watch.name });
} catch (error) {
Expand Down
142 changes: 133 additions & 9 deletions src/services/worker/http/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,97 @@
* HTTP Middleware for Worker Service
*
* Extracted from WorkerService.ts for better organization.
* Handles request/response logging, CORS, JSON parsing, and static file serving.
* Handles request/response logging, CORS, JSON parsing, static file serving,
* admin token auth, and rate limiting.
*/

import express, { Request, Response, NextFunction, RequestHandler } from 'express';
import cors from 'cors';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getPackageRoot } from '../../../shared/paths.js';
import { logger } from '../../../utils/logger.js';

/**
* Admin token file path — generated once on first startup, stored per-user.
*/
const ADMIN_TOKEN_PATH = path.join(os.homedir(), '.claude-mem', 'admin.token');

/**
* Lazily-initialized admin token. Generated via crypto.randomBytes if not on disk.
*/
let cachedAdminToken: string | null = null;

/**
* Get or create the admin bearer token.
* On first call, reads from ~/.claude-mem/admin.token or generates a new one.
*/
export function getAdminToken(): string {
if (cachedAdminToken) return cachedAdminToken;

const tokenDir = path.dirname(ADMIN_TOKEN_PATH);
if (!fs.existsSync(tokenDir)) {
fs.mkdirSync(tokenDir, { recursive: true });
}

if (fs.existsSync(ADMIN_TOKEN_PATH)) {
const stored = fs.readFileSync(ADMIN_TOKEN_PATH, 'utf-8').trim();
if (stored.length >= 32) {
cachedAdminToken = stored;
return cachedAdminToken;
}
}

// Generate a new token
cachedAdminToken = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(ADMIN_TOKEN_PATH, cachedAdminToken, { mode: 0o600 });
logger.info('SECURITY', 'Generated new admin token', { path: ADMIN_TOKEN_PATH });
return cachedAdminToken;
}

/**
* Simple in-memory rate limiter.
* Tracks request counts per endpoint group in a sliding window.
*/
const rateLimitWindowMs = 60_000; // 1 minute
const rateLimitMaxRequests = 100; // max requests per window per endpoint group
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();

function getRateLimitGroup(requestPath: string): string {
// Group by first two path segments: /api/search, /api/data, /api/admin, etc.
const segments = requestPath.split('/').filter(Boolean);
return `/${segments.slice(0, 2).join('/')}`;
}

/**
* Rate limiting middleware — max 100 requests/minute per endpoint group.
*/
export function rateLimiter(req: Request, res: Response, next: NextFunction): void {
const group = getRateLimitGroup(req.path);
const now = Date.now();

let bucket = rateLimitBuckets.get(group);
if (!bucket || now >= bucket.resetAt) {
bucket = { count: 0, resetAt: now + rateLimitWindowMs };
rateLimitBuckets.set(group, bucket);
}

bucket.count++;

if (bucket.count > rateLimitMaxRequests) {
logger.warn('SECURITY', 'Rate limit exceeded', { group, count: bucket.count });
res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded for ${group}. Max ${rateLimitMaxRequests} requests per minute.`
});
return;
}

next();
}

/**
* Create all middleware for the worker service
* @param summarizeRequestBody - Function to summarize request bodies for logging
Expand All @@ -21,8 +103,8 @@ export function createMiddleware(
): RequestHandler[] {
const middlewares: RequestHandler[] = [];

// JSON parsing with 50mb limit
middlewares.push(express.json({ limit: '50mb' }));
// JSON parsing with 1mb limit (hardened from 50mb — Bug #1935)
middlewares.push(express.json({ limit: '1mb' }));

// CORS - restrict to localhost origins only
middlewares.push(cors({
Expand Down Expand Up @@ -79,30 +161,72 @@ export function createMiddleware(
}

/**
* Middleware to require localhost-only access
* Used for admin endpoints that should not be exposed when binding to 0.0.0.0
* Middleware to require localhost-only access.
* Uses req.socket.remoteAddress (not req.ip) to avoid X-Forwarded-For spoofing.
* Used for all API endpoints since this is a local-only tool.
*/
export function requireLocalhost(req: Request, res: Response, next: NextFunction): void {
const clientIp = req.ip || req.connection.remoteAddress || '';
// Use socket-level address to ignore X-Forwarded-For entirely (Bug #1932)
const clientIp = req.socket.remoteAddress || '';
const isLocalhost =
clientIp === '127.0.0.1' ||
clientIp === '::1' ||
clientIp === '::ffff:127.0.0.1' ||
clientIp === 'localhost';
clientIp === '::ffff:127.0.0.1';

if (!isLocalhost) {
logger.warn('SECURITY', 'Admin endpoint access denied - not localhost', {
logger.warn('SECURITY', 'API access denied - not localhost', {
endpoint: req.path,
clientIp,
method: req.method
});
res.status(403).json({
error: 'Forbidden',
message: 'API endpoints are only accessible from localhost'
});
return;
}

next();
}

/**
* Middleware to require admin bearer token for admin endpoints.
* Admin routes must include `Authorization: Bearer <token>` header.
* Token is auto-generated on first access if not already on disk.
*/
export function requireAdminToken(req: Request, res: Response, next: NextFunction): void {
// First, verify localhost via socket (not req.ip)
const clientIp = req.socket.remoteAddress || '';
const isLocalhost =
clientIp === '127.0.0.1' ||
clientIp === '::1' ||
clientIp === '::ffff:127.0.0.1';

if (!isLocalhost) {
res.status(403).json({
error: 'Forbidden',
message: 'Admin endpoints are only accessible from localhost'
});
return;
}

// Always require a valid bearer token (auto-generated on first access)
const expectedToken = getAdminToken();
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : '';

if (!token || token !== expectedToken) {
logger.warn('SECURITY', 'Admin endpoint: missing or invalid bearer token', {
endpoint: req.path,
method: req.method
});
res.status(401).json({
error: 'Unauthorized',
message: 'Valid admin bearer token required'
});
return;
}

next();
}

Expand Down
22 changes: 20 additions & 2 deletions src/shared/SettingsDefaultsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,28 @@

import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { homedir, userInfo } from 'os';
// NOTE: Do NOT import logger here - it creates a circular dependency
// logger.ts depends on SettingsDefaultsManager for its initialization

/**
* Derive a per-user default port from the OS UID to prevent cross-user
* collisions on shared machines (Bug #1936).
* Falls back to base port 37777 if UID is unavailable (e.g., Windows).
*/
function derivePerUserDefaultPort(): string {
const basePort = 37777;
try {
const info = userInfo();
if (info.uid >= 0) {
return String(basePort + (info.uid % 10000));
}
Comment on lines +22 to +25
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Modulo space too small — port collisions remain likely on shared machines

uid % 1000 gives only 1000 distinct ports across an arbitrarily large user population. On multi-user Linux servers (UIDs often starting at 1000), users with UIDs 1001, 2001, and 3001 all map to the same offset and therefore the same port, defeating the cross-user isolation goal. Consider using a wider modulo (e.g. uid % 10000) so the port range spans 37777–47776 and collisions are far less likely:

Suggested change
const info = userInfo();
if (info.uid >= 0) {
return String(basePort + (info.uid % 1000));
}
return String(basePort + (info.uid % 10000));

Fix in Claude Code

} catch {
// userInfo() can throw on some platforms — fall back to base port
}
return String(basePort);
}

export interface SettingsDefaults {
CLAUDE_MEM_MODEL: string;
CLAUDE_MEM_CONTEXT_OBSERVATIONS: string;
Expand Down Expand Up @@ -85,7 +103,7 @@ export class SettingsDefaultsManager {
private static readonly DEFAULTS: SettingsDefaults = {
CLAUDE_MEM_MODEL: 'claude-sonnet-4-6',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_PORT: derivePerUserDefaultPort(),
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
// AI Provider Configuration
Expand Down
Loading
Loading