Skip to content

Commit 9767eef

Browse files
authored
Merge pull request #32 from Multiplier-Labs/fix/security-audit-hardening
Implement security audit recommendations
2 parents 3d40be3 + 94b498e commit 9767eef

19 files changed

+523
-102
lines changed
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
# Security Audit: codekin
2+
3+
**Date**: 2026-03-09T09:15:00.658Z
4+
**Repository**: /home/dev/repos/codekin
5+
**Branch**: fix/workflow-model-display
6+
**Workflow Run**: 42a8460c-5d2b-470b-bcda-5ec0e74f3c5a
7+
**Session**: 2530e33f-9179-4415-a523-7cd16dc7662c
8+
9+
---
10+
11+
Now I have all the data needed. Here is the complete security audit report:
12+
13+
---
14+
15+
## Summary
16+
17+
**Overall Risk Rating: Medium**
18+
19+
| Severity | Count |
20+
|----------|-------|
21+
| Critical | 0 |
22+
| High | 3 |
23+
| Medium | 6 |
24+
| Low | 4 |
25+
| Info | 3 |
26+
27+
The codebase demonstrates good baseline security practices: timing-safe token comparison, HMAC-SHA256 webhook verification, array-based process spawning (no shell injection), and a fail-closed authentication model. No hardcoded secrets or known dependency vulnerabilities were found. The principal risks are a potential SSRF path in the Stepflow callback handler, the auth token being present in WebSocket URL query strings (server log exposure), and inadequate access controls when `allowedCallbackHosts` is unconfigured.
28+
29+
---
30+
31+
## Critical Findings
32+
33+
None identified.
34+
35+
---
36+
37+
## High Findings
38+
39+
### H1 — SSRF: Stepflow Callback URL Validation Bypassable When `allowedCallbackHosts` Is Empty
40+
41+
**File:** `server/stepflow-handler.ts:407–427`
42+
43+
**Description:**
44+
When a Stepflow webhook event includes a `callbackUrl`, the server POSTs results to that URL. The allowlist check is:
45+
46+
```typescript
47+
if (this.config.allowedCallbackHosts.length > 0 &&
48+
!this.config.allowedCallbackHosts.includes(parsedUrl.hostname)) {
49+
```
50+
51+
When `STEPFLOW_CALLBACK_HOSTS` is unset (the default), `allowedCallbackHosts` is an empty array and the guard short-circuits to `false` — **every host is permitted**. An attacker who can submit a crafted Stepflow event (or replay one) can route the callback to an internal network address, leaking session output and potentially triggering SSRF against internal services.
52+
53+
**Impact:** Internal service enumeration; data exfiltration via callback body; potential pivot to internal infrastructure.
54+
55+
**Remediation:** Invert the guard logic to deny-by-default: if `allowedCallbackHosts` is empty, block all external callbacks and log a startup warning requiring explicit configuration.
56+
57+
```typescript
58+
// Secure variant
59+
if (this.config.allowedCallbackHosts.length === 0 ||
60+
!this.config.allowedCallbackHosts.includes(parsedUrl.hostname)) {
61+
throw new Error(`Callback host '${parsedUrl.hostname}' is not in the allowlist`);
62+
}
63+
```
64+
65+
---
66+
67+
### H2 — Auth Token Exposed in WebSocket URL Query String
68+
69+
**File:** `server/ws-server.ts:287` (token extracted from `req.url` query string)
70+
71+
**Description:**
72+
The WebSocket client authenticates by appending the bearer token to the connection URL (`?token=<value>`). URLs — including query strings — are routinely captured in:
73+
- Nginx/reverse-proxy access logs
74+
- Browser history
75+
- `Referrer` headers sent to third-party resources
76+
- System logs on the host
77+
78+
An attacker with read access to any of these sources can recover the token and gain full server control.
79+
80+
**Impact:** Full authentication bypass; access to all sessions, repos, and shell operations.
81+
82+
**Remediation:** Move token delivery out of the URL. Use the WebSocket `protocols` field for initial token handshake, or send a signed `auth` message immediately after connection establishment, then close unauthenticated connections after a short timeout. Remove query-string token extraction.
83+
84+
---
85+
86+
### H3 — Auth Token Forwarded to Claude Child Processes
87+
88+
**File:** `server/claude-process.ts:68`, `server/session-manager.ts:69`
89+
90+
**Description:**
91+
The server auth token is passed into the environment of spawned Claude CLI subprocesses (noted in type comments: *"Additional env vars passed to the child process (session ID, port, token)"*). Any skill, tool, or hook executing inside Claude's context inherits this token and can use it to make authenticated API calls back to the Codekin server — escalating from a restricted Claude session to full server access.
92+
93+
**Impact:** Privilege escalation from a Claude tool/hook to full server control; potential for a malicious skill to exfiltrate all session data.
94+
95+
**Remediation:** Issue session-scoped tokens with limited permissions (e.g., approve/deny only for their own session) and pass those to child processes instead of the master auth token. Alternatively, expose a minimal local IPC socket inside the subprocess instead of reusing the main auth token.
96+
97+
---
98+
99+
## Medium Findings
100+
101+
### M1 — CORS Default Origin Permits Dev Server in Production
102+
103+
**File:** `server/ws-server.ts:227234`
104+
105+
**Description:**
106+
`Access-Control-Allow-Origin` defaults to `http://localhost:5173` when `CORS_ORIGIN` is not set. If the server is deployed without this variable, cross-origin requests from `localhost:5173` (or a page that can redirect through it) are accepted. More practically, the wildcard case is never used, but an absent `CORS_ORIGIN` silently allows the Vite dev origin.
107+
108+
**Impact:** Unintended cross-origin access from local development tooling if `CORS_ORIGIN` is not explicitly set in production.
109+
110+
**Remediation:** At startup, enforce that `CORS_ORIGIN` is explicitly configured in non-development environments. Log a startup error and refuse to bind if the variable is missing in production mode.
111+
112+
---
113+
114+
### M2Auth Token Appears in WebSocket URL Visible to All Joined Clients
115+
116+
**File:** `server/ws-server.ts:302–344`
117+
118+
**Description:**
119+
When a new client joins a session, the server replays the last 500 messages from `outputHistory`. If any prior command output or log entry contained the connection URL (with token), it will be replayed to subsequent clients. There is no scrubbing of sensitive strings in history replay.
120+
121+
**Impact:** Token leakage to any user who joins a session after someone whose token appeared in output.
122+
123+
**Remediation:** Add token redaction to history replay, or switch to the non-URL token delivery mechanism recommended in H2.
124+
125+
---
126+
127+
### M3No Rate Limiting on WebSocket Connections
128+
129+
**File:** `server/ws-server.ts:284–290`
130+
131+
**Description:**
132+
HTTP REST endpoints with webhook functionality have rate limiters, but the WebSocket upgrade endpoint has no connection rate limiting. An attacker can open thousands of unauthenticated WebSocket connections before token verification completes, exhausting file descriptors or memory.
133+
134+
**Impact:** Denial of service against the WebSocket server.
135+
136+
**Remediation:** Apply a rate limiter (e.g., `express-rate-limit` or `ws`-level connection counting per IP) to the HTTP upgrade path before the WebSocket handshake is completed.
137+
138+
---
139+
140+
### M4Session History Stored Without Encryption at Rest
141+
142+
**File:** `server/session-manager.ts` (sessions persisted to `~/.codekin/sessions.json`)
143+
144+
**Description:**
145+
Session metadata (names, working directories, model choices) is written to disk in plaintext. If the host is compromised or the file is world-readable (default umask does not guarantee 0600), an attacker can enumerate all managed repositories and sessions.
146+
147+
**Impact:** Information disclosure of repository paths and session metadata.
148+
149+
**Remediation:** Explicitly set file permissions to 0600 on write, or at minimum add a startup check that warns if the file is group/world readable.
150+
151+
---
152+
153+
### M5Bash Command Logging May Expose Secrets
154+
155+
**File:** `server/approval-manager.ts`, `server/session-manager.ts`
156+
157+
**Description:**
158+
Bash commands submitted to Claude are logged verbatim as part of approval and audit flows. Commands that include credentials (e.g., `curl -H 'Authorization: Bearer sk-...'`, `git clone https://user:password@host`) will appear in server logs in plaintext.
159+
160+
**Impact:** Credential exposure in log files, which may be forwarded to log aggregators or accessible to multiple users.
161+
162+
**Remediation:** Apply regex-based secret redaction to logged command strings before writing them to any log sink. At minimum, redact patterns matching `Authorization:`, `Bearer `, `password`, and common API key patterns.
163+
164+
---
165+
166+
### M6`execSync('claude --version')` in Server Startup
167+
168+
**File:** `server/ws-server.ts:101`
169+
170+
```typescript
171+
claudeVersion = execSync('claude --version', { timeout: 5000 }).toString().trim()
172+
```
173+
174+
**Description:**
175+
This is the only use of `execSync` in the codebase. While the command itself is safe (no user input), `execSync` uses a shell by default on some platforms and blocks the event loop. If `claude` is not found, the server startup flow may throw synchronously. More importantly, if `PATH` is manipulated in the deployment environment, a different binary named `claude` could be executed.
176+
177+
**Impact:** Lowpotential for PATH hijacking in misconfigured deployment; minor event-loop blocking at startup.
178+
179+
**Remediation:** Replace with `execFileSync('claude', ['--version'], { timeout: 5000 })` to avoid shell invocation. Alternatively, use `spawnSync` with an absolute path.
180+
181+
---
182+
183+
## Low Findings
184+
185+
### L1Missing HTTP Security Headers
186+
187+
**File:** `server/ws-server.ts` (global middleware)
188+
189+
**Description:**
190+
No security headers are set on HTTP responses: no `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`, `X-Content-Type-Options`, or `Permissions-Policy`. The app relies on being deployed behind nginx, but headers should also be set at the application layer as defense-in-depth.
191+
192+
**Remediation:** Add `helmet` middleware (`npm install helmet`) or set headers manually in the Express middleware chain.
193+
194+
---
195+
196+
### L2No Audit Log for Approval Decisions
197+
198+
**File:** `server/approval-manager.ts`
199+
200+
**Description:**
201+
When a user approves or denies a Claude tool/bash action, the decision is sent to the process but not persisted to any audit log. There is no record of who approved which command in which session at what time.
202+
203+
**Impact:** No forensic trail for post-incident analysis; inability to detect approval abuse.
204+
205+
**Remediation:** Write structured approval/denial events to an append-only log file or database table, including timestamp, session ID, command, and decision.
206+
207+
---
208+
209+
### L3Webhook Workspace Cleanup Not Validated on Failure
210+
211+
**File:** `server/webhook-workspace.ts`
212+
213+
**Description:**
214+
Workspaces are cleaned up with `rmSync(workspacePath, { recursive: true, force: true })`. If cleanup is skipped due to an early error return, the workspace (containing a full git clone of a private repository) persists on disk indefinitely.
215+
216+
**Impact:** Gradual disk exhaustion; potential exposure of source code from private repositories.
217+
218+
**Remediation:** Wrap cleanup in a `finally` block so it always executes, and log a warning if it fails.
219+
220+
---
221+
222+
### L4Repository/Owner Identifier Validation in Clone Endpoint
223+
224+
**File:** `server/upload-routes.ts`
225+
226+
**Description:**
227+
The clone endpoint validates `owner` and `name` with `/^[\w.-]+$/`. While this prevents obvious path traversal, the regex permits names like `...` or `.git` which could be meaningful to git in certain contexts. The validation relies entirely on `gh repo clone` doing the right thing downstream.
228+
229+
**Impact:** Low`gh` CLI handles these cases, but defense-in-depth suggests tighter validation.
230+
231+
**Remediation:** Add an explicit blocklist for names that begin with `.` or consist entirely of dots, and enforce a minimum length.
232+
233+
---
234+
235+
## Secrets & Credentials Exposure
236+
237+
**No hardcoded secrets were found in any committed source file.**
238+
239+
The `git grep` scan across all TypeScript, JavaScript, JSON, YAML, and environment files produced only benign matches:
240+
241+
| File | Match Type | Notes |
242+
|------|-----------|-------|
243+
| `server/auth-routes.ts`, `session-routes.ts`, `upload-routes.ts` | `token` (variable name) | Auth token verification logicno literal values |
244+
| `server/config.ts` | `AUTH_TOKEN`, `AUTH_TOKEN_FILE` | Environment variable names only |
245+
| `server/crypto-utils.ts` | `secret` (parameter name) | HMAC function signature — no literal value |
246+
| `server/stepflow-handler.ts` | `STEPFLOW_WEBHOOK_SECRET` | Environment variable reference only |
247+
| `.codekin/settings.example.json:8` | `authFile` | Path to token file — example template, no actual token |
248+
| `package-lock.json` | `tokens` | CSS tokenizer package names — irrelevant |
249+
250+
All secrets (API keys, auth tokens, webhook secrets) are loaded exclusively from environment variables or gitignored local files (`~/.codekin/auth-token`, `.codekin/settings.json`). Both `.env*` and `.codekin/settings.json` are correctly listed in `.gitignore`.
251+
252+
`npm audit` reports **0 vulnerabilities** across all dependencies.
253+
254+
---
255+
256+
## Recommendations
257+
258+
1. **[High] Fix the SSRF in Stepflow callback handling (H1).** Invert the `allowedCallbackHosts` guard to deny-by-default. This is a one-line fix with high security impact. Require explicit host configuration before enabling Stepflow webhooks.
259+
260+
2. **[High] Move auth token off the WebSocket URL (H2).** Implement a post-connect `auth` message handshake or use the `protocols` field. This also resolves H3 indirectly if session-scoped tokens are issued per-connection.
261+
262+
3. **[High] Stop forwarding the master auth token to Claude child processes (H3).** Issue a least-privilege session token (approve/deny only) for the subprocess environment. This limits the blast radius of any malicious skill or hook.
263+
264+
4. **[Medium] Add HTTP security headers (L1 combined with M concern).** Install `helmet` or manually set `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy`, and `Strict-Transport-Security`. This is a single middleware addition.
265+
266+
5. **[Medium] Rate-limit WebSocket connections (M3).** Apply a per-IP connection rate limit to the `/` upgrade endpoint before the handshake completes. Consider a maximum simultaneous connection count per IP.
267+
268+
6. **[Medium] Require explicit `CORS_ORIGIN` in production (M1).** Add a startup check that exits with an error if `NODE_ENV=production` and `CORS_ORIGIN` is not set or is a `localhost` URL.
269+
270+
7. **[Medium] Implement secret redaction in command logging (M5).** Apply regex scrubbing to logged bash command strings to prevent credential leakage into log files. At minimum, redact `Bearer `, `Authorization:`, and URL-embedded passwords.
271+
272+
8. **[Medium] Enforce 0600 permissions on all persisted state files (M4).** Use `fs.chmodSync(path, 0o600)` immediately after writing `sessions.json`, `repo-approvals.json`, and any session-scoped files.
273+
274+
9. **[Low] Add a structured audit log for approval decisions (L2).** Persist a JSON record for every tool-use approval or denial, including session ID, working directory, command, decision, and UTC timestamp. This is essential for post-incident forensics.
275+
276+
10. **[Low] Wrap webhook workspace cleanup in `finally` blocks (L3).** Ensure workspace directories are always deleted on session completion or error, and log a warning (with the workspace path) if deletion fails, so operators can manually clean up.

