Skip to content

Commit ed81207

Browse files
0xLeifclaude
andcommitted
Address review: gate ALL scaffolding entry points + robust name validation
Adversarial review found the first cut was incomplete (two blocking issues): - `new` (cmd_new) is another user-name scaffolding entry point that was NOT guarded, so `specsync new "../../PWNED" --full` still escaped the project on every platform. - The blocklist relied on `is_absolute()`, which misses Windows drive-relative names (`C:foo`): no separator, not absolute, so it passed — and on Windows `join` replaces the base, escaping `<specs_dir>/<name>/`. Plus two follow-ups it surfaced: `wizard` had the same traversal gap (interactive, only checked empty), and control characters/newlines were accepted (YAML frontmatter injection / control-char dir names, in-project but unexpected). Reworked into a single shared `validate_module_name` in commands/mod.rs (next to `load_and_discover`) and gated ALL FOUR scaffolding entry points: `new`, `add-spec`, `scaffold`, `wizard`. The validator now requires a single `Component::Normal` segment with no raw separator and no control chars — platform-aware, so it also rejects Windows drive-relative prefixes that `is_absolute()` misses, and blocks frontmatter injection. Reproduced: `new "../../PWNED" --full` went from "files written outside project, exit 0" to `invalid module name … / exit 1` with nothing created outside; a newline-injecting name is refused. Tests: `validate_module_name` unit tests (plain/unicode names ok; empty, separators, `.`/`..`, absolute, control chars rejected; `#[cfg(windows)]` drive-relative rejected); `scaffold_rejects_module_name_path_traversal` now also covers `new`. Documented the shared export in commands.spec.md and the new error case in the new/wizard specs. 734 unit + 170 integration, fmt / clippy (bin) / self-check 100% (37851 LOC). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m
1 parent 44b7895 commit ed81207

8 files changed

Lines changed: 103 additions & 64 deletions

File tree

specs/cmd_new/cmd_new.spec.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Implements the `specsync new` command. Quick-creates a minimal spec with auto-de
5555
| Spec already exists | Exits 1 |
5656
| No source files found | Creates spec with empty `files:` and prints a ⚠ explaining that the `files:` list must be filled in before `check` passes |
5757
| Dir creation fails | Exits 1 |
58+
| Invalid module name (path separator, `.`/`..`, absolute/drive-relative, control chars) | Refused via `validate_module_name` before any write; prints `invalid module name …` and exits 1 (no path traversal) |
5859

5960
## Dependencies
6061

specs/cmd_wizard/cmd_wizard.spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Implements the `specsync wizard` command — an interactive TUI wizard for creat
5454

5555
| Condition | Behavior |
5656
|-----------|----------|
57-
| Empty module name entered | Exits with code 1 |
57+
| Empty or unsafe module name entered (path separator, `.`/`..`, absolute/drive-relative, control chars) | Refused via `validate_module_name`; prints `invalid module name …` and exits 1 (no path traversal) |
5858
| Spec directory already exists | Prints error and exits 1 |
5959
| User cancels at confirmation | Exits cleanly with code 0 |
6060
| Directory creation fails | Exits with code 1 |

specs/commands/commands.spec.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Shared command infrastructure used by all CLI subcommands. Provides config loadi
2929
| Function | Parameters | Returns | Description |
3030
|----------|-----------|---------|-------------|
3131
| `load_and_discover` | `root: &Path, allow_empty: bool` | `(SpecSyncConfig, Vec<PathBuf>)` | Load config and discover all spec files (excluding `_`-prefixed); exits if empty and `allow_empty` is false |
32+
| `validate_module_name` | `module_name: &str` | `Result<(), String>` | Validate a user-supplied module name for the scaffolding commands (`new`, `add-spec`, `scaffold`, `wizard`): must be a single plain path segment (one `Component::Normal`, no separators/`.`/`..`/absolute/drive-relative/control chars), preventing path traversal outside the project |
3233
| `filter_specs` | `root: &Path, spec_files: &[PathBuf], filters: &[String]` | `Vec<PathBuf>` | Filter spec files by user-provided names/paths (exact path, relative path, filename, module name); returns all if filters is empty |
3334
| `filter_by_status` | `spec_files: &[PathBuf], exclude: &[String], only: &[String]` | `Vec<PathBuf>` | Filter spec files by their frontmatter status field; supports exclude-list and allow-list modes |
3435
| `build_schema_columns` | `root: &Path, config: &SpecSyncConfig` | `HashMap<String, SchemaTable>` | Build column-level schema from migration files if `schema_dir` is configured |

