Skip to content

Commit 6e67564

Browse files
committed
VPR-138 fix(cms): harden DownloadZip path handling and ZIP entries
- 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.
1 parent 5949e52 commit 6e67564

4 files changed

Lines changed: 501 additions & 33 deletions

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
using CmsData = Viper.Areas.CMS.Services.CmsFilePathSafety;
2+
3+
namespace Viper.test.CMS;
4+
5+
/// <summary>
6+
/// Security tests for the helpers that back <c>CMS.DownloadZip</c>.
7+
/// Covers the filename sanitizer used for the Content-Disposition header
8+
/// and the per-request temp-archive path builder (VPR-138).
9+
/// </summary>
10+
public sealed class DownloadZipSecurityTests
11+
{
12+
#region SanitizeDownloadName
13+
14+
[Theory]
15+
[InlineData(@"\..\..\evil.zip")]
16+
[InlineData("../../evil.zip")]
17+
[InlineData(@"C:\Windows\Temp\evil.zip")]
18+
[InlineData("../evil")]
19+
public void SanitizeDownloadName_StripsPathSeparatorsAndTraversal(string input)
20+
{
21+
var result = CmsData.SanitizeDownloadName(input);
22+
23+
Assert.DoesNotContain('\\', result);
24+
Assert.DoesNotContain('/', result);
25+
Assert.DoesNotContain("..", result);
26+
Assert.EndsWith(".zip", result, StringComparison.OrdinalIgnoreCase);
27+
}
28+
29+
[Theory]
30+
[InlineData(null)]
31+
[InlineData("")]
32+
[InlineData(" ")]
33+
[InlineData("!!!")]
34+
[InlineData("///")]
35+
[InlineData(@"\\\")]
36+
[InlineData(".")]
37+
[InlineData("..")]
38+
[InlineData("...")]
39+
[InlineData(". .")]
40+
public void SanitizeDownloadName_NullEmptyOrJunk_ReturnsDefault(string? input)
41+
{
42+
var result = CmsData.SanitizeDownloadName(input!);
43+
44+
Assert.Equal("FileDownload.zip", result);
45+
}
46+
47+
[Fact]
48+
public void SanitizeDownloadName_NameWithoutExtension_AppendsZip()
49+
{
50+
var result = CmsData.SanitizeDownloadName("export");
51+
52+
Assert.Equal("export.zip", result);
53+
}
54+
55+
[Fact]
56+
public void SanitizeDownloadName_NonZipExtension_ForcesZipSuffix()
57+
{
58+
// Documented choice: preserve the original name and append .zip so the
59+
// response MIME (application/zip) always matches the filename.
60+
var result = CmsData.SanitizeDownloadName("export.exe");
61+
62+
Assert.EndsWith(".zip", result, StringComparison.OrdinalIgnoreCase);
63+
Assert.Equal("export.exe.zip", result);
64+
}
65+
66+
[Fact]
67+
public void SanitizeDownloadName_AlreadyZip_IsPreserved()
68+
{
69+
var result = CmsData.SanitizeDownloadName("monthly-report.zip");
70+
71+
Assert.Equal("monthly-report.zip", result);
72+
}
73+
74+
[Fact]
75+
public void SanitizeDownloadName_CaseInsensitiveZipExtension_IsPreserved()
76+
{
77+
var result = CmsData.SanitizeDownloadName("REPORT.ZIP");
78+
79+
Assert.Equal("REPORT.ZIP", result);
80+
}
81+
82+
[Theory]
83+
[InlineData("CON")]
84+
[InlineData("PRN")]
85+
[InlineData("AUX")]
86+
[InlineData("NUL")]
87+
[InlineData("COM1")]
88+
[InlineData("LPT1")]
89+
[InlineData("con")]
90+
[InlineData("CON.txt")]
91+
public void SanitizeDownloadName_ReservedWindowsDeviceNames_ReturnsDefault(string input)
92+
{
93+
var result = CmsData.SanitizeDownloadName(input);
94+
95+
Assert.Equal("FileDownload.zip", result);
96+
}
97+
98+
[Fact]
99+
public void SanitizeDownloadName_AllowsSafeCharacters()
100+
{
101+
var result = CmsData.SanitizeDownloadName("My_File-01 v2.zip");
102+
103+
Assert.Equal("My_File-01 v2.zip", result);
104+
}
105+
106+
#endregion
107+
108+
#region Regression guard (VPR-138)
109+
110+
// Before the fix, DownloadZip built the on-disk temp archive path by
111+
// concatenating GetRootFileFolder() + ticks + user-supplied fileName.
112+
// A traversal payload in fileName (e.g. \..\..\evil.zip) escaped the
113+
// CMS root. The fix splits the flow: user input feeds only the
114+
// Content-Disposition name (SanitizeDownloadName); the on-disk path
115+
// is generated from a server-side GUID under a dedicated temp root
116+
// (BuildTempArchivePath).
117+
//
118+
// This test exercises both helpers as DownloadZip wires them and
119+
// asserts the traversal payload cannot influence the on-disk path,
120+
// even if a future refactor reintroduces the old concatenation.
121+
[Theory]
122+
[InlineData(@"\..\..\evil.zip")]
123+
[InlineData("../../evil.zip")]
124+
[InlineData(@"C:\Windows\System32\evil.zip")]
125+
[InlineData(@"..\..\..\..\Windows\evil.zip")]
126+
[InlineData("../../../../etc/passwd")]
127+
public void Regression_Vpr138_TraversalPayload_CannotEscapeTempRoot(string attackPayload)
128+
{
129+
var tempRoot = CreateIsolatedTempRoot();
130+
try
131+
{
132+
var responseName = CmsData.SanitizeDownloadName(attackPayload);
133+
var tempPath = CmsData.BuildTempArchivePath(tempRoot);
134+
135+
Assert.DoesNotContain('\\', responseName);
136+
Assert.DoesNotContain('/', responseName);
137+
Assert.DoesNotContain("..", responseName);
138+
Assert.EndsWith(".zip", responseName, StringComparison.OrdinalIgnoreCase);
139+
140+
var resolvedRoot = Path.GetFullPath(tempRoot);
141+
var resolvedPath = Path.GetFullPath(tempPath);
142+
Assert.StartsWith(
143+
resolvedRoot + Path.DirectorySeparatorChar,
144+
resolvedPath,
145+
StringComparison.OrdinalIgnoreCase);
146+
Assert.DoesNotContain("..", resolvedPath);
147+
}
148+
finally
149+
{
150+
Cleanup(tempRoot);
151+
}
152+
}
153+
154+
#endregion
155+
156+
#region SanitizeZipEntryName
157+
158+
[Theory]
159+
[InlineData(@"..\..\evil.txt", "evil.txt")]
160+
[InlineData("../../evil.txt", "evil.txt")]
161+
[InlineData(@"nested\folder\file.pdf", "file.pdf")]
162+
[InlineData("nested/folder/file.pdf", "file.pdf")]
163+
[InlineData("Annual Report 2024.pdf", "Annual Report 2024.pdf")]
164+
public void SanitizeZipEntryName_StripsPathComponents(string friendlyName, string expected)
165+
{
166+
var result = CmsData.SanitizeZipEntryName(friendlyName, fallback: "fallback.bin");
167+
168+
Assert.Equal(expected, result);
169+
Assert.DoesNotContain('\\', result);
170+
Assert.DoesNotContain('/', result);
171+
}
172+
173+
[Theory]
174+
[InlineData(null)]
175+
[InlineData("")]
176+
[InlineData(" ")]
177+
public void SanitizeZipEntryName_EmptyFriendlyName_FallsBackToFilePath(string? friendlyName)
178+
{
179+
var result = CmsData.SanitizeZipEntryName(friendlyName!, fallback: @"C:\storage\real-file.bin");
180+
181+
Assert.Equal("real-file.bin", result);
182+
}
183+
184+
[Theory]
185+
[InlineData("..")]
186+
[InlineData(".")]
187+
[InlineData("dir/..")]
188+
[InlineData(@"nested\.")]
189+
[InlineData(". .")]
190+
public void SanitizeZipEntryName_DotsOnly_FallsBackToFilePath(string friendlyName)
191+
{
192+
var result = CmsData.SanitizeZipEntryName(friendlyName, fallback: @"C:\storage\real-file.bin");
193+
194+
Assert.Equal("real-file.bin", result);
195+
}
196+
197+
#endregion
198+
199+
#region BuildTempArchivePath
200+
201+
[Fact]
202+
public void BuildTempArchivePath_ReturnsPathUnderTempRoot()
203+
{
204+
var tempRoot = CreateIsolatedTempRoot();
205+
try
206+
{
207+
var path = CmsData.BuildTempArchivePath(tempRoot);
208+
209+
var resolved = Path.GetFullPath(path);
210+
var normalizedRoot = Path.GetFullPath(tempRoot);
211+
212+
Assert.StartsWith(
213+
normalizedRoot + Path.DirectorySeparatorChar,
214+
resolved,
215+
StringComparison.OrdinalIgnoreCase);
216+
Assert.EndsWith(".zip", resolved, StringComparison.OrdinalIgnoreCase);
217+
}
218+
finally
219+
{
220+
Cleanup(tempRoot);
221+
}
222+
}
223+
224+
[Fact]
225+
public void BuildTempArchivePath_AcceptsRootWithTrailingSeparator()
226+
{
227+
var tempRoot = CreateIsolatedTempRoot();
228+
var withTrailing = tempRoot + Path.DirectorySeparatorChar;
229+
try
230+
{
231+
var path = CmsData.BuildTempArchivePath(withTrailing);
232+
233+
var resolved = Path.GetFullPath(path);
234+
var normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(withTrailing));
235+
236+
Assert.StartsWith(
237+
normalizedRoot + Path.DirectorySeparatorChar,
238+
resolved,
239+
StringComparison.OrdinalIgnoreCase);
240+
}
241+
finally
242+
{
243+
Cleanup(tempRoot);
244+
}
245+
}
246+
247+
[Fact]
248+
public void BuildTempArchivePath_GeneratesUniquePaths_OnSuccessiveCalls()
249+
{
250+
var tempRoot = CreateIsolatedTempRoot();
251+
try
252+
{
253+
var a = CmsData.BuildTempArchivePath(tempRoot);
254+
var b = CmsData.BuildTempArchivePath(tempRoot);
255+
256+
Assert.NotEqual(a, b);
257+
}
258+
finally
259+
{
260+
Cleanup(tempRoot);
261+
}
262+
}
263+
264+
[Theory]
265+
[InlineData("")]
266+
[InlineData(" ")]
267+
public void BuildTempArchivePath_RejectsEmptyRoot(string tempRoot)
268+
{
269+
Assert.Throws<ArgumentException>(() => CmsData.BuildTempArchivePath(tempRoot));
270+
}
271+
272+
[Fact]
273+
public void BuildTempArchivePath_RejectsNullRoot()
274+
{
275+
Assert.Throws<ArgumentException>(() => CmsData.BuildTempArchivePath(null!));
276+
}
277+
278+
#endregion
279+
280+
#region Helpers
281+
282+
private static string CreateIsolatedTempRoot()
283+
{
284+
var root = Path.Join(Path.GetTempPath(), "Viper-CMS-Test-" + Guid.NewGuid().ToString("N"));
285+
Directory.CreateDirectory(root);
286+
return root;
287+
}
288+
289+
private static void Cleanup(string root)
290+
{
291+
if (Directory.Exists(root))
292+
{
293+
Directory.Delete(root, recursive: true);
294+
}
295+
}
296+
297+
#endregion
298+
}

0 commit comments

Comments
 (0)