You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: specs/cmd_new/cmd_new.spec.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -55,6 +55,7 @@ Implements the `specsync new` command. Quick-creates a minimal spec with auto-de
55
55
| Spec already exists | Exits 1 |
56
56
| No source files found | Creates spec with empty `files:` and prints a ⚠ explaining that the `files:` list must be filled in before `check` passes |
57
57
| 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) |
Copy file name to clipboardExpand all lines: specs/cmd_scaffold/cmd_scaffold.spec.md
+3Lines changed: 3 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -34,6 +34,7 @@ Implements `specsync add-spec` and `specsync scaffold` commands. Creates new spe
34
34
2.`cmd_scaffold` supports custom templates and auto-appends to registry
35
35
3. Neither overwrites existing specs
36
36
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)
37
38
38
39
## Behavioral Examples
39
40
@@ -50,6 +51,8 @@ Implements `specsync add-spec` and `specsync scaffold` commands. Creates new spe
50
51
| Spec exists | Early return |
51
52
| Dir creation fails | Exits 1 |
52
53
| 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 |
Copy file name to clipboardExpand all lines: specs/cmd_scaffold/testing.md
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,7 +6,8 @@ spec: cmd_scaffold.spec.md
6
6
7
7
| Area | Command | Assertions To Watch |
8
8
|------|---------|---------------------|
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 |
10
11
|`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 |
11
12
|`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 |
12
13
@@ -27,6 +28,7 @@ spec: cmd_scaffold.spec.md
27
28
| Spec exists | Early return | Keep or add a focused assertion before changing this behavior |
28
29
| Dir creation fails | Exits 1 | Keep or add a focused assertion before changing this behavior |
29
30
| 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 |
Copy file name to clipboardExpand all lines: specs/cmd_wizard/cmd_wizard.spec.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -54,7 +54,7 @@ Implements the `specsync wizard` command — an interactive TUI wizard for creat
54
54
55
55
| Condition | Behavior |
56
56
|-----------|----------|
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)|
58
58
| Spec directory already exists | Prints error and exits 1 |
59
59
| User cancels at confirmation | Exits cleanly with code 0 |
Copy file name to clipboardExpand all lines: specs/commands/commands.spec.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -29,6 +29,7 @@ Shared command infrastructure used by all CLI subcommands. Provides config loadi
29
29
| Function | Parameters | Returns | Description |
30
30
|----------|-----------|---------|-------------|
31
31
|`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 |
32
33
|`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 |
33
34
|`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 |
34
35
|`build_schema_columns`|`root: &Path, config: &SpecSyncConfig`|`HashMap<String, SchemaTable>`| Build column-level schema from migration files if `schema_dir` is configured |
0 commit comments