Skip to content

VPR-138 fix(cms): harden DownloadZip and unblock controller route#184

Open
rlorenzo wants to merge 2 commits into
mainfrom
VPR-138-cms-download-cleanup
Open

VPR-138 fix(cms): harden DownloadZip and unblock controller route#184
rlorenzo wants to merge 2 commits into
mainfrom
VPR-138-cms-download-cleanup

Conversation

@rlorenzo
Copy link
Copy Markdown
Contributor

@rlorenzo rlorenzo commented May 9, 2026

Summary

Security hardening of CMS.DownloadZip (commit 6e67564):

  • Replace user-influenced temp file path with a server-generated GUID under a dedicated %TEMP%/Viper-CMS/ folder; FileMode.Create + ZipArchiveMode.Create + finally-cleanup.
  • Extract sanitization and temp-path generation into CmsFilePathSafety (static + DI ICmsFilePathSafety for the PLAN-CMS migration consumer); Scrutor registration for Viper.Areas.CMS.Services.
  • SanitizeDownloadName: cross-platform separator handling (\/ before Path.GetFileName), allow-list, reserved Windows device names, dot/space-only rejection, forced .zip suffix.
  • SanitizeZipEntryName: defense-in-depth ZIP-slip — strip path components from each CMSFile.FriendlyName before writing to the archive.
  • BuildTempArchivePath: trim trailing separator so the containment check survives "C:\Temp\"-style roots.
  • Encrypted-branch writes go directly to ZipArchiveEntry.Open() instead of wrapping a StreamWriter (text encoder was unused).
  • 400 on empty fileGUIDs, 404 on no resolvable files — both before any filesystem I/O.

