mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
fix(security): redact tool-call arguments, gate redirects, widen the SSRF guard
Six hardening fixes from a read-only audit of this tree. Each is small; the first is the one that mattered. 1. Tool-call arguments were never redacted. `redact_tool_result_blocks` matched only `ContentBlock::ToolResult` and `continue`d on everything else, so a model-authored command carrying a credential (`curl -H 'Authorization: Bearer ...'`, `API_KEY=... ./run`, a `.env` write) was persisted verbatim into `SavedSession.messages` and the journal, and replayed on resume. The doc comment claimed "a token a tool printed is never written to disk live", which was true for printed and false for given. Now `redact_tool_blocks` covers `ToolUse` and `ServerToolUse` input as well. `session_secret_scrub` had the same hole from the other side, so existing files on disk could not be remediated: it rewrote `tool_result` and `tool_call` summaries but never arguments. It scrubs `tool_use` and `server_tool_use` input now. 2. `[redaction] model_bound = false` disabled the *at-rest* boundary, not only the request one. The opt-out exists so the model can quote file bytes back for exact edits — never to write a live credential to disk — which the sibling method's own doc already said. The transcript path now always redacts; the request path still honors the opt-out. 3. Provider clients followed up to 10 redirects across hosts. reqwest strips `Authorization` and `Cookie` on a cross-host hop but not `x-api-key`, which is how Anthropic-dialect and gateway providers carry their key, so an open redirect on a configured `base_url` collected it. Same-host redirects are now followed; a cross-host hop or an https->http downgrade stops. 4. `is_restricted_ip` left IPv6 gaps: NAT64 (`64:ff9b::/96`) reaches its embedded IPv4 target, and 6to4, Teredo, site-local and IPv4-compatible addresses were unrestricted. NAT64 is unwrapped and checked as IPv4. 5. The MCP resource-template cache was keyed by server-supplied strings with no bound, so one hostile server could pin memory for the process lifetime. 6. `prefix_allow_matches` granted on a raw string prefix, so `git status; rm -rf /` matched a `git status` allow rule. Its only caller happens to reject compound commands first, so this was latent rather than live; the fallback now fails closed on any shell metacharacter instead of depending on that caller. NOT VERIFIED: no compile or test run. The workspace build lock was held by another session throughout. Verified by inspection: the `ContentBlock` or-pattern binds `input: serde_json::Value` in both arms; the redirect comparison matches reqwest 0.13.5's own `remove_sensitive_headers` semantics (`previous.last()` vs next host); and the `spacer`-style match in `spacer_rows_for_boundary` is untouched here. Deliberately NOT changed: `workspace-write` still runs unsandboxed when the host has no OS sandbox, reporting `sandboxed: false` rather than refusing. The read-only lane refuses in that situation. Making workspace-write refuse would break every container and CI run without bubblewrap, so it is a product decision, not a patch.
This commit is contained in:
1 parent
39621ffb5f
commit
d1fd6f1a31
7 files changed
+142
-49
No files matched your search
@@ -382,12 +382,23 @@ pub fn prefix_allow_matches(pattern: &str, command: &str) -> bool {
|
||||
|
||||
// Fallback: normalised exact match for patterns not in the arity table
|
||||
// (e.g. exact-match rules like `"ls -la"` that lack a dictionary entry).
|
||||
// A prefix grant never covers a compound or expansion-bearing command: the
|
||||
// allow is for one inert invocation, so a shell operator fails closed here
|
||||
// rather than depending on the caller having classified it first.
|
||||
let command_norm: String = command
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if command_norm.chars().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
';' | '&' | '|' | '`' | '$' | '(' | ')' | '<' | '>' | '\n' | '\r'
|
||||
)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
command_norm == pattern_norm || command_norm.starts_with(&format!("{pattern_norm} "))
|
||||
}
|
||||
|
||||
|
||||
+30
-10
@@ -903,6 +903,30 @@ fn request_query_secret_values(url: &reqwest::Url) -> Vec<String> {
|
||||
values
|
||||
}
|
||||
|
||||
/// Redirects are followed only within one host, over the same or a stronger
|
||||
/// scheme.
|
||||
///
|
||||
/// Provider keys ride in headers this stack may send under names reqwest does
|
||||
/// not strip on a cross-host hop (`x-api-key`, operator `http_headers`), so a
|
||||
/// compromised or open-redirecting endpoint could otherwise collect a key by
|
||||
/// answering `302`. A same-host hop to plaintext would expose the same key to
|
||||
/// anything on the path, so a downgrade stops too. Upgrades and same-scheme
|
||||
/// hops still work for the endpoints that use them.
|
||||
fn same_host_redirects() -> reqwest::redirect::Policy {
|
||||
reqwest::redirect::Policy::custom(|attempt| {
|
||||
let from = attempt.previous().last();
|
||||
let next = attempt.url();
|
||||
let same_host = from.is_some_and(|previous| previous.host_str() == next.host_str());
|
||||
let downgrade =
|
||||
from.is_some_and(|previous| previous.scheme() == "https" && next.scheme() == "http");
|
||||
if same_host && !downgrade && attempt.previous().len() < 10 {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn redact_model_bound_text(text: &str, exact_secret_values: &[String]) -> String {
|
||||
let mut redacted = text.to_string();
|
||||
for secret in exact_secret_values {
|
||||
@@ -1872,7 +1896,7 @@ impl CodewhaleClient {
|
||||
let http_client = if plugin_provider.is_some() || api_key_source == "Claude sign-in" {
|
||||
http_client.redirect(reqwest::redirect::Policy::none())
|
||||
} else {
|
||||
http_client
|
||||
http_client.redirect(same_host_redirects())
|
||||
}
|
||||
.build()?;
|
||||
let models_http_client = Self::http_client_builder_with_auth_mode(
|
||||
@@ -2179,16 +2203,12 @@ impl CodewhaleClient {
|
||||
redact_model_bound_text(text, &self.model_bound_secret_values)
|
||||
}
|
||||
|
||||
/// Redact tool output as it enters the transcript. Same masking as the
|
||||
/// request boundary, including the confirmed `[redaction] model_bound`
|
||||
/// opt-out: a user who chose to let the model see file bytes verbatim
|
||||
/// keeps that, and everyone else never stores a live credential.
|
||||
/// Redact tool output as it enters the transcript. Unlike the request
|
||||
/// boundary, this path always redacts: the `[redaction] model_bound`
|
||||
/// opt-out exists so the model can quote file bytes back for exact edits,
|
||||
/// never to write a live credential to disk.
|
||||
pub(crate) fn redact_tool_output_for_transcript(&self, text: &str) -> String {
|
||||
if self.model_bound_masking {
|
||||
redact_model_bound_text(text, &self.model_bound_secret_values)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
redact_model_bound_text(text, &self.model_bound_secret_values)
|
||||
}
|
||||
|
||||
/// Alternate models share this client's frozen endpoint and declarations.
|
||||
|
||||
@@ -918,26 +918,47 @@ enum EngineHostSetup {
|
||||
Rlm(rlm_host::RlmHostSetup),
|
||||
}
|
||||
|
||||
/// Rewrite the text of every tool result in `message`, including text
|
||||
/// content blocks, with `redact`.
|
||||
pub(crate) fn redact_tool_result_blocks(message: &mut Message, redact: impl Fn(&str) -> String) {
|
||||
/// Rewrite the text of every tool result and tool-call input in `message`,
|
||||
/// including text content blocks, with `redact`.
|
||||
pub(crate) fn redact_tool_blocks(message: &mut Message, redact: impl Fn(&str) -> String) {
|
||||
for block in &mut message.content {
|
||||
let ContentBlock::ToolResult {
|
||||
content,
|
||||
content_blocks,
|
||||
..
|
||||
} = block
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
*content = redact(content);
|
||||
for value in content_blocks.iter_mut().flatten() {
|
||||
if value.get("type").and_then(serde_json::Value::as_str) == Some("text")
|
||||
&& let Some(serde_json::Value::String(text)) = value.get_mut("text")
|
||||
{
|
||||
*text = redact(text);
|
||||
match block {
|
||||
ContentBlock::ToolResult {
|
||||
content,
|
||||
content_blocks,
|
||||
..
|
||||
} => {
|
||||
*content = redact(content);
|
||||
for value in content_blocks.iter_mut().flatten() {
|
||||
if value.get("type").and_then(serde_json::Value::as_str) == Some("text")
|
||||
&& let Some(serde_json::Value::String(text)) = value.get_mut("text")
|
||||
{
|
||||
*text = redact(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
|
||||
redact_json_strings(input, &redact);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_json_strings(value: &mut serde_json::Value, redact: &impl Fn(&str) -> String) {
|
||||
match value {
|
||||
serde_json::Value::String(text) => *text = redact(text),
|
||||
serde_json::Value::Array(values) => {
|
||||
for value in values {
|
||||
redact_json_strings(value, redact);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(values) => {
|
||||
for value in values.values_mut() {
|
||||
redact_json_strings(value, redact);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4701,13 +4722,15 @@ impl Engine {
|
||||
self.emit_session_updated().await;
|
||||
}
|
||||
|
||||
/// Scrub credentials from tool output once, as it enters the transcript
|
||||
/// (B1). The transcript is what session JSON, the journal and every
|
||||
/// later request are built from, so a token a tool printed is never
|
||||
/// written to disk live. Honors the confirmed `[redaction] model_bound`
|
||||
/// opt-out the same way the request boundary does.
|
||||
/// Scrub credentials from tool output and tool-call input once, as it
|
||||
/// enters the transcript (B1). The transcript is what session JSON, the
|
||||
/// journal and every later request are built from, so a token a tool
|
||||
/// printed — or one a tool was *given* in its arguments — is never written
|
||||
/// to disk live. Unlike the request boundary this does not honor the
|
||||
/// `[redaction] model_bound` opt-out: that opt-out exists so the model can
|
||||
/// quote file bytes back for exact edits, never to relax storage.
|
||||
fn redact_tool_results_for_transcript(&self, message: &mut Message) {
|
||||
redact_tool_result_blocks(message, |text| self.redact_tool_output_for_transcript(text));
|
||||
redact_tool_blocks(message, |text| self.redact_tool_output_for_transcript(text));
|
||||
}
|
||||
|
||||
fn redact_tool_output_for_transcript(&self, text: &str) -> String {
|
||||
|
||||
+14
-9
@@ -1439,8 +1439,11 @@ fn resource_template_pattern(template: &str) -> Option<String> {
|
||||
///
|
||||
/// This runs per URI per advertised template, while the template itself is
|
||||
/// fixed by the server's listing, so compiling it on every call was pure
|
||||
/// repetition. `None` still means "matches nothing" (#6213 T7).
|
||||
/// repetition. `None` still means "matches nothing" (#6213 T7). The cache is
|
||||
/// bounded because the template list is server-supplied: an unbounded key set
|
||||
/// would let one server pin memory for the life of the process.
|
||||
fn compiled_resource_template(template: &str) -> Option<Arc<regex::Regex>> {
|
||||
const MAX_CACHED_TEMPLATES: usize = 512;
|
||||
static CACHE: std::sync::OnceLock<
|
||||
std::sync::Mutex<HashMap<String, Option<Arc<regex::Regex>>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
@@ -1448,14 +1451,16 @@ fn compiled_resource_template(template: &str) -> Option<Arc<regex::Regex>> {
|
||||
let mut cache = cache
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
cache
|
||||
.entry(template.to_string())
|
||||
.or_insert_with(|| {
|
||||
resource_template_pattern(template)
|
||||
.and_then(|pattern| regex::Regex::new(&pattern).ok())
|
||||
.map(Arc::new)
|
||||
})
|
||||
.clone()
|
||||
if let Some(cached) = cache.get(template) {
|
||||
return cached.clone();
|
||||
}
|
||||
let compiled = resource_template_pattern(template)
|
||||
.and_then(|pattern| regex::Regex::new(&pattern).ok())
|
||||
.map(Arc::new);
|
||||
if cache.len() < MAX_CACHED_TEMPLATES {
|
||||
cache.insert(template.to_string(), compiled.clone());
|
||||
}
|
||||
compiled
|
||||
}
|
||||
|
||||
fn resource_uri_matches_template(uri: &str, template: &str) -> bool {
|
||||
|
||||
@@ -19469,7 +19469,7 @@ impl RuntimeThreadManager {
|
||||
{
|
||||
let mut messages = engine.get_session_snapshot().await?.messages;
|
||||
for message in &mut messages {
|
||||
crate::core::engine::redact_tool_result_blocks(message, |text| {
|
||||
crate::core::engine::redact_tool_blocks(message, |text| {
|
||||
crate::client::redact_model_bound_text(text, &receipt_secrets)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
//! `codewhale doctor` reports them and `codewhale sessions scrub-secrets`
|
||||
//! rewrites them on request.
|
||||
//!
|
||||
//! Only transcript tool results and Runtime tool receipts (including event
|
||||
//! copies) are touched. Cleanup masks credential shapes and currently configured
|
||||
//! secret values; bare secrets absent from current configuration cannot be recovered.
|
||||
//! Only transcript tool results, transcript tool-call arguments and Runtime
|
||||
//! tool receipts (including event copies) are touched. Cleanup masks credential
|
||||
//! shapes and currently configured secret values; bare secrets absent from
|
||||
//! current configuration cannot be recovered.
|
||||
//! Runtime rewrites require the exclusive process lease, and session rewrites
|
||||
//! the session's live lease; busy stores and sessions open in an interactive
|
||||
//! surface are reported and left untouched while the remaining files are
|
||||
@@ -334,13 +335,14 @@ fn scrub_file(path: &Path, apply: bool, secrets: &[String]) -> io::Result<FileSc
|
||||
Ok(FileScan::Dirty(redacted))
|
||||
}
|
||||
|
||||
/// Mask credentials in every `tool_result` text field below `value`.
|
||||
/// Returns how many fields changed.
|
||||
/// Mask credentials in every `tool_result` and `tool_call` argument below
|
||||
/// `value`. Returns how many fields changed.
|
||||
fn scrub_value(value: &mut Value, secrets: &[String]) -> usize {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut changed = 0;
|
||||
if map.get("type").and_then(Value::as_str) == Some("tool_result") {
|
||||
let block_type = map.get("type").and_then(Value::as_str);
|
||||
if block_type == Some("tool_result") {
|
||||
if let Some(Value::String(content)) = map.get_mut("content") {
|
||||
changed += usize::from(scrub_text(content, secrets));
|
||||
}
|
||||
@@ -354,6 +356,15 @@ fn scrub_value(value: &mut Value, secrets: &[String]) -> usize {
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(block_type, Some("tool_use" | "server_tool_use"))
|
||||
&& let Some(input) = map.get_mut("input")
|
||||
{
|
||||
let redacted = crate::client::redact_json_model_bound_text(input, secrets);
|
||||
if *input != redacted {
|
||||
*input = redacted;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
if map.get("kind").and_then(Value::as_str) == Some("tool_call") {
|
||||
for key in ["summary", "detail", "metadata"] {
|
||||
if let Some(value) = map.get_mut(key) {
|
||||
|
||||
@@ -62,14 +62,37 @@ pub(crate) fn is_restricted_ip(ip: &IpAddr) -> bool {
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return is_restricted_ip(&IpAddr::V4(v4));
|
||||
}
|
||||
// A NAT64 gateway reaches the embedded IPv4 target, so the prefix
|
||||
// must not hide a restricted address.
|
||||
if let Some(v4) = nat64_ipv4(*v6)
|
||||
&& is_restricted_ip(&IpAddr::V4(v4))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
v6.is_loopback()
|
||||
|| v6.is_multicast()
|
||||
|| matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7
|
||||
|| matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10
|
||||
|| matches!(v6.segments(), [0xfec0..=0xfeff, ..]) // Site-local fec0::/10
|
||||
|| matches!(v6.segments(), [0x2002, ..]) // 6to4 2002::/16
|
||||
|| matches!(v6.segments(), [0x2001, 0x0000, ..]) // Teredo 2001::/32
|
||||
|| matches!(v6.segments(), [0, 0, 0, 0, 0, 0, ..]) // IPv4-compatible ::a.b.c.d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The IPv4 target of a well-known-prefix NAT64 address (`64:ff9b::/96`).
|
||||
fn nat64_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
let segments = v6.segments();
|
||||
if segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2..6].iter().all(|s| *s == 0) {
|
||||
let octets = v6.octets();
|
||||
return Some(std::net::Ipv4Addr::new(
|
||||
octets[12], octets[13], octets[14], octets[15],
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate that `url` is a safe fetch target under SSRF and network policy.
|
||||
///
|
||||
/// On success returns an optional DNS pin `(hostname, ip)` for hostnames that
|
||||
|
||||
Reference in new issue
Block a user