src/commands/mod.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,37 @@ pub fn load_and_discover(root: &Path, allow_empty: bool) -> (types::SpecSyncConf
6666
(config, spec_files)
6767
}
6868

69+
/// Validate a user-supplied module name used by the scaffolding commands
70+
/// (`new`, `add-spec`, `scaffold`, `wizard`). The name is written verbatim into paths
71+
/// like `<specs_dir>/<name>/<name>.spec.md` and joined onto source dirs, so an
72+
/// unvalidated name containing a path separator, `.`/`..`, or an absolute/drive-relative
73+
/// path would let scaffolding create files anywhere on disk (path traversal).
74+
///
75+
/// A valid name is a single plain path segment: exactly one `Component::Normal`, with no
76+
/// raw path separator and no control characters. The component check is platform-aware —
77+
/// it also rejects Windows drive-relative prefixes like `C:foo` that `Path::is_absolute`
78+
/// misses. Control characters are rejected so a name cannot inject into the generated
79+
/// YAML frontmatter or create a control-char directory. Returns `Err` (to be printed and
80+
/// exited on) rather than writing outside the project.
81+
pub(crate) fn validate_module_name(module_name: &str) -> Result<(), String> {
82+
let single_normal_segment = {
83+
let mut components = Path::new(module_name).components();
84+
matches!(components.next(), Some(std::path::Component::Normal(_)))
85+
&& components.next().is_none()
86+
};
87+
let clean = !module_name.contains('/')
88+
&& !module_name.contains('\\')
89+
&& !module_name.chars().any(char::is_control);
90+
if single_normal_segment && clean {
91+
return Ok(());
92+
}
93+
Err(format!(
94+
"invalid module name `{}`: use a single plain name — no path separators (`/`, `\\`), \
95+
`.`/`..`, drive prefixes, absolute paths, or control characters",
96+
module_name.escape_default()
97+
))
98+
}
99+
69100
/// Filter spec files by user-provided spec names/paths.
70101
/// Matches against: exact file path, relative path, module name (from filename stem).
71102
/// Returns the full list if `filters` is empty.
@@ -702,3 +733,61 @@ pub fn create_drift_issues(
702733
}
703734
}
704735
}
736+
737+
#[cfg(test)]
738+
mod tests {
739+
use super::validate_module_name;
740+
741+
#[test]
742+
fn validate_module_name_accepts_plain_names() {
743+
for name in [
744+
"auth",
745+
"auth-service",
746+
"user_profile",
747+
"v2",
748+
"a.b",
749+
"Módulo",
750+
] {
751+
assert!(
752+
validate_module_name(name).is_ok(),
753+
"`{name}` should be a valid module name"
754+
);
755+
}
756+
}
757+
758+
#[test]
759+
fn validate_module_name_rejects_traversal_and_injection() {
760+
// Empty, path separators, parent/current refs, absolute paths, and control
761+
// characters must all be refused — none may reach a filesystem join.
762+
for name in [
763+
"",
764+
".",
765+
"..",
766+
"../evil",
767+
"../../PWNED/evil",
768+
"a/b",
769+
"a\\b",
770+
"sub/mod",
771+
"/tmp/abs",
772+
"auth/", // trailing separator normalizes to one segment, still refused
773+
"evil\nversion: 99", // newline → frontmatter injection
774+
"tab\tname",
775+
"null\0byte",
776+
] {
777+
assert!(
778+
validate_module_name(name).is_err(),
779+
"`{}` must be rejected as an unsafe module name",
780+
name.escape_default()
781+
);
782+
}
783+
}
784+
785+
#[test]
786+
#[cfg(windows)]
787+
fn validate_module_name_rejects_windows_drive_relative() {
788+
// `C:foo` has no separator and `is_absolute()` is false (drive-relative), but its
789+
// components include a Prefix, so the single-Normal-segment check refuses it.
790+
assert!(validate_module_name("C:foo").is_err());
791+
assert!(validate_module_name("C:\\abs").is_err());
792+
}
793+
}

src/commands/new.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ use crate::config::load_config;
77
use crate::exports;
88
use crate::generator;
99

