fix: security hardening across all layers (#35)

* fix: comprehensive security hardening across all layers

Critical:
- Replace --dangerously-skip-permissions with explicit tool allowlist
  via settings.json (Claude Code bridge)
- Constant-time token comparison (subtle crate) in web auth and
  orchestrator auth to prevent timing attacks

High:
- Revoke tokens and clean up handles on container creation failure
- Drop SETUID/SETGID capabilities from containers (keep only CHOWN)
- Disable redirect following in HTTP tool and WASM wrapper (SSRF)
- Reject URL userinfo (@) in WASM allowlist parser (host confusion)
- Fix binary body bypassing leak detection (from_utf8 -> from_utf8_lossy)
- Protect identity files from LLM overwrites (prompt injection defense)
- Prevent tool shadowing: built-in tools cannot be replaced dynamically
- User-scoped job APIs: list/detail/cancel/restart/prompt/events/files
- CORS restricted to localhost origins, WebSocket origin validation
- Sandbox shell fail-closed: no silent fallback to unsandboxed execution
- Scrub secrets from log broadcaster before SSE broadcast
- XSS sanitization on rendered markdown in web UI
- WASM epoch ticker thread so timeout deadlines actually fire

Medium:
- Cap state transition history at 200 entries
- SSE/WebSocket connection limit (100 max)
- Request body size limit (1MB)
- Response body size limit enforcement in WASM HTTP
- UTF-8 safe string truncation (routine engine, shell tool)
- Fix PolicyAction::Sanitize to actually run the sanitizer
- TOCTOU fix in scheduler and context manager (hold write lock)
- Project file serving moved behind auth
- Path traversal guard on project_id
- Session file permissions set to 0600 on unix
- AtomicUsize for routine running_count (panic-safe)
- Completion detection hardened against false positives and tool injection
- Tool output no longer drives job completion (only LLM response)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address security review findings across all layers

- Fix path traversal sandbox bypass via lexical normalization (file.rs)
- Fix SSRF via DNS rebinding with pre-request hostname resolution (http.rs)
- Add token budget enforcement on LLM calls (reasoning.rs, state.rs)
- Fix cross-user chat history leak with ownership verification (store.rs, server.rs)
- Add sliding-window rate limiter on gateway chat endpoint (server.rs)
- Harden extension install: HTTPS-only, 50MB cap, WASM magic validation (manager.rs)
- Add destructive command blocklist that overrides shell auto-approval (shell.rs)
- Add 5MB response body size cap to HTTP tool (http.rs)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: deduplicate shared helpers and remove dead code

Extract floor_char_boundary and llm_signals_completion into src/util.rs,
unifying diverging phrase lists from agent/worker.rs and worker/runtime.rs.
Remove dead RespondResult::usage(), duplicate PROTECTED_IDENTITY_FILES
constant, double LeakDetector scanning in WebLogLayer, and invalid
0.0.0.0 origin from WebSocket allow list.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review findings and CI test failures

- Fix record_failed_approve: .truncate(true) wiped the attempts file
  before reading, so failed pairing attempts never accumulated and
  rate limiting never triggered.
- Guard wizard WASM test: skip gracefully when channel build artifacts
  are absent (CI doesn't compile wasm32-wasip2 targets).
- Fix DNS rebinding check: use port 0 instead of hardcoded 443, since
  the port is irrelevant for hostname resolution.
- Remove hardcoded CORS port 3001: the dynamic addr.port() entries
  already cover the actual server port.
- Require WebSocket Origin header: reject connections that omit it
  entirely, since browsers always send Origin for WS upgrades and a
  missing header indicates a non-browser client bypassing the check.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address second round of PR review findings

- store.rs: reintroduce file locking around read-modify-write in
  record_failed_approve (concurrent callers could clobber each other).
- sse.rs: replace load+check+fetch_add with atomic fetch_update in both
  subscribe_raw() and subscribe() to prevent overshooting max_connections.
- ws.rs: decrement WS tracker before early return when subscribe_raw()
  returns None (connection limit reached), fixing a counter leak.
- server.rs: parse WS Origin host exactly instead of prefix matching,
  preventing bypass via crafted origins like http://localhost.evil.com.
- workspace_integration.rs: skip tests gracefully when Postgres is
  unreachable instead of panicking (fixes 10 CI failures).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add Origin header to WS integration tests

The Origin header requirement added in a3b0190 broke the WS gateway
integration tests. Test clients now send Origin: http://127.0.0.1:{port}
to match the server's localhost validation.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-13 05:25:20 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e0a43c81f9
commit 33ef0a6ea5
46 changed files with 2165 additions and 283 deletions
+106 -12
View File
@@ -4,18 +4,26 @@
//! output back to the orchestrator via HTTP. Supports follow-up prompts via
//! `--resume`.
//!
//! Security model: the Docker container is the primary security boundary
//! (cap-drop ALL, non-root user, memory limits, network isolation).
//! As defense-in-depth, a project-level `.claude/settings.json` is written
//! before spawning with an explicit tool allowlist. Only listed tools are
//! auto-approved; unknown/future tools would require interactive approval,
//! which times out harmlessly in the non-interactive container.
//!
//! ```text
//! ┌─────────────────────────────────────────────┐
//! │ Docker Container │
//! │ │
//! │ ironclaw claude-bridge --job-id <uuid> │
//! ┌─────────────────────────────────────────────
//! │ Docker Container
//! │
//! │ ironclaw claude-bridge --job-id <uuid>
//! │ └─ writes /workspace/.claude/settings.json │
//! │ └─ claude -p "task" --output-format │
//! │ stream-json --dangerously-skip-perms
//! │ └─ reads stdout line-by-line │
//! │ └─ POSTs events to orchestrator │
//! │ └─ polls for follow-up prompts │
//! │ └─ on follow-up: claude --resume │
//! └─────────────────────────────────────────────┘
//! │ stream-json
//! │ └─ reads stdout line-by-line
//! │ └─ POSTs events to orchestrator
//! │ └─ polls for follow-up prompts
//! │ └─ on follow-up: claude --resume
//! └─────────────────────────────────────────────
//! ```
use std::sync::Arc;
@@ -36,6 +44,8 @@ pub struct ClaudeBridgeConfig {
pub max_turns: u32,
pub model: String,
pub timeout: Duration,
/// Tool patterns to auto-approve via project-level settings.json.
pub allowed_tools: Vec<String>,
}
/// A Claude Code streaming event (NDJSON line from `--output-format stream-json`).
@@ -119,8 +129,37 @@ impl ClaudeBridgeRuntime {
Ok(Self { config, client })
}
/// Write project-level `.claude/settings.json` with the tool allowlist.
///
/// This replaces `--dangerously-skip-permissions` with an explicit set of
/// auto-approved tools. The Docker container is still the primary security
/// boundary; this is defense-in-depth.
fn write_permission_settings(&self) -> Result<(), WorkerError> {
let settings_json = build_permission_settings(&self.config.allowed_tools);
let settings_dir = std::path::Path::new("/workspace/.claude");
std::fs::create_dir_all(settings_dir).map_err(|e| WorkerError::ExecutionFailed {
reason: format!("failed to create /workspace/.claude/: {e}"),
})?;
std::fs::write(settings_dir.join("settings.json"), &settings_json).map_err(|e| {
WorkerError::ExecutionFailed {
reason: format!("failed to write settings.json: {e}"),
}
})?;
tracing::info!(
job_id = %self.config.job_id,
tools = ?self.config.allowed_tools,
"Wrote Claude Code permission settings"
);
Ok(())
}
/// Run the bridge: fetch job, spawn claude, stream events, handle follow-ups.
pub async fn run(&self) -> Result<(), WorkerError> {
// Write project-level settings with explicit tool allowlist.
// This replaces --dangerously-skip-permissions with defense-in-depth:
// only the listed tools are auto-approved, unknown tools fail safely.
self.write_permission_settings()?;
// Fetch the job description from the orchestrator
let job = self.client.get_job().await?;
@@ -226,7 +265,6 @@ impl ClaudeBridgeRuntime {
.arg(prompt)
.arg("--output-format")
.arg("stream-json")
.arg("--dangerously-skip-permissions")
.arg("--max-turns")
.arg(self.config.max_turns.to_string())
.arg("--model")
@@ -380,6 +418,19 @@ impl ClaudeBridgeRuntime {
}
}
/// Build the JSON content for `.claude/settings.json` with the given tool allowlist.
///
/// Produces a Claude Code project settings file that auto-approves the listed
/// tools while leaving any unknown/future tools unapproved (defense-in-depth).
fn build_permission_settings(allowed_tools: &[String]) -> String {
let settings = serde_json::json!({
"permissions": {
"allow": allowed_tools,
}
});
serde_json::to_string_pretty(&settings).expect("static JSON structure is always valid")
}
/// Convert a Claude stream event into one or more event payloads for the orchestrator.
fn stream_event_to_payloads(event: &ClaudeStreamEvent) -> Vec<JobEventPayload> {
let mut payloads = Vec::new();
@@ -465,7 +516,16 @@ fn stream_event_to_payloads(event: &ClaudeStreamEvent) -> Vec<JobEventPayload> {
}
fn truncate(s: &str, max_len: usize) -> &str {
if s.len() <= max_len { s } else { &s[..max_len] }
if s.len() <= max_len {
s
} else {
// Walk back from max_len to find a valid UTF-8 char boundary.
let mut end = max_len;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
}
#[cfg(test)]
@@ -641,4 +701,38 @@ mod tests {
assert_eq!(truncate("hello world", 5), "hello");
assert_eq!(truncate("", 5), "");
}
#[test]
fn test_build_permission_settings_default_tools() {
let tools: Vec<String> = ["Bash(*)", "Read", "Edit(*)", "Glob", "Grep"]
.into_iter()
.map(String::from)
.collect();
let json_str = build_permission_settings(&tools);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let allow = parsed["permissions"]["allow"].as_array().unwrap();
assert_eq!(allow.len(), 5);
assert_eq!(allow[0], "Bash(*)");
assert_eq!(allow[1], "Read");
assert_eq!(allow[2], "Edit(*)");
}
#[test]
fn test_build_permission_settings_empty_tools() {
let json_str = build_permission_settings(&[]);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let allow = parsed["permissions"]["allow"].as_array().unwrap();
assert!(allow.is_empty());
}
#[test]
fn test_build_permission_settings_is_valid_json() {
let tools = vec!["Bash(npm run *)".to_string(), "Read".to_string()];
let json_str = build_permission_settings(&tools);
// Must be valid JSON
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
// Must have the expected structure
assert!(parsed["permissions"].is_object());
assert!(parsed["permissions"]["allow"].is_array());
}
}
+38 -8
View File
@@ -238,7 +238,7 @@ Work independently to complete this job. Report when done."#,
reason: format!("respond_with_tools failed: {}", e),
})?;
match respond_result {
match respond_result.result {
RespondResult::Text(response) => {
self.post_event(
"message",
@@ -249,11 +249,7 @@ Work independently to complete this job. Report when done."#,
)
.await;
let response_lower = response.to_lowercase();
if response_lower.contains("complete")
|| response_lower.contains("finished")
|| response_lower.contains("done")
{
if crate::util::llm_signals_completion(&response) {
if last_output.is_empty() {
last_output = response.clone();
}
@@ -431,7 +427,11 @@ Work independently to complete this job. Report when done."#,
wrapped,
));
output.contains("TASK_COMPLETE") || output.contains("JOB_DONE")
// Tool output should never signal job completion. Only the LLM's
// natural language response should decide when a job is done. A
// tool could return text containing "TASK_COMPLETE" in its output
// (e.g. from file contents) and trigger a false positive.
false
}
Err(e) => {
tracing::warn!("Tool {} failed: {}", selection.tool_name, e);
@@ -486,6 +486,36 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max])
let end = crate::util::floor_char_boundary(s, max);
format!("{}...", &s[..end])
}
}
#[cfg(test)]
mod tests {
use crate::worker::runtime::truncate;
#[test]
fn test_truncate_within_limit() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn test_truncate_at_limit() {
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn test_truncate_beyond_limit() {
let result = truncate("hello world", 5);
assert_eq!(result, "hello...");
}
#[test]
fn test_truncate_multibyte_safe() {
// "é" is 2 bytes in UTF-8; slicing at byte 1 would panic without safety
let result = truncate("é is fancy", 1);
// Should truncate to 0 chars (can't fit "é" in 1 byte)
assert_eq!(result, "...");
}
}