Skip to content

Commit b5a612d

Browse files
Complete phase16-session03-input-sanitization-and-auth-hardening: input sanitization, auth hardening, exec approval tightening
- 16 security items closed (8 Apply + 8 Adapt) across injection prevention, input validation, and auth hardening - 4 new infrastructure modules: secret-equal, base64 validator, bounded HTTP body reader, auth rate-limiter - Exec approval chain: device binding, param allowlisting, node.invoke bypass prevention - 113 new security-focused tests, 5935 total passing - Version: 0.1.156 -> 0.1.157
1 parent 195fa29 commit b5a612d

38 files changed

Lines changed: 2248 additions & 103 deletions

.spec_system/PRD/phase_16/PRD_phase_16.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
**Sessions**: 5 (initial estimate)
55
**Estimated Duration**: 10-20 days
66

7-
**Progress**: 2/5 sessions (40%)
7+
**Progress**: 3/5 sessions (60%)
88

99
---
1010

@@ -32,7 +32,7 @@ Audit and port ~65 upstream security patches covering SSRF bypasses, path traver
3232
|---------|------|--------|-------|------------|------------|-----------|
3333
| 01 | Security Triage and Applicability Audit | Complete | 60 triaged | 20 | ~1 | 2026-02-23 |
3434
| 02 | Network, SSRF, and Filesystem Hardening | Complete | 14 (5A+9Ad) | 20 | ~1 | 2026-02-23 |
35-
| 03 | Input Sanitization and Auth Hardening | Not Started | 16 (8A+8Ad) | 18-24 | 4-5 | - |
35+
| 03 | Input Sanitization and Auth Hardening | Complete | 16 (8A+8Ad) | 20 | ~1 | 2026-02-23 |
3636
| 04 | Execution Hardening and Data Leak Prevention | Not Started | 17 (5A+12Ad) | 20-26 | 5-6 | - |
3737
| 05 | ACP Fixes and Security Validation | Not Started | 5 (0A+5Ad) | 15-20 | 3-4 | - |
3838

@@ -56,9 +56,18 @@ Audit and port ~65 upstream security patches covering SSRF bypasses, path traver
5656

5757
---
5858

59+
### Session 03: Input Sanitization and Auth Hardening
60+
- **Completed**: 2026-02-23
61+
- **Duration**: ~53 minutes
62+
- **Tasks**: 20/20
63+
- **Result**: Closed 16 security items (8 Apply + 8 Adapt). Input sanitization: chat.send injection prevention, transcript sanitization, Unicode homoglyph detection, base64 size validation, bounded HTTP body reading, rawCommand/argv consistency. Auth hardening: OAuth CSRF protection, timing-safe token comparison, auth rate-limiting with brute-force lockout, exec approval device binding, system.run param allowlisting, node.invoke bypass prevention. 4 new infrastructure modules created. 113 security-focused tests added.
64+
65+
---
66+
5967
## Upcoming Sessions
6068

61-
- Session 03: Input Sanitization and Auth Hardening
69+
- Session 04: Execution Hardening and Data Leak Prevention
70+
- Session 05: ACP Fixes and Security Validation
6271