10+
use super::validate_module_name;
11+
1012
/// Quick-create a minimal spec for a module with auto-detected source files.
1113
pub fn cmd_new(root: &Path, module_name: &str, full: bool) {
14+
if let Err(e) = validate_module_name(module_name) {
15+
eprintln!("{e}");
16+
process::exit(1);
17+
}
1218
let config = load_config(root);
1319
let specs_dir = root.join(&config.specs_dir);
1420
let spec_dir = specs_dir.join(module_name);

src/commands/scaffold.rs

Lines changed: 1 addition & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,7 @@ use crate::config::load_config;
77
use crate::generator;
88
use crate::registry;
99

10-
/// Reject a module name that could escape the target directory. The name is written
11-
/// verbatim into paths like `<specs_dir>/<name>/<name>.spec.md` and joined onto source
12-
/// dirs, so a name containing a path separator, `.`/`..`, or an absolute/rooted path
13-
/// would let `add-spec`/`scaffold` create files anywhere on disk (path traversal). A
14-
/// module name must be a single path segment. Fails loud rather than writing outside
15-
/// the project.
16-
fn validate_module_name(module_name: &str) -> Result<(), String> {
17-
if module_name.is_empty() {
18-
return Err("module name must not be empty".to_string());
19-
}
20-
if module_name.contains('/')
21-
|| module_name.contains('\\')
22-
|| module_name == "."
23-
|| module_name == ".."
24-
|| Path::new(module_name).is_absolute()
25-
{
26-
return Err(format!(
27-
"invalid module name `{module_name}`: use a single name without path \
28-
separators (`/`, `\\`), `.`/`..`, or an absolute path"
29-
));
30-
}
31-
Ok(())
32-
}
10+
use super::validate_module_name;
3311

3412
pub fn cmd_add_spec(root: &Path, module_name: &str) {
3513
if let Err(e) = validate_module_name(module_name) {
@@ -313,39 +291,3 @@ pub fn cmd_scaffold(
313291
}
314292
}
315293
}
316-
317-
#[cfg(test)]
318-
mod tests {
319-
use super::*;
320-
321-
#[test]
322-
fn validate_module_name_accepts_plain_names() {
323-
for name in ["auth", "auth-service", "user_profile", "v2", "a.b"] {
324-
assert!(
325-
validate_module_name(name).is_ok(),
326-
"`{name}` should be a valid module name"
327-
);
328-
}
329-
}
330-
331-
#[test]
332-
fn validate_module_name_rejects_path_traversal() {
333-
// Anything that could escape `<specs_dir>/<name>/` must be refused.
334-
for name in [
335-
"",
336-
"..",
337-
".",
338-
"../evil",
339-
"../../PWNED/evil",
340-
"a/b",
341-
"a\\b",
342-
"/tmp/abs",
343-
"sub/mod",
344-
] {
345-
assert!(
346-
validate_module_name(name).is_err(),
347-
"`{name}` must be rejected as an unsafe module name"
348-
);
349-
}
350-
}
351-
}

src/commands/wizard.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ pub fn cmd_wizard(root: &Path) {
2929
.unwrap_or_else(|_| process::exit(0));
3030
let module_name = module_name.trim().to_string();
3131

32-
if module_name.is_empty() {
33-
eprintln!("{} Module name cannot be empty", "Error:".red());
32+
if let Err(e) = super::validate_module_name(&module_name) {
33+
eprintln!("{} {e}", "Error:".red());
3434
process::exit(1);
3535
}
3636

tests/integration.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5483,15 +5483,15 @@ fn scaffold_auto_detects_single_source_file() {
54835483
);
54845484
}
54855485

5486-
/// `add-spec`/`scaffold` must refuse a module name that would escape the project
5486+
/// `new`/`add-spec`/`scaffold` must refuse a module name that would escape the project
54875487
/// (path traversal) rather than writing spec/companion files to an arbitrary location.
54885488
#[test]
54895489
fn scaffold_rejects_module_name_path_traversal() {
54905490
let tmp = TempDir::new().unwrap();
54915491
let root = tmp.path();
54925492
write_config(root, "specs", &["src"]);
54935493

5494-
for sub in ["add-spec", "scaffold"] {
5494+
for sub in ["new", "add-spec", "scaffold"] {
54955495
let output = specsync()
54965496
.args([sub, "../../escape/evil", "--root", root.to_str().unwrap()])
54975497
.output()

0 commit comments

Comments
 (0)