Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 19 additions & 0 deletions src/services/transcripts/processor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from 'path';
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 +364,23 @@ 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)
const resolvedAgentsPath = path.resolve(agentsPath);
const projectRoot = 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) {
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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Node.js, does path.resolve() resolve symlinks or only normalize path segments? What API should be used to canonicalize a path through symlinked directories before enforcing a directory allowlist?

💡 Result:

In Node.js, path.resolve only normalizes path segments (resolves '.', '..', and makes absolute using cwd if needed) but does not resolve symlinks, as it performs string manipulation without filesystem access. To canonicalize a path by resolving symlinks (for secure directory allowlist enforcement), use fs.realpath (async callback), fs.realpathSync (sync), or fs.promises.realpath (async promise). These compute the canonical pathname by resolving '.', '..', and symbolic links, equivalent to POSIX realpath(3). Example for promises: import { realpath } from 'fs/promises'; const canonicalPath = await realpath(userPath); // Check if canonicalPath starts with allowed directory This prevents symlink attacks like /allowed/../etc/passwd.

Citations:


🏁 Script executed:

cat -n src/services/transcripts/processor.ts | sed -n '360,385p'

Repository: thedotmack/claude-mem

Length of output: 1295


🏁 Script executed:

cat -n src/utils/agents-md-utils.ts | sed -n '1,50p'

Repository: thedotmack/claude-mem

Length of output: 1663


🏁 Script executed:

cat -n src/services/transcripts/config.ts | sed -n '100,120p'

Repository: thedotmack/claude-mem

Length of output: 828


The allowlist check is bypassable through symlinked directories.

path.resolve() on lines 369–375 only normalizes path segments without resolving symlinks. A user-controlled watch.context.path containing a symlink component (e.g., <projectRoot>/link/AGENTS.md where link/etc/) will pass the prefix check but cause writeAgentsMd() to write outside allowed roots when the filesystem follows the symlink target. Validate the canonical path using fs.realpathSync() or fs.realpathSync.native() before the allowlist check, and pass the canonical path to writeAgentsMd().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/transcripts/processor.ts` around lines 369 - 375, The current
allowlist check uses path.resolve (resolvedAgentsPath) which does not resolve
symlinks and can be bypassed; change the code to compute the canonical path
(e.g., via fs.realpathSync or fs.realpathSync.native) for agentsPath and for the
project/home dataDir roots before performing the prefix equality checks, use
those canonical values in the allowlist conditional, and pass the canonical
agents path into writeAgentsMd (and any downstream consumers) so writes cannot
escape via symlinked components.

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
141 changes: 134 additions & 7 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,75 @@ 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';
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 'localhost' string check is dead code

req.socket.remoteAddress always returns an IP address string ('127.0.0.1', '::1', or '::ffff:127.0.0.1'). The Node.js net module never resolves a hostname back to a string name in remoteAddress, so clientIp === 'localhost' can never be true. The three IP checks above it are sufficient; this branch can be removed to avoid misleading reviewers.

Fix in Claude Code


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
* OR come from verified localhost (Bug #1932).
*/
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' ||
clientIp === 'localhost';

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

// Check bearer token if provided; if not provided, localhost is sufficient
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
const expectedToken = getAdminToken();
if (!token || token !== expectedToken) {
logger.warn('SECURITY', 'Admin endpoint: invalid bearer token', {
endpoint: req.path,
method: req.method
});
res.status(401).json({
error: 'Unauthorized',
message: 'Invalid admin token'
});
return;
}
}

next();
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.

P1 security Admin token is optional — not enforced

The Authorization header is validated only if present. A caller from localhost that sends no Authorization header skips all token checks and falls straight to next(). This means any local process can call POST /api/admin/shutdown or /api/admin/restart without knowing the admin token. The bearer-token protection added by this PR is opt-in rather than enforced, which contradicts the stated "Fix #1932: Add bearer token auth for admin endpoints" goal.

The fix is to remove the if (authHeader) guard and always require a valid token, returning 401 when the header is absent or contains an invalid value.

Fix in Claude Code

}

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 % 1000));
}
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