6372
---
6473

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Implementation Summary
2+
3+
**Session ID**: `phase16-session03-input-sanitization-and-auth-hardening`
4+
**Completed**: 2026-02-23
5+
**Duration**: ~53 minutes
6+
7+
---
8+
9+
## Overview
10+
11+
Ported 16 upstream security fixes (8 Apply + 8 Adapt) covering injection prevention, input sanitization, authentication hardening, and exec approval tightening. Created 4 new infrastructure modules and modified 25 existing files. Closed all Section 1.3 (Injection & Input Sanitization) and Section 1.4 (Auth & Access Control) items from the phase 16 triage.
12+
13+
---
14+
15+
## Deliverables
16+
17+
### Files Created
18+
| File | Purpose | Lines |
19+
|------|---------|-------|
20+
| `src/security/secret-equal.ts` | Timing-safe string comparison via crypto.timingSafeEqual | ~25 |
21+
| `src/security/secret-equal.test.ts` | Unit tests for timing-safe comparison | ~40 |
22+
| `src/media/base64.ts` | Oversized base64 rejection before decode | ~45 |
23+
| `src/media/base64.test.ts` | Unit tests for base64 validation | ~70 |
24+
| `src/infra/http-body.ts` | Bounded HTTP body reading with configurable size limits | ~280 |
25+
| `src/infra/http-body.test.ts` | Unit tests for bounded body reader | ~170 |
26+
| `src/gateway/auth-rate-limit.ts` | Sliding-window per-IP auth rate limiting with lockout | ~190 |
27+
| `src/gateway/auth-rate-limit.test.ts` | Unit + integration tests for auth rate-limiting | ~130 |
28+
29+
### Files Modified
30+
| File | Changes |
31+
|------|---------|
32+
| `src/gateway/server-methods/chat.ts` | Null byte stripping and message length limit on chat.send |
33+
| `src/agents/session-transcript-repair.ts` | Hardened tool-call block sanitization against injection |
34+
| `src/agents/session-transcript-repair.test.ts` | Added sanitization test cases |
35+
| `src/security/external-content.ts` | Added Unicode angle bracket homoglyph detection (U+FF1C, U+FF1E, U+FE64, U+FE65, etc.) |
36+
| `src/security/external-content.test.ts` | Tests for new homoglyph patterns |
37+
| `src/gateway/server-methods/agents.ts` | Replaced unsafe .toString() with safe stringification |
38+
| `src/gateway/chat-attachments.ts` | Integrated base64 size validation before decode |
39+
| `src/media/input-files.ts` | Integrated base64 size validation |
40+
| `src/gateway/server-http.ts` | Integrated bounded body reading; wired auth rate-limiter |
41+
| `src/gateway/http-common.ts` | Used bounded body reader for JSON/text parsing |
42+
| `src/node-host/runner.ts` | Enforced rawCommand/argv consistency in system.run |
43+
| `src/commands/status.summary.ts` | Redacted sensitive details for non-admin scopes |
44+
| `src/agents/chutes-oauth.ts` | Validated OAuth state parameter on callback |
45+
| `src/commands/chutes-oauth.test.ts` | Tests for OAuth CSRF validation |
46+
| `src/commands/onboard-helpers.ts` | Rejected literal "undefined"/"null" tokens |
47+
| `src/wizard/onboarding.gateway-config.ts` | Rejected literal "undefined"/"null" tokens |
48+
| `src/gateway/auth.ts` | Integrated secretEqual for timing-safe token comparison; wired rate-limiter |
49+
| `src/gateway/auth.test.ts` | Tests for secretEqual integration and rate-limit behavior |
50+
| `src/gateway/node-command-policy.ts` | Added EXEC_APPROVAL_REQUIRED_COMMANDS and requiresExecApproval() |
51+
| `src/gateway/server-methods/nodes.ts` | Integrated sanitizer into node.invoke; added approval-required check |
52+
| `src/gateway/server-methods/exec-approval.ts` | Added device binding, self-approval guard, param sanitizer |
53+
| `src/gateway/server-methods/exec-approval.test.ts` | Integration tests for device binding, self-approval, param sanitization |
54+
| `src/gateway/exec-approval-manager.ts` | Added validateDeviceBinding() method |
55+
| `src/gateway/server-methods/types.ts` | Added execApprovalManager to GatewayRequestContext |
56+
| `src/gateway/server.impl.ts` | Wired auth rate-limiter and exec approval manager into server init |
57+
| `src/commands/auth-choice.test.ts` | Fixed pre-existing lint issue (no-base-to-string) |
58+
59+
---
60+
61+
## Technical Decisions
62+
63+
1. **Sanitizer placement in exec-approval.ts**: Placed system.run param sanitizer inline in exec-approval.ts rather than a separate file (upstream pattern). Rationale: crocbot's simpler architecture means the sanitizer is tightly coupled to ExecApprovalManager and called from one place only.
64+
2. **Self-approval check uses clientId**: Chose clientId over deviceId or connId for self-approval prevention. Rationale: deviceId would be too restrictive for desktop users where CLI and UI run on same device but with different client IDs.
65+
3. **Sliding-window rate limiter**: Implemented separate auth-specific sliding-window rate limiter rather than extending existing fixed-window rate-limit.ts. Rationale: auth rate-limiting needs per-IP sliding window with lockout semantics; different algorithm and scope from general HTTP rate limiting.
66+
4. **Base64 size threshold**: Used upstream-aligned threshold value to balance legitimate image attachments against abuse prevention.
67+
68+
---
69+
70+
## Test Results
71+
72+
| Metric | Value |
73+
|--------|-------|
74+
| Test Files | 778 |
75+
| Total Tests | 5935 |
76+
| Passed | 5935 |
77+
| Failed | 0 |
78+
| Skipped | 1 |
79+
| New Security Tests | 113 |
80+
81+
---
82+
83+
## Lessons Learned
84+
85+
1. Exec approval chain (T015-T017) required strict sequential implementation due to tight coupling between device binding, param sanitization, and bypass prevention.
86+
2. Upstream file mapping is not 1:1 -- crocbot's runner.ts absorbs logic that upstream splits across invoke.ts and node-invoke-system-run-approval.ts.
87+
3. Pre-existing non-ASCII characters in modified files need to be documented but not "fixed" to avoid unnecessary churn.
88+
89+
---
90+
91+
## Future Considerations
92+
93+
Items for future sessions:
94+
1. Session 04 (Execution Hardening and Data Leak Prevention) depends on the exec approval chain completed here
95+
2. Session 05 (ACP Fixes) depends on all prior sessions including the input sanitization and auth guards
96+
3. Auth rate-limiter could be extended to cover non-auth endpoints if abuse patterns emerge
97+
4. Consider adding rate-limit telemetry/logging for security monitoring
98+
99+
---
100+
101+
## Session Statistics
102+
103+
- **Tasks**: 20 completed
104+
- **Files Created**: 8
105+
- **Files Modified**: 26
106+
- **Tests Added**: 113
107+
- **Blockers**: 0 resolved

