Skip to content

Commit 916e473

Browse files
0xLeifclaude
andauthored
Fix: reject path-traversal module names in add-spec/scaffold (#316)
* Fix: reject path-traversal module names in `add-spec`/`scaffold` `cmd_add_spec` and `cmd_scaffold` wrote the user-supplied module name verbatim into paths (`<specs_dir>/<name>/<name>.spec.md`, and joined onto source dirs), with no validation. A name containing `../` or an absolute path escaped the project: e.g. `specsync add-spec "../../PWNED/evil"` created `evil.spec.md` and a companion directory OUTSIDE the project root (and then panicked mid-way, exit 101). Path traversal writing files to arbitrary locations. Added `validate_module_name`: a module name must be a single path segment — empty, path separators (`/`, `\`), `.`/`..`, and absolute paths are refused with a clear error and exit 1, before any filesystem write. Gated both entry points. Legitimate names (`auth`, `auth-service`, `user_profile`) are unaffected. Reproduced: the traversal above went from "files written outside project + exit 101" to `invalid module name … / exit 1` with nothing created outside. Tests: `validate_module_name` unit tests (accepts plain names, rejects separators/`..`/absolute/empty); `scaffold_rejects_module_name_path_traversal` integration test (both commands fail loud, nothing escapes root). Documented the new invariant + error cases in the cmd_scaffold spec. 734 unit + 170 integration, fmt / clippy (bin) / self-check 100% (37814 LOC). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m * 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 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent eb4bb1d commit 916e473

10 files changed

Lines changed: 147 additions & 4 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_scaffold/cmd_scaffold.spec.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Implements `specsync add-spec` and `specsync scaffold` commands. Creates new spe
3434
2. `cmd_scaffold` supports custom templates and auto-appends to registry
3535
3. Neither overwrites existing specs
3636
4. Companion files (tasks.md, context.md, requirements.md, testing.md) are always generated with guided starter content; design.md is generated only when `companions.design` is enabled in config
37+
5. Both validate `module_name` as a single path segment before any filesystem write — a name containing a path separator (`/`, `\`), `.`/`..`, or an absolute path is refused with exit 1, so scaffolding can never create files outside the project (no path traversal)
3738

3839
## Behavioral Examples
3940

@@ -50,6 +51,8 @@ Implements `specsync add-spec` and `specsync scaffold` commands. Creates new spe
5051
| Spec exists | Early return |
5152
| Dir creation fails | Exits 1 |
5253
| Custom template dir missing | Falls back to built-in |
54+
| Module name with path separator / `..` / absolute path | Refused before any write; prints `invalid module name …` and exits 1 |
55+
| Empty module name | Refused; exits 1 |
5356

5457
## Dependencies
5558

specs/cmd_scaffold/testing.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ spec: cmd_scaffold.spec.md
66

77
| Area | Command | Assertions To Watch |
88
|------|---------|---------------------|
9-
| `src/commands/scaffold.rs` | cargo test commands::scaffold | No inline `#[cfg(test)]` module; add focused coverage for `cmd_add_spec`, `cmd_scaffold`, source auto-detection, and registry auto-registration before risky changes |
9+
| `src/commands/scaffold.rs` | cargo test commands::scaffold | Inline tests cover `validate_module_name` (accepts plain names, rejects separators/`..`/absolute); still add coverage for source auto-detection and registry auto-registration before risky changes |
10+
| `tests/integration.rs` | cargo test --test integration scaffold_rejects_module_name_path_traversal | `add-spec`/`scaffold` with a traversal name (`../../escape/evil`) exit non-zero with "invalid module name" and write nothing outside the project root |
1011
| `tests/integration.rs` | cargo test --test integration generate_creates_companion_files | Exercises the same `generator::generate_companion_files_for_spec` path scaffold uses to emit companions |
1112
| `tests/integration.rs` | cargo test --test integration companion_files_not_overwritten_on_regenerate | Confirms existing companions are not clobbered when re-running on an existing spec |
1213

@@ -27,6 +28,7 @@ spec: cmd_scaffold.spec.md
2728
| Spec exists | Early return | Keep or add a focused assertion before changing this behavior |
2829
| Dir creation fails | Exits 1 | Keep or add a focused assertion before changing this behavior |
2930
| Custom template dir missing | Falls back to built-in | Keep or add a focused assertion before changing this behavior |
31+
| Module name with path separator / `..` / absolute / empty | Refused before any write; exits 1 with "invalid module name" (no path traversal) | Asserted by `scaffold_rejects_module_name_path_traversal` + `commands::scaffold::tests`; keep the guard first in both entry points |
3032

3133
## Reviewer Checklist
3234

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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ use crate::config::load_config;
77
use crate::generator;
88
use crate::registry;
99

10+
use super::validate_module_name;
11+
1012
pub fn cmd_add_spec(root: &Path, module_name: &str) {
13+
if let Err(e) = validate_module_name(module_name) {
14+
eprintln!("{e}");
15+
process::exit(1);
16+
}
1117
let config = load_config(root);
1218
let specs_dir = root.join(&config.specs_dir);
1319
let spec_dir = specs_dir.join(module_name);
@@ -184,6 +190,10 @@ pub fn cmd_scaffold(
184190
dir: Option<PathBuf>,
185191
template: Option<PathBuf>,
186192
) {
193+
if let Err(e) = validate_module_name(module_name) {
194+
eprintln!("{e}");
195+
process::exit(1);
196+
}
187197
let config = load_config(root);
188198
let specs_dir = dir.unwrap_or_else(|| root.join(&config.specs_dir));
189199
let spec_dir = specs_dir.join(module_name);

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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5483,6 +5483,37 @@ fn scaffold_auto_detects_single_source_file() {
54835483
);
54845484
}
54855485

5486+
/// `new`/`add-spec`/`scaffold` must refuse a module name that would escape the project
5487+
/// (path traversal) rather than writing spec/companion files to an arbitrary location.
5488+
#[test]
5489+
fn scaffold_rejects_module_name_path_traversal() {
5490+
let tmp = TempDir::new().unwrap();
5491+
let root = tmp.path();
5492+
write_config(root, "specs", &["src"]);
5493+
5494+
for sub in ["new", "add-spec", "scaffold"] {
5495+
let output = specsync()
5496+
.args([sub, "../../escape/evil", "--root", root.to_str().unwrap()])
5497+
.output()
5498+
.unwrap();
5499+
assert!(
5500+
!output.status.success(),
5501+
"`{sub}` with a traversal module name must fail loud"
5502+
);
5503+
let stderr = String::from_utf8_lossy(&output.stderr);
5504+
assert!(
5505+
stderr.contains("invalid module name"),
5506+
"`{sub}` should report an invalid module name; stderr: {stderr}"
5507+
);
5508+
}
5509+
5510+
// The guard runs before any filesystem write, so nothing escaped the project root.
5511+
assert!(
5512+
!root.parent().unwrap().join("escape").exists(),
5513+
"a spec/companion escaped the project root"
5514+
);
5515+
}
5516+
54865517
/// The original repro: a spec with a warning gets recorded in the hash cache,
54875518
/// then `check --fix` (without --force) reports "All specs unchanged" and
54885519
/// fixes nothing. An explicit --fix must never be a silent no-op.

0 commit comments

Comments
 (0)