Marketplace: validate tree entry names and cap depth/manifest size
Fix round 1 from PR review of the tree/catalog parsing: - collect_dir now rejects an entry whose name is ".", "..", empty, or contains "/", "\" or NUL before it becomes part of an item's rel_path — a crafted git tree could otherwise walk a file outside the item's own folder once that path is joined against the item root downstream. - collect_dir caps recursion at 32 directory levels and counts directories (not just files) toward MAX_ITEM_FILES, so a tree that is wide or deep rather than merely file-heavy is still bounded. - hook.json and plugins/.claude-plugin/marketplace.json are now rejected unparsed above 1 MiB, rather than handed to serde_json regardless of size. A pre-read size query (checking a blob's size before reading it) is deferred per controller ruling — this round reads the blob and checks its length before parsing, which is enough for the JSON-parsing DoS shape being closed here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,17 @@ pub const MAX_ITEM_BYTES: u64 = 2 * 1024 * 1024;
|
|||||||
pub const MAX_ITEM_FILES: usize = 200;
|
pub const MAX_ITEM_FILES: usize = 200;
|
||||||
/// Preview text is truncated to this many bytes (on a char boundary).
|
/// Preview text is truncated to this many bytes (on a char boundary).
|
||||||
const MAX_PREVIEW_BYTES: usize = 64 * 1024;
|
const MAX_PREVIEW_BYTES: usize = 64 * 1024;
|
||||||
|
/// How many directory levels `collect_dir` will descend into an item before
|
||||||
|
/// giving up on it as invalid. A crafted tree can nest directories far deeper
|
||||||
|
/// than any real item would (or, over a network `TreeView`, be effectively
|
||||||
|
/// unbounded), so this is a hard stop rather than a performance nicety.
|
||||||
|
const MAX_ITEM_DEPTH: usize = 32;
|
||||||
|
/// `hooks/<key>/hook.json` and `plugins/.claude-plugin/marketplace.json` are
|
||||||
|
/// parsed as JSON before anything else about the item is known, so they are
|
||||||
|
/// capped and rejected *unparsed* well below `MAX_ITEM_BYTES` — a bound on
|
||||||
|
/// the whole item is not a bound on what one `serde_json::from_str` call is
|
||||||
|
/// asked to chew through.
|
||||||
|
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
const PLUGIN_CATALOG_PATH: &str = "plugins/.claude-plugin/marketplace.json";
|
const PLUGIN_CATALOG_PATH: &str = "plugins/.claude-plugin/marketplace.json";
|
||||||
|
|
||||||
@@ -123,6 +134,23 @@ fn invalid_name_reason(key: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject a git tree entry name that could escape the item root once it is
|
||||||
|
/// joined into a `/`-separated relative path: `.` and `..` (traversal), an
|
||||||
|
/// empty name (nothing to join), and any name containing `/`, `\` or a NUL
|
||||||
|
/// byte (a path separator on this or another OS, or a string terminator in
|
||||||
|
/// C-based tooling downstream). `TreeView` implementations are trusted to
|
||||||
|
/// return real entries, but a git tree is repo-controlled content, not
|
||||||
|
/// something this app authored, so a hostile blob naming a tree entry `..`
|
||||||
|
/// must not turn into a file written outside the item's own folder.
|
||||||
|
fn valid_entry_name(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name != "."
|
||||||
|
&& name != ".."
|
||||||
|
&& !name.contains('/')
|
||||||
|
&& !name.contains('\\')
|
||||||
|
&& !name.contains('\0')
|
||||||
|
}
|
||||||
|
|
||||||
/// Normalise a plugin `source` into a path under `plugins/`, refusing
|
/// Normalise a plugin `source` into a path under `plugins/`, refusing
|
||||||
/// anything that is not a plain relative path staying inside `plugins/`.
|
/// anything that is not a plain relative path staying inside `plugins/`.
|
||||||
fn plugin_source_path(source: &serde_json::Value) -> Result<String, String> {
|
fn plugin_source_path(source: &serde_json::Value) -> Result<String, String> {
|
||||||
@@ -162,6 +190,13 @@ fn read_plugin_catalog(tree: &dyn TreeView) -> Result<Option<Vec<serde_json::Val
|
|||||||
let Some(text) = read_utf8(tree, PLUGIN_CATALOG_PATH)? else {
|
let Some(text) = read_utf8(tree, PLUGIN_CATALOG_PATH)? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
if text.len() as u64 > MAX_MANIFEST_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is larger than {} MiB",
|
||||||
|
PLUGIN_CATALOG_PATH,
|
||||||
|
MAX_MANIFEST_BYTES / (1024 * 1024)
|
||||||
|
));
|
||||||
|
}
|
||||||
let json: serde_json::Value = serde_json::from_str(&text)
|
let json: serde_json::Value = serde_json::from_str(&text)
|
||||||
.map_err(|e| format!("{} is not valid JSON: {}", PLUGIN_CATALOG_PATH, e))?;
|
.map_err(|e| format!("{} is not valid JSON: {}", PLUGIN_CATALOG_PATH, e))?;
|
||||||
let plugins = json
|
let plugins = json
|
||||||
@@ -199,13 +234,27 @@ fn item_path(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<String, S
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively collect a folder's files, enforcing the item rules.
|
/// Recursively collect a folder's files, enforcing the item rules.
|
||||||
|
///
|
||||||
|
/// `depth` is how many directory levels below the item root this call is
|
||||||
|
/// (the root call is `0`); `entries_seen` counts directories *and* files
|
||||||
|
/// together against [`MAX_ITEM_FILES`] — a tree with few files but many
|
||||||
|
/// directories is just as much a resource-exhaustion shape as many files,
|
||||||
|
/// and only counting files would let it through uncapped.
|
||||||
fn collect_dir(
|
fn collect_dir(
|
||||||
tree: &dyn TreeView,
|
tree: &dyn TreeView,
|
||||||
root: &str,
|
root: &str,
|
||||||
rel: &str,
|
rel: &str,
|
||||||
|
depth: usize,
|
||||||
|
entries_seen: &mut usize,
|
||||||
out: &mut Vec<ItemFile>,
|
out: &mut Vec<ItemFile>,
|
||||||
total: &mut u64,
|
total: &mut u64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
if depth > MAX_ITEM_DEPTH {
|
||||||
|
return Err(format!(
|
||||||
|
"is nested more than {} directories deep",
|
||||||
|
MAX_ITEM_DEPTH
|
||||||
|
));
|
||||||
|
}
|
||||||
let path = if rel.is_empty() {
|
let path = if rel.is_empty() {
|
||||||
root.to_string()
|
root.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -215,6 +264,12 @@ fn collect_dir(
|
|||||||
.list_dir(&path)?
|
.list_dir(&path)?
|
||||||
.ok_or_else(|| format!("{} is not a folder", path))?;
|
.ok_or_else(|| format!("{} is not a folder", path))?;
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
if !valid_entry_name(&entry.name) {
|
||||||
|
return Err(format!(
|
||||||
|
"contains an entry with an invalid name ({:?})",
|
||||||
|
entry.name
|
||||||
|
));
|
||||||
|
}
|
||||||
let child_rel = if rel.is_empty() {
|
let child_rel = if rel.is_empty() {
|
||||||
entry.name.clone()
|
entry.name.clone()
|
||||||
} else {
|
} else {
|
||||||
@@ -233,15 +288,22 @@ fn collect_dir(
|
|||||||
child_rel
|
child_rel
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
EntryKind::Dir => collect_dir(tree, root, &child_rel, out, total)?,
|
EntryKind::Dir => {
|
||||||
|
*entries_seen += 1;
|
||||||
|
if *entries_seen > MAX_ITEM_FILES {
|
||||||
|
return Err(format!("has more than {} files", MAX_ITEM_FILES));
|
||||||
|
}
|
||||||
|
collect_dir(tree, root, &child_rel, depth + 1, entries_seen, out, total)?
|
||||||
|
}
|
||||||
EntryKind::File => {
|
EntryKind::File => {
|
||||||
|
*entries_seen += 1;
|
||||||
|
if *entries_seen > MAX_ITEM_FILES {
|
||||||
|
return Err(format!("has more than {} files", MAX_ITEM_FILES));
|
||||||
|
}
|
||||||
let data = tree
|
let data = tree
|
||||||
.read_file(&format!("{}/{}", root, child_rel))?
|
.read_file(&format!("{}/{}", root, child_rel))?
|
||||||
.ok_or_else(|| format!("{} vanished while reading", child_rel))?;
|
.ok_or_else(|| format!("{} vanished while reading", child_rel))?;
|
||||||
*total += data.len() as u64;
|
*total += data.len() as u64;
|
||||||
if out.len() + 1 > MAX_ITEM_FILES {
|
|
||||||
return Err(format!("has more than {} files", MAX_ITEM_FILES));
|
|
||||||
}
|
|
||||||
if *total > MAX_ITEM_BYTES {
|
if *total > MAX_ITEM_BYTES {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"is larger than {} MiB",
|
"is larger than {} MiB",
|
||||||
@@ -311,7 +373,8 @@ pub fn item_files(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<Vec<
|
|||||||
}
|
}
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
collect_dir(tree, &path, "", &mut out, &mut total)?;
|
let mut entries_seen = 0usize;
|
||||||
|
collect_dir(tree, &path, "", 0, &mut entries_seen, &mut out, &mut total)?;
|
||||||
let required = match kind {
|
let required = match kind {
|
||||||
ItemKind::Skill => Some("SKILL.md"),
|
ItemKind::Skill => Some("SKILL.md"),
|
||||||
ItemKind::Hook => Some("hook.json"),
|
ItemKind::Hook => Some("hook.json"),
|
||||||
@@ -423,6 +486,13 @@ fn validate_hooks(hooks: &serde_json::Value) -> Result<Vec<String>, String> {
|
|||||||
fn read_hook_json(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
|
fn read_hook_json(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
|
||||||
let path = format!("hooks/{}/hook.json", key);
|
let path = format!("hooks/{}/hook.json", key);
|
||||||
let text = read_utf8(tree, &path)?.ok_or_else(|| format!("{} is missing", path))?;
|
let text = read_utf8(tree, &path)?.ok_or_else(|| format!("{} is missing", path))?;
|
||||||
|
if text.len() as u64 > MAX_MANIFEST_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is larger than {} MiB",
|
||||||
|
path,
|
||||||
|
MAX_MANIFEST_BYTES / (1024 * 1024)
|
||||||
|
));
|
||||||
|
}
|
||||||
serde_json::from_str(&text).map_err(|e| format!("{} is not valid JSON: {}", path, e))
|
serde_json::from_str(&text).map_err(|e| format!("{} is not valid JSON: {}", path, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,6 +892,89 @@ mod tests {
|
|||||||
assert!(parse_catalog(&t)[0].invalid.is_some());
|
assert!(parse_catalog(&t)[0].invalid.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_tree_entries_whose_name_could_escape_the_item() {
|
||||||
|
for bad_name in ["..", ".", "", "a\0b"] {
|
||||||
|
let t = MemTree::new()
|
||||||
|
.file("skills/s/SKILL.md", "x")
|
||||||
|
.raw_named_file("skills/s", bad_name, "evil");
|
||||||
|
let err = item_files(&t, ItemKind::Skill, "s").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("invalid name") || err.contains("invalid entry"),
|
||||||
|
"{:?}: {}",
|
||||||
|
bad_name,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
let items = parse_catalog(&t);
|
||||||
|
let skill = items.iter().find(|i| i.key == "s").unwrap();
|
||||||
|
assert!(
|
||||||
|
skill.invalid.is_some(),
|
||||||
|
"{:?} should mark the item invalid",
|
||||||
|
bad_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directories_count_toward_the_item_file_limit() {
|
||||||
|
// Well under MAX_ITEM_FILES by file count alone (151 files), but 150
|
||||||
|
// directories on top of that pushes total entries past the limit —
|
||||||
|
// a shape the old "only count files" logic let through.
|
||||||
|
let mut t = MemTree::new().file("skills/wide/SKILL.md", "x");
|
||||||
|
for i in 0..150 {
|
||||||
|
t = t.file(&format!("skills/wide/d{}/f.txt", i), "x");
|
||||||
|
}
|
||||||
|
let err = item_files(&t, ItemKind::Skill, "wide").unwrap_err();
|
||||||
|
assert!(err.contains("more than 200 files"), "{}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_folders_cannot_nest_past_the_depth_cap() {
|
||||||
|
let mut nested = "skills/deep".to_string();
|
||||||
|
for i in 0..40 {
|
||||||
|
nested = format!("{}/d{}", nested, i);
|
||||||
|
}
|
||||||
|
let t = MemTree::new()
|
||||||
|
.file("skills/deep/SKILL.md", "x")
|
||||||
|
.file(&format!("{}/leaf.txt", nested), "x");
|
||||||
|
let err = item_files(&t, ItemKind::Skill, "deep").unwrap_err();
|
||||||
|
assert!(err.contains("nested more than 32"), "{}", err);
|
||||||
|
assert!(parse_catalog(&t)[0].invalid.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hook_json_over_1_mib_is_rejected_without_being_parsed() {
|
||||||
|
let huge = "x".repeat(MAX_MANIFEST_BYTES as usize + 1);
|
||||||
|
let t = MemTree::new().file("hooks/big/hook.json", &huge);
|
||||||
|
let items = parse_catalog(&t);
|
||||||
|
let hook = items.iter().find(|i| i.key == "big").unwrap();
|
||||||
|
assert!(
|
||||||
|
hook.invalid
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("larger than 1 MiB"),
|
||||||
|
"{:?}",
|
||||||
|
hook.invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_catalog_over_1_mib_is_rejected_without_being_parsed() {
|
||||||
|
let huge = "x".repeat(MAX_MANIFEST_BYTES as usize + 1);
|
||||||
|
let t = MemTree::new().file("plugins/.claude-plugin/marketplace.json", &huge);
|
||||||
|
let items = parse_catalog(&t);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert!(
|
||||||
|
items[0]
|
||||||
|
.invalid
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("larger than 1 MiB"),
|
||||||
|
"{:?}",
|
||||||
|
items[0].invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hooks_must_name_known_events_and_commands() {
|
fn hooks_must_name_known_events_and_commands() {
|
||||||
let t = MemTree::new()
|
let t = MemTree::new()
|
||||||
|
|||||||
@@ -92,6 +92,29 @@ impl MemTree {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Place a file so that, inside `dir`, it is listed under the literal
|
||||||
|
/// entry name `name` — including a name `file`/`exec_file`/`symlink`
|
||||||
|
/// could never be asked to produce because it doesn't correspond to any
|
||||||
|
/// real filesystem path a caller here would construct: `.`, `..`, empty,
|
||||||
|
/// or containing `/`, `\` or a NUL byte. Exists only so a test can drive
|
||||||
|
/// `catalog::collect_dir`'s hostile-entry-name rejection without relying
|
||||||
|
/// on incidental behaviour of path-string splitting.
|
||||||
|
pub fn raw_named_file(mut self, dir: &str, name: &str, contents: &str) -> Self {
|
||||||
|
let path = if dir.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", dir, name)
|
||||||
|
};
|
||||||
|
self.nodes.insert(
|
||||||
|
path,
|
||||||
|
MemNode::File {
|
||||||
|
data: contents.as_bytes().to_vec(),
|
||||||
|
executable: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn is_dir(&self, path: &str) -> bool {
|
fn is_dir(&self, path: &str) -> bool {
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user