.spec_system/state.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"name": "Critical Security Hardening II",
1010
"description": "Audit and port ~65 upstream security patches covering SSRF bypasses, path traversal, input sanitization, auth hardening, execution safety, data leak prevention, and ACP policy fixes.",
1111
"status": "in_progress",
12-
"sessions_completed": 2,
12+
"sessions_completed": 3,
1313
"session_count": 5
1414
},
1515
"17": {
@@ -50,7 +50,8 @@
5050
},
5151
"completed_sessions": [
5252
"phase16-session01-security-triage-and-applicability-audit",
53-
"phase16-session02-network-ssrf-and-filesystem-hardening"
53+
"phase16-session02-network-ssrf-and-filesystem-hardening",
54+
"phase16-session03-input-sanitization-and-auth-hardening"
5455
],
5556
"next_session_history": [
5657
{
@@ -62,6 +63,11 @@
6263
"date": "2026-02-23",
6364
"session": "phase16-session02-network-ssrf-and-filesystem-hardening",
6465
"status": "completed"
66+
},
67+
{
68+
"date": "2026-02-23",
69+
"session": "phase16-session03-input-sanitization-and-auth-hardening",
70+
"status": "completed"
6571
}
6672
]
6773
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "crocbot",
3-
"version": "0.1.156",
3+
"version": "0.1.157",
44
"description": "Telegram gateway CLI with Pi RPC agent",
55
"type": "module",
66
"main": "dist/index.js",