dist/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
77
<title>Codekin</title>
8-
<script type="module" crossorigin src="/assets/index-DpGwZSiY.js"></script>
8+
<script type="module" crossorigin src="/assets/index-mRBYLJ4V.js"></script>
99
<link rel="stylesheet" crossorigin href="/assets/index-dNFIrKVU.css">
1010
</head>
1111
<body class="bg-neutral-12 text-neutral-2">

server/approval-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export class ApprovalManager {
211211
try {
212212
mkdirSync(DATA_DIR, { recursive: true })
213213
const tmp = REPO_APPROVALS_FILE + '.tmp'
214-
writeFileSync(tmp, JSON.stringify(data, null, 2))
214+
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 })
215215
renameSync(tmp, REPO_APPROVALS_FILE)
216216
} catch (err) {
217217
console.error('Failed to persist repo approvals:', err)

server/claude-process.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { randomUUID } from 'crypto'
1818
import { homedir } from 'os'
1919
import type { ClaudeEvent, ClaudeSystemInit, ClaudeControlRequest, ClaudeResultEvent, ClaudeStreamEvent, TaskItem, PromptQuestion } from './types.js'
2020
import { SCREENSHOTS_DIR } from './config.js'
21+
import { redactSecrets } from './crypto-utils.js'
2122

2223
/** Typed event map for ClaudeProcess. Each key maps to the listener argument tuple. */
2324
export interface ClaudeProcessEvents {
@@ -370,7 +371,7 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> {
370371
this.emit('control_request', request_id, toolName, toolInput)
371372
} else if (toolName === 'Bash') {
372373
// Bash runs arbitrary commands — forward to session manager for registry check / UI prompt
373-
console.log(`[control_request] forwarding Bash to session manager: ${String(toolInput.command || '').slice(0, 80)}`)
374+
console.log(`[control_request] forwarding Bash to session manager: ${redactSecrets(String(toolInput.command || '').slice(0, 80))}`)
374375
this.emit('control_request', request_id, toolName, toolInput)
375376
} else {
376377
// All other tools (Write, Edit, Read, Glob, Grep, Task, TodoWrite,

server/config.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,19 @@ import { readFileSync, existsSync, realpathSync } from 'fs'
1717
/** Main server port (WebSocket + REST + uploads). */
1818
export const PORT = parseInt(process.env.PORT || '32352', 10)
1919

20-
/** CORS allowed origin. Defaults to localhost dev server; set explicitly for production. */
20+
/** CORS allowed origin. Defaults to localhost dev server; must be set explicitly for production. */
2121
export const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:5173'
2222

23+
// Warn at startup if production is using the default localhost CORS origin
24+
if (process.env.NODE_ENV === 'production' && !process.env.CORS_ORIGIN) {
25+
console.error('[config] ERROR: CORS_ORIGIN is not set in production. Defaulting to localhost is insecure.')
26+
console.error('[config] Set CORS_ORIGIN to the production frontend origin (e.g. https://example.com).')
27+
process.exit(1)
28+
}
29+
if (process.env.NODE_ENV === 'production' && CORS_ORIGIN.includes('localhost')) {
30+
console.warn('[config] WARNING: CORS_ORIGIN contains "localhost" in production mode. This is likely misconfigured.')
31+
}
32+
2333
// ---------------------------------------------------------------------------
2434
// Authentication
2535
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)