Dev-environment routing fix (commit 05876a4):

  • The URL rewriter and Vite proxy claimed every /CMS/* path before MVC routing ran, so CMSController.Files never executed and the Vue SPA shell came back instead. Wrap the rewriter + Vite proxy + SPA static files in UseWhen(ctx => ctx.GetEndpoint() is null, …) so MVC controllers claim their endpoints first. Vue SPA roots, deep client-side routes, and Vite/HMR assets unaffected.

Test plan

  • test/CMS/DownloadZipSecurityTests.cs — 51 tests across sanitizer, ZIP-entry helper, temp-path builder, traversal regression, trailing-separator handling.
  • Full backend test suite passing on CI (Linux).
  • Local dev smoke: /CMS/Files?ids=<bogus> → 404 from controller (was 200 SPA shell pre-routing-fix); /CMS still serves SPA shell; /2/vue/@vite/client + HMR unaffected; /Effort and /favicon.ico unaffected.

TEST verification is not possible pre-merge — this PR's routing fix is what makes /2/CMS/Files reachable on TEST in the first place. Post-deploy manual smoke (after merge → Development → TEST):

  1. /2/CMS/Files?ids=<real-guid>&fileName=Report.zip → ZIP downloads with Content-Disposition: attachment; filename=Report.zip.
  2. /2/CMS/Files?ids=<real-guid>&fileName=..%5C..%5Cevil.zip → ZIP still downloads, response filename sanitized to evil.zip (no .., no separators).
  3. %TEMP%/Viper-CMS/ empty after each request (finally-cleanup intact).

Notes

CMSController.Files carries no production traffic today — file downloads go through the legacy ColdFusion CMS at the root domain (/cms/files/?id=...). VIPER2's modernized handler exists as the migration target for PLAN-CMS.md. The two commits in this PR are load-bearing on each other: shipping the routing fix without the hardening would expose the path-traversal vulnerability; shipping the hardening alone leaves it gated behind a still-broken route. Merging both at once unlocks the endpoint and secures it in the same step.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

📝 Walkthrough

Walkthrough

Fixes directory-traversal and temp-file safety in CMS ZIP downloads: add CmsFilePathSafety helpers, refactor DownloadZip to use validated temp paths and sanitized entry/download names, update DI registration, and add comprehensive security tests including a VPR-138 regression.

Changes

CMS DownloadZip Security Hardening

Layer / File(s) Summary
Service Contract & Implementation
web/Areas/CMS/Services/CmsFilePathSafety.cs
Introduces CmsFilePathSafety static utility with SanitizeDownloadName, BuildTempArchivePath, GetZipTempFolder, and SanitizeZipEntryName. Adds ICmsFilePathSafety and sealed CmsFilePathSafetyService adapter delegating to the static methods.
DownloadZip Refactoring
web/Areas/CMS/Data/CMS.cs
Validates inputs (BadRequest for missing GUIDs, NotFound when no files), uses CmsFilePathSafety.SanitizeDownloadName and SanitizeZipEntryName, generates temp archive paths via BuildTempArchivePath, creates zip in isolated temp folder (ZipArchiveMode.Create), serves bytes with MimeTypes["zip"], and performs best-effort cleanup in a finally block.
Dependency Registration
web/Program.cs
Adds Viper.Areas.Curriculum.Services to Scrutor InNamespaces(...) so convention-based *Service/*Validator types are auto-registered.
Security Tests
test/CMS/DownloadZipSecurityTests.cs
Adds comprehensive tests for SanitizeDownloadName (traversal/separators, .zip suffix rules, reserved Windows device names, null/empty fallback, safe characters), SanitizeZipEntryName behavior, BuildTempArchivePath containment/uniqueness/argument validation, and a VPR-138 regression ensuring traversal payloads cannot escape the temp root. Includes helpers for isolated temp directory creation and cleanup.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title directly addresses the main objective: hardening the DownloadZip method and fixing the routing issue that blocks the controller route.
Description check ✅ Passed The description comprehensively covers both the security hardening and routing fix, with detailed test plans and verification steps.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch VPR-138-cms-download-cleanup

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter
Copy link
Copy Markdown

Bundle Report

Bundle size has no change ✅

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 9, 2026

Codecov Report

❌ Patch coverage is 35.97122% with 89 lines in your changes missing coverage. Please review.
✅ Project coverage is 42.99%. Comparing base (5949e52) to head (d85d125).

Files with missing lines Patch % Lines
web/Program.cs 0.00% 49 Missing ⚠️
web/Areas/CMS/Data/CMS.cs 0.00% 29 Missing ⚠️
web/Areas/CMS/Services/CmsFilePathSafety.cs 81.96% 9 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #184      +/-   ##
==========================================
+ Coverage   42.96%   42.99%   +0.03%     
==========================================
  Files         877      878       +1     
  Lines       51468    51542      +74     
  Branches     4802     4812      +10     
==========================================
+ Hits        22113    22163      +50     
- Misses      28831    28853      +22     
- Partials      524      526       +2     
Flag Coverage Δ
backend 43.08% <35.97%> (+0.03%) ⬆️
frontend 41.34% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@rlorenzo
Copy link
Copy Markdown
Contributor Author

rlorenzo commented May 9, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/CMS/DownloadZipSecurityTests.cs`:
- Around line 5-9: Update the XML doc comment in the CMS.DownloadZip security
tests to use inline code formatting instead of an unresolved <see/> tag: replace
the <see cref="CMS.DownloadZip"/> reference with <c>CMS.DownloadZip</c> in the
comment above the tests (the summary for the tests covering CMS.DownloadZip,
filename sanitizer and temp-archive path builder) to remove the analyzer
warning.

In `@web/Areas/CMS/Data/CMS.cs`:
- Around line 491-505: Sanitize the ZIP entry names before creating entries:
instead of passing file.FriendlyName directly to archive.CreateEntry and
archive.CreateEntryFromFile, strip any path characters (e.g., via
Path.GetFileName or by calling the existing
CmsFilePathSafety.SanitizeDownloadName()) and use that sanitized name; update
the block that handles encrypted files (where DecryptFile is used and writer
writes to fileEntry) and the else branch (where CreateEntryFromFile is called)
to supply the sanitized filename variable.

In `@web/Areas/CMS/Services/CmsFilePathSafety.cs`:
- Around line 48-50: Normalize backslashes before extracting the filename and
ensure any remaining traversal dots are removed: in CmsFilePathSafety.cs, before
calling Path.GetFileName(userInput) replace backslashes with forward slashes
(e.g., userInput.Replace('\\','/')) so Path.GetFileName treats sequences like
"\..\evil.zip" as path segments on Unix, then after applying
SafeFileNameAllowList and Trim remove any remaining ".." sequences (e.g., strip
or collapse runs of "..") so
SanitizeDownloadName_StripsPathSeparatorsAndTraversal and VPR-138 tests no
longer fail.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 84f82321-14d3-46c2-b06b-32c07318742d

📥 Commits

Reviewing files that changed from the base of the PR and between 4678e73 and 0ad937b.

📒 Files selected for processing (4)
  • test/CMS/DownloadZipSecurityTests.cs
  • web/Areas/CMS/Data/CMS.cs
  • web/Areas/CMS/Services/CmsFilePathSafety.cs
  • web/Program.cs

Comment thread test/CMS/DownloadZipSecurityTests.cs
Comment thread web/Areas/CMS/Data/CMS.cs Outdated
Comment thread web/Areas/CMS/Services/CmsFilePathSafety.cs Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the legacy CMS ZIP download flow by separating user-provided download names (response header only) from server-generated on-disk temp archive paths, reducing the risk of path traversal and unsafe filesystem writes.

Changes:

  • Introduces CmsFilePathSafety (static + DI-friendly interface/service) for sanitizing download names, ZIP entry names, and generating per-request temp archive paths.
  • Updates CMS.DownloadZip to validate inputs earlier (400/404), generate temp ZIP paths under a dedicated temp folder, create archives with FileMode.Create/ZipArchiveMode.Create, and always attempt cleanup in finally.
  • Adds a focused security test suite covering sanitization and temp-path generation regressions.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
web/Program.cs Adds Viper.Areas.CMS.Services to the Scrutor namespace scan for auto DI registration.
web/Areas/CMS/Services/CmsFilePathSafety.cs Adds centralized path/filename sanitization and temp archive path generation helpers (+ DI wrapper).
web/Areas/CMS/Data/CMS.cs Reworks DownloadZip to use the new safety helpers, improve early validation, and ensure temp file cleanup.
test/CMS/DownloadZipSecurityTests.cs Adds security regression tests for download-name sanitization, temp-path containment, and ZIP entry naming.

Comment thread web/Areas/CMS/Services/CmsFilePathSafety.cs
Comment thread web/Areas/CMS/Data/CMS.cs Outdated
@rlorenzo rlorenzo force-pushed the VPR-138-cms-download-cleanup branch from 2531897 to caaaceb Compare May 9, 2026 07:12
@rlorenzo
Copy link
Copy Markdown
Contributor Author

rlorenzo commented May 9, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/Areas/CMS/Services/CmsFilePathSafety.cs`:
- Around line 54-58: The allow-list filtering can leave names that consist only
of dots/spaces (e.g., "." or "..") which then become unsafe when suffixed (e.g.,
"..zip"); in the method using SafeFileNameAllowList with variable fileNamePart,
after computing filtered = SafeFileNameAllowList.Replace(fileNamePart,
string.Empty).Trim() treat any filtered value that consists only of dots and/or
whitespace as invalid and return DefaultDownloadName instead; update the
conditional that checks string.IsNullOrEmpty(filtered) (in CmsFilePathSafety.cs
/ the method handling download name normalization) to also detect and reject
dot-only strings (for example by checking filtered.Trim('.') == string.Empty or
equivalent) so such inputs fall back to FileDownload.zip.
- Around line 124-133: The extracted basename in SanitizeZipEntryName (the block
that computes entry = Path.GetFileName(friendlyName.Replace('\\','/')) and
returns it) must reject names that collapse to "." or ".." or consist only of
dots/spaces; update the logic to treat such values as invalid and fall back to
the sanitized fallback basename (i.e., return
Path.GetFileName(fallback.Replace('\\','/')) when entry is null/whitespace or
matches a pattern of only dots/spaces or exactly "." or ".."); also add a
regression unit test next to SanitizeZipEntryName_StripsPathComponents covering
inputs like "..", "dir/..", and @"nested\." to assert the fallback is used.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 31654e00-bc72-46a1-8d47-7691475ace9a

📥 Commits

Reviewing files that changed from the base of the PR and between 0ad937b and caaaceb.

📒 Files selected for processing (4)
  • test/CMS/DownloadZipSecurityTests.cs
  • web/Areas/CMS/Data/CMS.cs
  • web/Areas/CMS/Services/CmsFilePathSafety.cs
  • web/Program.cs

Comment thread web/Areas/CMS/Services/CmsFilePathSafety.cs
Comment thread web/Areas/CMS/Services/CmsFilePathSafety.cs Outdated
@rlorenzo rlorenzo force-pushed the VPR-138-cms-download-cleanup branch from caaaceb to 7ad1880 Compare May 9, 2026 15:57
@rlorenzo rlorenzo requested a review from Copilot May 9, 2026 16:25
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

web/Areas/CMS/Data/CMS.cs:459

  • DownloadZip only checks fileGUIDs.Length == 0, but the controller passes ids.Split(','), which can include empty/whitespace entries (e.g. ids=, or trailing commas). That currently falls through and returns 404 after doing work. Consider trimming/filtering out empty GUID strings up front and returning 400 when no non-empty IDs remain to match the intended “empty fileGUIDs => 400” behavior.
            if (fileGUIDs.Length == 0)
            {
                return controller.BadRequest("Missing fileGUIDs parameter.");
            }

            var safeDownloadName = CmsFilePathSafety.SanitizeDownloadName(fileName);

            List<CMSFile> files = new();
            AaudUser? currentUser = UserHelper.GetCurrentUser();

            foreach (var guid in fileGUIDs)

Comment thread web/Areas/CMS/Services/CmsFilePathSafety.cs Outdated
Comment thread web/Areas/CMS/Data/CMS.cs Outdated
Comment thread web/Program.cs Fixed
Comment thread web/Program.cs Fixed
Comment thread web/Program.cs Fixed
Comment thread web/Program.cs Fixed
Comment thread web/Program.cs Fixed
Comment thread web/Program.cs Fixed
@rlorenzo rlorenzo changed the title VPR-138 fix(cms): harden temp file path in DownloadZip VPR-138 fix(cms): harden DownloadZip and unblock controller route May 14, 2026
rlorenzo added 2 commits May 13, 2026 20:29
- Replace user-influenced temp file path with a server-generated GUID
  under a dedicated temp folder; use FileMode.Create +
  ZipArchiveMode.Create and delete in a finally block.
- Add CmsFilePathSafety helper (static + DI variant) for download-name
  sanitization, ZIP-entry sanitization, and per-request temp-archive
  path generation. Register the new Viper.Areas.CMS.Services namespace
  with the existing Scrutor scan.
- SanitizeDownloadName normalizes backslash to forward slash before
  Path.GetFileName so Windows-style traversal payloads ("..\evil") are
  stripped on Linux runners too. Allow-list, reserved Windows device
  names, and forced .zip suffix come along.
- SanitizeZipEntryName strips path components from CMSFile.FriendlyName
  before passing it to archive.CreateEntry/CreateEntryFromFile -
  defense in depth against ZIP-slip when stored names contain
  separators or traversal sequences.
- BuildTempArchivePath trims trailing separators on the resolved root
  so the StartsWith containment check survives a "C:\Temp\\"-style
  input.
- DownloadZip writes encrypted bytes directly to the ZipArchiveEntry
  stream instead of wrapping a StreamWriter (the text encoder was
  unused). Return 400 on empty fileGUIDs and 404 when no files resolve,
  both before any filesystem I/O.
- Add 42 unit tests covering the sanitizer (separators, traversal,
  reserved names, extension forcing), ZIP entry helper, temp-path
  builder (incl. trailing separator), and a parameterized traversal
  regression guard.
…efixes

Vue app prefixes like /CMS, /Effort, etc. were claimed in two places that
ran before MVC routing: a dev-only Vite proxy and the URL rewriter
("(?i)^{appName}" → "/2/vue/src/{app}/index.html"). Any controller route
under those prefixes - notably /CMS/Files (CMSController.Files, the
modernized download surface VPR-138 hardens) - was returned the SPA shell
instead of executing.

Restructure so SPA shell serving (Vite proxy + rewriter + /2/vue static
files) runs inside a UseWhen branch gated on ctx.GetEndpoint() == null.
MVC controller routes claim their endpoints during UseRouting() and the
branch is skipped; everything else (Vue SPA roots, deep client-side
routes, Vite asset paths) still hits the branch and gets the SPA shell.

Smoke-tested locally:
- /CMS/Files?ids=<bogus> now returns 404 from the controller
  (was 200 SPA shell).
- /CMS still serves the Vue SPA shell.
- /Effort and /favicon.ico unaffected.
- Vite /2/vue/@vite/client and HMR assets unaffected.
@rlorenzo rlorenzo force-pushed the VPR-138-cms-download-cleanup branch from 05876a4 to d85d125 Compare May 14, 2026 03:33
Comment thread web/Program.cs
DefaultFileNames = new List<string> { "index.html" },
FileProvider = new PhysicalFileProvider(
Path.Combine(builder.Environment.ContentRootPath, "wwwroot/vue")),
Path.Combine(builder.Environment.ContentRootPath, "wwwroot", "vue")),
Comment thread web/Program.cs
branch.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
Path.Combine(builder.Environment.ContentRootPath, "wwwroot", "vue")),
Comment thread web/Program.cs
FileProvider = new PhysicalFileProvider(
Path.Combine(builder.Environment.ContentRootPath, "wwwroot/vue")),
RequestPath = "/2/vue"
});
Comment thread web/Program.cs Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants