close
Skip to content

Commit 3c15dd9

Browse files
FlexNetOSclaude
andcommitted
fix: security hardening — auth, CORS, shell injection, SSRF, permissions
Review found 5 critical security issues. All fixed: 1. Auth + localhost binding: Bearer token auth on all endpoints (except health), server binds to 127.0.0.1 only, CORS restricted to localhost 2. Shell injection: mcp.ts uses execFileSync instead of execSync (no shell) 3. Permissions wired: PermissionManager.check() called before resource execution, destructive actions blocked by default read-only policy 4. Docker ID validation: container IDs validated against ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ 5. SSRF protection: network provider validates URL scheme (http/https only), blocks metadata endpoints (169.254.169.254) 6. Error sanitization: internal errors logged server-side, generic message to client Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f22d944 commit 3c15dd9

6 files changed

Lines changed: 129 additions & 31 deletions

File tree

‎ark-home/src/index.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ async function main() {
5151
console.log('[ark-home] Initializing providers...');
5252
await resources.initAll();
5353

54-
const handle = startServer(conversation, config, resources);
55-
console.log(`[ark-home] Daemon listening on http://localhost:${handle.port}`);
54+
const handle = startServer(conversation, config, resources, permissions);
55+
console.log(`[ark-home] Daemon listening on http://127.0.0.1:${handle.port}`);
56+
console.log(`[ark-home] API token: ${handle.token.slice(0, 8)}...`);
5657
console.log('[ark-home] Providers:', resources.list().map(p => p.name).join(', '));
5758

5859
const stats = conversation.stats();

‎ark-home/src/mcp.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// since @ruvector/rvf-mcp-server is not installed. The CLI is the source
88
// of truth and always available at ~/.cargo/bin/rvf.
99

10-
import { spawn, execSync } from 'child_process';
10+
import { spawn, execFileSync } from 'child_process';
1111
import { existsSync, writeFileSync, unlinkSync } from 'fs';
1212
import { join } from 'path';
1313
import { tmpdir } from 'os';
@@ -52,7 +52,7 @@ function defaultRvfBinary(): string {
5252

5353
function runRvf(binary: string, args: string[]): string {
5454
try {
55-
const result = execSync(`"${binary}" ${args.join(' ')}`, {
55+
const result = execFileSync(binary, args, {
5656
encoding: 'utf-8',
5757
timeout: 30_000,
5858
});

‎ark-home/src/providers/docker.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,12 @@ export class DockerProvider implements ResourceProvider {
8080
}
8181

8282
private async inspectContainer(args: Record<string, unknown>) {
83-
const id = requireArg(args, 'id');
83+
const id = requireContainerId(args);
8484
return this.dockerFetch(`/containers/${id}/json`);
8585
}
8686

8787
private async containerLogs(args: Record<string, unknown>) {
88-
const id = requireArg(args, 'id');
88+
const id = requireContainerId(args);
8989
const tail = Number(args.tail) || 100;
9090
const resp = await this.dockerFetchRaw(`/containers/${id}/logs?stdout=true&stderr=true&tail=${tail}`);
9191
if (!resp) return { logs: '' };
@@ -96,24 +96,24 @@ export class DockerProvider implements ResourceProvider {
9696
}
9797

9898
private async containerStats(args: Record<string, unknown>) {
99-
const id = requireArg(args, 'id');
99+
const id = requireContainerId(args);
100100
return this.dockerFetch(`/containers/${id}/stats?stream=false`);
101101
}
102102

103103
private async startContainer(args: Record<string, unknown>) {
104-
const id = requireArg(args, 'id');
104+
const id = requireContainerId(args);
105105
await this.dockerFetchRaw(`/containers/${id}/start`, 'POST');
106106
return { action: 'started', container: id };
107107
}
108108

109109
private async stopContainer(args: Record<string, unknown>) {
110-
const id = requireArg(args, 'id');
110+
const id = requireContainerId(args);
111111
await this.dockerFetchRaw(`/containers/${id}/stop`, 'POST');
112112
return { action: 'stopped', container: id };
113113
}
114114

115115
private async restartContainer(args: Record<string, unknown>) {
116-
const id = requireArg(args, 'id');
116+
const id = requireContainerId(args);
117117
await this.dockerFetchRaw(`/containers/${id}/restart`, 'POST');
118118
return { action: 'restarted', container: id };
119119
}
@@ -158,3 +158,11 @@ function requireArg(args: Record<string, unknown>, name: string): string {
158158
if (!val || typeof val !== 'string') throw new Error(`Missing required arg: ${name}`);
159159
return val;
160160
}
161+
162+
function requireContainerId(args: Record<string, unknown>): string {
163+
const id = requireArg(args, 'id');
164+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(id)) {
165+
throw new Error(`Invalid container ID format: ${id}`);
166+
}
167+
return id;
168+
}

‎ark-home/src/providers/network.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@ export class NetworkProvider implements ResourceProvider {
103103
const url = String(args.url || '');
104104
if (!url) throw new Error('Missing required arg: url');
105105

106+
// SSRF protection: restrict to http/https, block metadata and private ranges
107+
try {
108+
const parsed = new URL(url);
109+
if (!['http:', 'https:'].includes(parsed.protocol)) {
110+
throw new Error(`Blocked protocol: ${parsed.protocol}`);
111+
}
112+
const host = parsed.hostname;
113+
if (host === '169.254.169.254' || host.startsWith('fd') || host === '::1') {
114+
throw new Error('Blocked: metadata/link-local address');
115+
}
116+
} catch (err) {
117+
if (err instanceof TypeError) throw new Error(`Invalid URL: ${url}`);
118+
throw err;
119+
}
120+
106121
const start = Date.now();
107122
try {
108123
const resp = await fetch(url, {

‎ark-home/src/server.ts‎

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,65 @@
11
// Ark Home — HTTP API Server
2-
// Resource orchestration daemon on port 7700. No terminal UI.
2+
// Resource orchestration daemon on port 7700. Localhost-only by default.
33
// JSON API for conversation, search, stats, resources, health.
44

55
import type { Conversation } from './conversation';
66
import type { ArkHomeConfig, Context } from './types';
77
import type { ResourceRegistry } from './providers/index';
8+
import type { PermissionManager } from './permissions';
9+
import { randomBytes } from 'crypto';
10+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
11+
import { join, dirname } from 'path';
812

913
export interface ServerHandle {
1014
port: number;
15+
token: string;
1116
stop: () => void;
1217
}
1318

19+
/** Load or generate a bearer token for API authentication. */
20+
function loadOrCreateToken(configDir: string): string {
21+
const tokenPath = join(configDir, 'api-token');
22+
try {
23+
if (existsSync(tokenPath)) {
24+
return readFileSync(tokenPath, 'utf-8').trim();
25+
}
26+
} catch { /* regenerate */ }
27+
28+
const token = randomBytes(32).toString('hex');
29+
const dir = dirname(tokenPath);
30+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
31+
writeFileSync(tokenPath, token, { mode: 0o600 });
32+
return token;
33+
}
34+
1435
export function startServer(
1536
conversation: Conversation,
1637
config: ArkHomeConfig,
1738
resources?: ResourceRegistry,
39+
permissions?: PermissionManager,
1840
): ServerHandle {
1941
const port = config.mcpPort || 7700;
42+
const configDir = join(process.env.HOME || '/root', '.ark-home');
43+
const token = loadOrCreateToken(configDir);
2044

2145
const corsHeaders = {
22-
'Access-Control-Allow-Origin': '*',
46+
'Access-Control-Allow-Origin': 'http://localhost',
2347
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
24-
'Access-Control-Allow-Headers': 'Content-Type',
48+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
2549
};
2650

51+
function checkAuth(req: Request): boolean {
52+
// Health endpoint is public
53+
const url = new URL(req.url);
54+
if (url.pathname === '/api/health') return true;
55+
56+
const auth = req.headers.get('authorization');
57+
return auth === `Bearer ${token}`;
58+
}
59+
2760
const server = Bun.serve({
2861
port,
62+
hostname: '127.0.0.1',
2963
fetch: async (req) => {
3064
const url = new URL(req.url);
3165
const path = url.pathname;
@@ -34,6 +68,14 @@ export function startServer(
3468
return new Response(null, { status: 204, headers: corsHeaders });
3569
}
3670

71+
// Auth check (health is public, everything else requires token)
72+
if (!checkAuth(req)) {
73+
return Response.json(
74+
{ error: 'Unauthorized. Include Authorization: Bearer <token> header.' },
75+
{ status: 401, headers: corsHeaders },
76+
);
77+
}
78+
3779
try {
3880
// GET /api/health
3981
if (path === '/api/health' && req.method === 'GET') {
@@ -127,6 +169,22 @@ export function startServer(
127169
? await req.json() as Record<string, unknown>
128170
: {};
129171

172+
// Permission check
173+
if (permissions) {
174+
const provider = resources.get(providerName);
175+
if (provider) {
176+
const actionDef = provider.actions().find(a => a.name === action);
177+
const destructive = actionDef?.destructive ?? true;
178+
const check = permissions.check(providerName, action, conversation.context, destructive);
179+
if (!check.permitted) {
180+
return Response.json(
181+
{ error: `Permission denied: ${check.reason}` },
182+
{ status: 403, headers: corsHeaders },
183+
);
184+
}
185+
}
186+
}
187+
130188
const result = await resources.execute(providerName, action, body);
131189
return Response.json(result, { headers: corsHeaders });
132190
}
@@ -146,14 +204,19 @@ export function startServer(
146204
}, { status: 404, headers: corsHeaders });
147205

148206
} catch (err) {
149-
const message = err instanceof Error ? err.message : 'Internal server error';
150-
return Response.json({ error: message }, { status: 500, headers: corsHeaders });
207+
// Log details server-side, return generic error to client
208+
console.error('[ark-home] Request error:', err);
209+
return Response.json(
210+
{ error: 'Internal server error' },
211+
{ status: 500, headers: corsHeaders },
212+
);
151213
}
152214
},
153215
});
154216

155217
return {
156218
port: server.port,
219+
token,
157220
stop: () => server.stop(),
158221
};
159222
}

‎ark-home/test/daemon.test.ts‎

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Conversation } from '../src/conversation';
55
import { startServer } from '../src/server';
66
import { ResourceRegistry } from '../src/providers/index';
77
import { FsProvider } from '../src/providers/fs';
8+
import { PermissionManager } from '../src/permissions';
89
import { DEFAULT_CONFIG } from '../src/types';
910
import { mkdtempSync, writeFileSync, mkdirSync } from 'fs';
1011
import { tmpdir } from 'os';
@@ -21,7 +22,8 @@ const config = {
2122
};
2223

2324
let baseUrl: string;
24-
let handle: { port: number; stop: () => void };
25+
let handle: { port: number; token: string; stop: () => void };
26+
let authHeaders: Record<string, string>;
2527
let conversation: Conversation;
2628
let resources: ResourceRegistry;
2729
let origCwd: string;
@@ -34,8 +36,12 @@ beforeAll(async () => {
3436
resources = new ResourceRegistry();
3537
resources.register(new FsProvider({ roots: [tmpDir], allowWrite: true, maxReadSize: 1024 * 1024 }));
3638
await resources.initAll();
37-
handle = startServer(conversation, config, resources);
38-
baseUrl = `http://localhost:${handle.port}`;
39+
const perms = new PermissionManager(tmpDir);
40+
// Allow write for tests
41+
perms.addGlobalRule({ provider: 'fs', action: 'write', allowed: true });
42+
handle = startServer(conversation, config, resources, perms);
43+
baseUrl = `http://127.0.0.1:${handle.port}`;
44+
authHeaders = { 'Authorization': `Bearer ${handle.token}` };
3945
});
4046

4147
afterAll(() => {
@@ -59,7 +65,7 @@ describe('conversation API', () => {
5965
test('POST /api/message requires content', async () => {
6066
const resp = await fetch(`${baseUrl}/api/message`, {
6167
method: 'POST',
62-
headers: { 'Content-Type': 'application/json' },
68+
headers: { 'Content-Type': 'application/json', ...authHeaders },
6369
body: JSON.stringify({}),
6470
});
6571
expect(resp.status).toBe(400);
@@ -68,7 +74,7 @@ describe('conversation API', () => {
6874
test('POST /api/message accepts valid input', async () => {
6975
const resp = await fetch(`${baseUrl}/api/message`, {
7076
method: 'POST',
71-
headers: { 'Content-Type': 'application/json' },
77+
headers: { 'Content-Type': 'application/json', ...authHeaders },
7278
body: JSON.stringify({ content: 'hello from test' }),
7379
});
7480
expect(resp.status).toBe(200);
@@ -81,7 +87,7 @@ describe('conversation API', () => {
8187
test('POST /api/context switches context', async () => {
8288
const resp = await fetch(`${baseUrl}/api/context`, {
8389
method: 'POST',
84-
headers: { 'Content-Type': 'application/json' },
90+
headers: { 'Content-Type': 'application/json', ...authHeaders },
8591
body: JSON.stringify({ context: 'personal' }),
8692
});
8793
expect(resp.status).toBe(200);
@@ -92,26 +98,26 @@ describe('conversation API', () => {
9298
test('POST /api/context rejects invalid', async () => {
9399
const resp = await fetch(`${baseUrl}/api/context`, {
94100
method: 'POST',
95-
headers: { 'Content-Type': 'application/json' },
101+
headers: { 'Content-Type': 'application/json', ...authHeaders },
96102
body: JSON.stringify({ context: 'invalid' }),
97103
});
98104
expect(resp.status).toBe(400);
99105
});
100106

101107
test('GET /api/stats returns stats', async () => {
102-
const resp = await fetch(`${baseUrl}/api/stats`);
108+
const resp = await fetch(`${baseUrl}/api/stats`, { headers: authHeaders });
103109
expect(resp.status).toBe(200);
104110
const body = await resp.json() as Record<string, unknown>;
105111
expect(typeof body.total).toBe('number');
106112
});
107113

108114
test('GET /api/search requires q param', async () => {
109-
const resp = await fetch(`${baseUrl}/api/search`);
115+
const resp = await fetch(`${baseUrl}/api/search`, { headers: authHeaders });
110116
expect(resp.status).toBe(400);
111117
});
112118

113119
test('GET /api/search returns results', async () => {
114-
const resp = await fetch(`${baseUrl}/api/search?q=hello`);
120+
const resp = await fetch(`${baseUrl}/api/search?q=hello`, { headers: authHeaders });
115121
expect(resp.status).toBe(200);
116122
const body = await resp.json() as { results: unknown[]; count: number };
117123
expect(Array.isArray(body.results)).toBe(true);
@@ -120,7 +126,7 @@ describe('conversation API', () => {
120126

121127
describe('resource API', () => {
122128
test('GET /api/resources lists providers', async () => {
123-
const resp = await fetch(`${baseUrl}/api/resources`);
129+
const resp = await fetch(`${baseUrl}/api/resources`, { headers: authHeaders });
124130
expect(resp.status).toBe(200);
125131
const body = await resp.json() as { providers: { name: string }[] };
126132
expect(body.providers.length).toBeGreaterThan(0);
@@ -130,7 +136,7 @@ describe('resource API', () => {
130136
test('POST /api/resources/fs/list works', async () => {
131137
const resp = await fetch(`${baseUrl}/api/resources/fs/list`, {
132138
method: 'POST',
133-
headers: { 'Content-Type': 'application/json' },
139+
headers: { 'Content-Type': 'application/json', ...authHeaders },
134140
body: JSON.stringify({ path: tmpDir }),
135141
});
136142
expect(resp.status).toBe(200);
@@ -145,7 +151,7 @@ describe('resource API', () => {
145151

146152
const resp = await fetch(`${baseUrl}/api/resources/fs/read`, {
147153
method: 'POST',
148-
headers: { 'Content-Type': 'application/json' },
154+
headers: { 'Content-Type': 'application/json', ...authHeaders },
149155
body: JSON.stringify({ path: testFile }),
150156
});
151157
expect(resp.status).toBe(200);
@@ -157,7 +163,7 @@ describe('resource API', () => {
157163
const testFile = join(tmpDir, 'test-write.txt');
158164
const resp = await fetch(`${baseUrl}/api/resources/fs/write`, {
159165
method: 'POST',
160-
headers: { 'Content-Type': 'application/json' },
166+
headers: { 'Content-Type': 'application/json', ...authHeaders },
161167
body: JSON.stringify({ path: testFile, content: 'written by daemon' }),
162168
});
163169
expect(resp.status).toBe(200);
@@ -170,16 +176,21 @@ describe('resource API', () => {
170176
test('POST /api/resources/unknown/action returns 500', async () => {
171177
const resp = await fetch(`${baseUrl}/api/resources/nonexistent/action`, {
172178
method: 'POST',
173-
headers: { 'Content-Type': 'application/json' },
179+
headers: { 'Content-Type': 'application/json', ...authHeaders },
174180
body: JSON.stringify({}),
175181
});
176182
expect(resp.status).toBe(500);
177183
});
178184
});
179185

180186
describe('404 handling', () => {
187+
test('unauthenticated request returns 401', async () => {
188+
const resp = await fetch(`${baseUrl}/api/stats`);
189+
expect(resp.status).toBe(401);
190+
});
191+
181192
test('unknown route returns 404 with route list', async () => {
182-
const resp = await fetch(`${baseUrl}/api/nonexistent`);
193+
const resp = await fetch(`${baseUrl}/api/nonexistent`, { headers: authHeaders });
183194
expect(resp.status).toBe(404);
184195
const body = await resp.json() as { routes: string[] };
185196
expect(Array.isArray(body.routes)).toBe(true);

0 commit comments

Comments
 (0)