src/agents/chutes-oauth.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,23 @@ export function parseOAuthCallbackInput(
5353
if (!state) {
5454
return { error: "Missing 'state' parameter. Paste the full URL." };
5555
}
56+
if (state !== expectedState) {
57+
return { error: "OAuth state mismatch -- possible CSRF. Restart the flow." };
58+
}
5659
return { code, state };
5760
} catch {
58-
if (!expectedState) {
59-
return { error: "Paste the full redirect URL, not just the code." };
61+
// Try parsing as a query string (e.g. "code=abc&state=xyz")
62+
const qs = new URLSearchParams(trimmed);
63+
const qsCode = qs.get("code");
64+
const qsState = qs.get("state");
65+
if (qsCode && qsState) {
66+
if (qsState !== expectedState) {
67+
return { error: "OAuth state mismatch -- possible CSRF. Restart the flow." };
68+
}
69+
return { code: qsCode, state: qsState };
6070
}
61-
return { code: trimmed, state: expectedState };
71+
// Reject bare code pastes: require the full URL for CSRF safety
72+
return { error: "Paste the full redirect URL, not just the code." };
6273
}
6374
}
6475

src/agents/session-transcript-repair.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,51 @@ describe("sanitizeToolCallInputs", () => {
148148
expect(types).toEqual(["text", "toolUse"]);
149149
});
150150
});
151+
152+
describe("tool-call block field validation", () => {
153+
it("skips tool calls with empty id", () => {
154+
const input: AgentMessage[] = [
155+
{
156+
role: "assistant",
157+
content: [{ type: "toolCall", id: "", name: "read", arguments: {} }],
158+
},
159+
{ role: "user", content: "hello" },
160+
];
161+
162+
const out = sanitizeToolUseResultPairing(input);
163+
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
164+
expect(Array.isArray(assistant.content) ? assistant.content.length : 0).toBe(1);
165+
});
166+
167+
it("skips tool calls with oversized id", () => {
168+
const longId = "x".repeat(200);
169+
const input: AgentMessage[] = [
170+
{
171+
role: "assistant",
172+
content: [{ type: "toolCall", id: longId, name: "read", arguments: {} }],
173+
},
174+
{ role: "user", content: "hello" },
175+
];
176+
177+
const out = sanitizeToolUseResultPairing(input);
178+
const results = out.filter((m) => m.role === "toolResult");
179+
// No synthetic result for oversized ID - it gets truncated or skipped
180+
expect(results.length).toBe(0);
181+
});
182+
183+
it("sanitizes tool names with invalid characters", () => {
184+
const input: AgentMessage[] = [
185+
{
186+
role: "assistant",
187+
content: [{ type: "toolCall", id: "call_1", name: "read<script>", arguments: {} }],
188+
},
189+
];
190+
191+
const out = sanitizeToolUseResultPairing(input);
192+
const results = out.filter((m) => m.role === "toolResult") as Array<{
193+
toolName?: string;
194+
}>;
195+
expect(results.length).toBe(1);
196+
expect(results[0]?.toolName).not.toContain("<");
197+
});
198+
});

src/agents/session-transcript-repair.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ type ToolCallLike = {
77

88
const TOOL_CALL_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
99

10+
const MAX_TOOL_CALL_ID_LENGTH = 128;
11+
const MAX_TOOL_NAME_LENGTH = 64;
12+
const TOOL_NAME_PATTERN = /^[A-Za-z0-9_\-.]+$/;
13+
1014
type ToolCallBlock = {
1115
type?: unknown;
1216
id?: unknown;
@@ -23,6 +27,17 @@ function isToolCallBlock(block: unknown): block is ToolCallBlock {
2327
return typeof type === "string" && TOOL_CALL_TYPES.has(type);
2428
}
2529

30+
function isValidToolCallId(id: unknown): boolean {
31+
return typeof id === "string" && id.length > 0 && id.length <= MAX_TOOL_CALL_ID_LENGTH;
32+
}
33+
34+
function isValidToolName(name: unknown): boolean {
35+
if (typeof name !== "string") {
36+
return true;
37+
}
38+
return name.length <= MAX_TOOL_NAME_LENGTH && TOOL_NAME_PATTERN.test(name);
39+
}
40+
2641
function hasToolCallInput(block: ToolCallBlock): boolean {
2742
const hasInput = "input" in block ? block.input !== undefined && block.input !== null : false;
2843
const hasArguments =
@@ -44,15 +59,20 @@ function extractToolCallsFromAssistant(
4459
continue;
4560
}
4661
const rec = block as { type?: unknown; id?: unknown; name?: unknown };
47-
if (typeof rec.id !== "string" || !rec.id) {
62+
if (!isValidToolCallId(rec.id)) {
4863
continue;
4964
}
5065

5166
if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") {
52-
toolCalls.push({
53-
id: rec.id,
54-
name: typeof rec.name === "string" ? rec.name : undefined,
55-
});
67+
const id = (rec.id as string).slice(0, MAX_TOOL_CALL_ID_LENGTH);
68+
const rawName = typeof rec.name === "string" ? rec.name : undefined;
69+
const name =
70+
rawName && isValidToolName(rawName)
71+
? rawName
72+
: rawName
73+
? rawName.slice(0, MAX_TOOL_NAME_LENGTH).replace(/[^A-Za-z0-9_\-.]/g, "_")
74+
: undefined;
75+
toolCalls.push({ id, name });
5676
}
5777
}
5878
return toolCalls;

src/commands/auth-choice.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,19 @@ describe("applyAuthChoice", () => {
437437
});
438438
vi.stubGlobal("fetch", fetchSpy);
439439

440-
const text = vi.fn().mockResolvedValue("code_manual");
440+
let capturedState = "";
441+
const logFn = vi.fn((...args: unknown[]) => {
442+
const msg = typeof args[0] === "string" ? args[0] : "";
443+
const match = /[?&]state=([^&\s]+)/.exec(msg);
444+
if (match) {
445+
capturedState = match[1];
446+
}
447+
});
448+
const text = vi
449+
.fn()
450+
.mockImplementation(
451+
async () => `http://127.0.0.1:1456/oauth-callback?code=code_manual&state=${capturedState}`,
452+
);
441453
const select: WizardPrompter["select"] = vi.fn(
442454
async (params) => params.options[0]?.value as never,
443455
);
@@ -453,7 +465,7 @@ describe("applyAuthChoice", () => {
453465
progress: vi.fn(() => ({ update: noop, stop: noop })),
454466
};
455467
const runtime: RuntimeEnv = {
456-
log: vi.fn(),
468+
log: logFn,
457469
error: vi.fn(),
458470
exit: vi.fn((code: number) => {
459471
throw new Error(`exit:${code}`);

src/commands/chutes-oauth.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ describe("loginChutes", () => {
6868
expect(creds.email).toBe("local-user");
6969
});
7070

71-
it("supports manual flow with pasted code", async () => {
71+
it("supports manual flow with pasted redirect URL", async () => {
7272
const fetchFn: typeof fetch = async (input) => {
7373
const url = String(input);
7474
if (url === CHUTES_TOKEN_ENDPOINT) {
@@ -90,15 +90,19 @@ describe("loginChutes", () => {
9090
return new Response("not found", { status: 404 });
9191
};
9292

93+
let capturedState = "";
9394
const creds = await loginChutes({
9495
app: {
9596
clientId: "cid_test",
9697
redirectUri: "http://127.0.0.1:1456/oauth-callback",
9798
scopes: ["openid"],
9899
},
99100
manual: true,
100-
onAuth: async () => {},
101-
onPrompt: async () => "code_manual",
101+
onAuth: async ({ url }) => {
102+
capturedState = new URL(url).searchParams.get("state") ?? "";
103+
},
104+
onPrompt: async () =>
105+
`http://127.0.0.1:1456/oauth-callback?code=code_manual&state=${capturedState}`,
102106
fetchFn,
103107
});
104108

@@ -149,7 +153,7 @@ describe("loginChutes", () => {
149153
expect(parsed.searchParams.get("state")).toBe("state_456");
150154
expect(parsed.searchParams.get("state")).not.toBe("verifier_123");
151155
},
152-
onPrompt: async () => "code_manual",
156+
onPrompt: async () => "http://127.0.0.1:1456/oauth-callback?code=code_manual&state=state_456",
153157
fetchFn,
154158
});
155159

0 commit comments

Comments
 (0)