Skip to content

Commit 53ef05f

Browse files
authored
FileStorage File Name validation (#71)
* Updated filename validator wiht custom messages.
1 parent 97a8b8c commit 53ef05f

9 files changed

Lines changed: 381 additions & 15 deletions

File tree

src/DfE.CoreLibs.FileStorage/Interfaces/IFileStorageService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@ public interface IFileStorageService
1818
/// </summary>
1919
/// <param name="path">Relative path of the file within the storage provider. Must not be null or empty.</param>
2020
/// <param name="content">Stream containing the data to upload. Must be readable and not null.</param>
21+
/// <param name="originalFileName">Original Filename.</param>
2122
/// <param name="token">Optional cancellation token to cancel the operation.</param>
2223
/// <returns>A task that represents the asynchronous upload operation.</returns>
2324
/// <exception cref="ArgumentNullException">Thrown when <paramref name="path"/> or <paramref name="content"/> is null.</exception>
2425
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is empty or <paramref name="content"/> is not readable.</exception>
2526
/// <exception cref="FileStorageException">Thrown when the upload operation fails.</exception>
2627
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled via the cancellation token.</exception>
27-
Task UploadAsync(string path, Stream content, CancellationToken token = default);
28+
Task UploadAsync(string path, Stream content, string? originalFileName = null, CancellationToken token = default);
2829

2930
/// <summary>
3031
/// Retrieves a file from the specified <paramref name="path"/>.

src/DfE.CoreLibs.FileStorage/LocalFileStorageExample.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ This guide shows how to use the Local File Storage provider in the DfE.CoreLibs.
1414
"CreateDirectoryIfNotExists": true,
1515
"AllowOverwrite": true,
1616
"MaxFileSizeBytes": 104857600,
17-
"AllowedExtensions": ["jpg", "png", "pdf", "docx", "xlsx"]
17+
"AllowedExtensions": ["jpg", "png", "pdf", "docx", "xlsx"],
18+
"AllowedFileNamePattern": "^[a-zA-Z0-9_-]+$"
1819
}
1920
}
2021
}
@@ -203,12 +204,39 @@ public class DocumentService
203204
- Files without extensions are rejected when `AllowedExtensions` is configured
204205
- The validation checks the last extension in filenames with multiple dots (e.g., "file.backup.jpg" is validated as "jpg")
205206

207+
### Filename Pattern Examples
208+
209+
```json
210+
{
211+
"FileStorage": {
212+
"Provider": "Local",
213+
"Local": {
214+
"BaseDirectory": "C:\\FileStorage",
215+
"AllowedFileNamePattern": "^[a-zA-Z0-9_-]+$"
216+
}
217+
}
218+
}
219+
```
220+
221+
**Filename Pattern Options**:
222+
- `"^[a-zA-Z0-9_-]+$"` - Only letters, numbers, underscore, and hyphens (default recommended pattern)
223+
- `"^[A-Z]{2}[0-9]{3}$"` - 2 uppercase letters followed by 3 digits (e.g., "AB123")
224+
- `"^[a-z0-9]+$"` - Only lowercase letters and numbers
225+
- `null` or empty - No filename pattern validation (allows all characters)
226+
227+
**Filename Pattern Validation Notes**:
228+
- If `AllowedFileNamePattern` is null or empty, no filename pattern validation is applied
229+
- Pattern validation applies only to the filename part (without extension), not the directory path
230+
- Uses .NET regular expressions with compiled optimization for performance
231+
- Invalid regex patterns will throw `FileStorageConfigurationException` during service initialization
232+
206233
## Security Features
207234

208235
- **Directory Traversal Protection**: The service prevents access to files outside the base directory
209236
- **Path Normalization**: All paths are normalized to prevent security issues
210237
- **File Size Limits**: Configurable maximum file size to prevent abuse
211238
- **File Extension Validation**: Restrict uploads to specific file types for security
239+
- **Filename Pattern Validation**: Restrict filenames to match specific patterns, preventing malicious or problematic filenames
212240

213241
## Error Handling
214242

@@ -226,6 +254,14 @@ When file extension validation is enabled, the service will throw `FileStorageEx
226254
- **No extension**: "File extension is required. Allowed extensions: jpg, png, pdf"
227255
- **Invalid extension**: "File extension 'exe' is not allowed. Allowed extensions: jpg, png, pdf"
228256

257+
### Filename Pattern Validation Errors
258+
259+
When filename pattern validation is enabled, the service will throw `FileStorageException` with specific messages:
260+
261+
- **Invalid filename**: "Filename 'file with spaces' does not match the allowed pattern. Pattern: ^[a-zA-Z0-9_-]+$"
262+
- **Missing filename**: "Filename is required when filename pattern validation is enabled. Pattern: ^[a-zA-Z0-9_-]+$"
263+
- **Invalid pattern at startup**: "Invalid filename pattern: [unclosed" (throws `FileStorageConfigurationException`)
264+
229265
## Best Practices
230266

231267
1. **Use relative paths**: Always use relative paths within your storage structure

src/DfE.CoreLibs.FileStorage/Services/AzureFileStorageService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ internal AzureFileStorageService(IShareClientWrapper clientWrapper)
4747
}
4848

4949
/// <inheritdoc />
50-
public async Task UploadAsync(string path, Stream content, CancellationToken token = default)
50+
public async Task UploadAsync(string path, Stream content, string? originalFileName = null, CancellationToken token = default)
5151
{
5252
ArgumentException.ThrowIfNullOrWhiteSpace(path);
5353
ArgumentNullException.ThrowIfNull(content);

src/DfE.CoreLibs.FileStorage/Services/LocalFileStorageService.cs

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
using DfE.CoreLibs.FileStorage.Settings;
33
using DfE.CoreLibs.FileStorage.Exceptions;
44
using System.IO;
5+
using System.Text.RegularExpressions;
56
using FileNotFoundException = DfE.CoreLibs.FileStorage.Exceptions.FileNotFoundException;
7+
using System.Xml.Linq;
68

79
namespace DfE.CoreLibs.FileStorage.Services;
810

@@ -16,6 +18,9 @@ public class LocalFileStorageService : IFileStorageService
1618
private readonly bool _allowOverwrite;
1719
private readonly long _maxFileSizeBytes;
1820
private readonly string[] _allowedExtensions;
21+
private readonly Regex? _allowedFileNamePattern;
22+
private readonly string _friendlyAllowedFileNamePattern;
23+
private readonly string _friendlyAllowedFileExtensionsPattern;
1924

2025
/// <summary>
2126
/// Creates a new instance of the service using the provided configuration <paramref name="options"/>.
@@ -28,10 +33,25 @@ public LocalFileStorageService(FileStorageOptions options)
2833
ArgumentNullException.ThrowIfNull(options);
2934

3035
var localOptions = options.Local;
36+
_friendlyAllowedFileNamePattern = localOptions.AllowedFileNamePatternFriendlyList;
37+
_friendlyAllowedFileExtensionsPattern = localOptions.AllowedExtensionsFriendlyList;
3138
_createDirectoryIfNotExists = localOptions.CreateDirectoryIfNotExists;
3239
_allowOverwrite = localOptions.AllowOverwrite;
3340
_maxFileSizeBytes = localOptions.MaxFileSizeBytes;
3441
_allowedExtensions = localOptions.AllowedExtensions ?? Array.Empty<string>();
42+
43+
// Initialize filename pattern if specified
44+
if (!string.IsNullOrWhiteSpace(localOptions.AllowedFileNamePattern))
45+
{
46+
try
47+
{
48+
_allowedFileNamePattern = new Regex(localOptions.AllowedFileNamePattern, RegexOptions.Compiled);
49+
}
50+
catch (ArgumentException ex)
51+
{
52+
throw new FileStorageConfigurationException($"Invalid filename pattern: {localOptions.AllowedFileNamePattern}", ex);
53+
}
54+
}
3555

3656
// Determine base directory
3757
if (string.IsNullOrWhiteSpace(localOptions.BaseDirectory))
@@ -66,13 +86,35 @@ public LocalFileStorageService(FileStorageOptions options)
6686
/// <summary>
6787
/// Internal constructor used for testing with custom settings.
6888
/// </summary>
69-
internal LocalFileStorageService(string baseDirectory, bool createDirectoryIfNotExists = true, bool allowOverwrite = true, long maxFileSizeBytes = 100 * 1024 * 1024, string[] allowedExtensions = null)
89+
internal LocalFileStorageService(string baseDirectory,
90+
bool createDirectoryIfNotExists = true,
91+
bool allowOverwrite = true,
92+
long maxFileSizeBytes = 100 * 1024 * 1024,
93+
string[] allowedExtensions = null,
94+
string allowedFileNamePattern = null,
95+
string? friendlyAllowedFileNamePattern = "a-z A-Z 0-9 _ - no-space",
96+
string? friendlyAllowedFileExtensionsPattern = "\"jpg\", \"png\", \"pdf\", \"docx\"")
7097
{
7198
_baseDirectory = Path.GetFullPath(baseDirectory);
99+
_friendlyAllowedFileNamePattern = friendlyAllowedFileNamePattern;
100+
_friendlyAllowedFileExtensionsPattern = friendlyAllowedFileExtensionsPattern;
72101
_createDirectoryIfNotExists = createDirectoryIfNotExists;
73102
_allowOverwrite = allowOverwrite;
74103
_maxFileSizeBytes = maxFileSizeBytes;
75104
_allowedExtensions = allowedExtensions ?? Array.Empty<string>();
105+
106+
// Initialize filename pattern if specified
107+
if (!string.IsNullOrWhiteSpace(allowedFileNamePattern))
108+
{
109+
try
110+
{
111+
_allowedFileNamePattern = new Regex(allowedFileNamePattern, RegexOptions.Compiled);
112+
}
113+
catch (ArgumentException ex)
114+
{
115+
throw new FileStorageConfigurationException($"Invalid filename pattern: {allowedFileNamePattern}", ex);
116+
}
117+
}
76118

77119
if (_createDirectoryIfNotExists && !Directory.Exists(_baseDirectory))
78120
{
@@ -81,7 +123,7 @@ internal LocalFileStorageService(string baseDirectory, bool createDirectoryIfNot
81123
}
82124

83125
/// <inheritdoc />
84-
public async Task UploadAsync(string path, Stream content, CancellationToken token = default)
126+
public async Task UploadAsync(string path, Stream content, string? originalFileName = null, CancellationToken token = default)
85127
{
86128
ArgumentException.ThrowIfNullOrWhiteSpace(path);
87129
ArgumentNullException.ThrowIfNull(content);
@@ -99,13 +141,28 @@ public async Task UploadAsync(string path, Stream content, CancellationToken tok
99141
var fileExtension = Path.GetExtension(path);
100142
if (string.IsNullOrEmpty(fileExtension))
101143
{
102-
throw new FileStorageException($"File extension is required. Allowed extensions: {string.Join(", ", _allowedExtensions)}");
144+
throw new FileStorageException($"File extension is required. Allowed extensions: {_friendlyAllowedFileExtensionsPattern}");
103145
}
104146

105147
var extensionWithoutDot = fileExtension.TrimStart('.');
106148
if (!_allowedExtensions.Contains(extensionWithoutDot, StringComparer.OrdinalIgnoreCase))
107149
{
108-
throw new FileStorageException($"File extension '{extensionWithoutDot}' is not allowed. Allowed extensions: {string.Join(", ", _allowedExtensions)}");
150+
throw new FileStorageException($"File extension '{extensionWithoutDot}' is not allowed. Allowed extensions: {_friendlyAllowedFileExtensionsPattern}");
151+
}
152+
}
153+
154+
// Validate filename pattern if configured
155+
if (_allowedFileNamePattern != null && originalFileName != null)
156+
{
157+
var fileName = Path.GetFileNameWithoutExtension(originalFileName);
158+
if (string.IsNullOrEmpty(fileName))
159+
{
160+
throw new FileStorageException($"Filename is required when filename pattern validation is enabled. Pattern: {_friendlyAllowedFileNamePattern}");
161+
}
162+
163+
if (!_allowedFileNamePattern.IsMatch(fileName))
164+
{
165+
throw new FileStorageException($"Filename '{fileName}' does not match the allowed pattern. Pattern: {_friendlyAllowedFileNamePattern}");
109166
}
110167
}
111168

src/DfE.CoreLibs.FileStorage/Settings/LocalFileStorageOptions.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,24 @@ public class LocalFileStorageOptions
3333
/// Example: ["jpg", "png", "pdf", "docx"]
3434
/// </summary>
3535
public string[] AllowedExtensions { get; set; } = Array.Empty<string>();
36+
37+
/// <summary>
38+
/// Friendly human-readable list of allowed file extensions.
39+
/// Example: ["jpg", "png", "pdf", "docx"]
40+
/// </summary>
41+
public string AllowedExtensionsFriendlyList { get; set; } = "\"jpg\", \"png\", \"pdf\", \"docx\"";
42+
43+
/// <summary>
44+
/// Regular expression pattern for allowed file names.
45+
/// If null or empty, no filename pattern validation is applied.
46+
/// Default pattern allows letters, numbers, underscores, and hyphens.
47+
/// Example: "^[a-zA-Z0-9_-]+$"
48+
/// </summary>
49+
public string? AllowedFileNamePattern { get; set; } = null;
50+
51+
/// <summary>
52+
/// Friendly and human-readable list of allowed file name characters.
53+
/// Example: a-zA-Z0-9_-
54+
/// </summary>
55+
public string AllowedFileNamePatternFriendlyList { get; set; } = "a-z A-Z 0-9 _ - no-space";
3656
}

src/Tests/DfE.CoreLibs.FileStorage.Tests/LocalFileStorageIntegrationTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,10 @@ public async Task UploadWithCustomSettings_ShouldRespectConfiguration()
252252
["FileStorage:Local:MaxFileSizeBytes"] = "1024", // 1KB limit
253253
["FileStorage:Local:AllowedExtensions:0"] = "jpg",
254254
["FileStorage:Local:AllowedExtensions:1"] = "png",
255-
["FileStorage:Local:AllowedExtensions:2"] = "pdf"
255+
["FileStorage:Local:AllowedExtensions:2"] = "pdf",
256+
["FileStorage:Local:AllowedExtensionsFriendlyList"] = "jpg, png, pdf",
257+
["FileStorage:Local:AllowedFileNamePatternFriendlyList"] = "a-z A-Z 0-9 _ - no-space"
258+
256259
});
257260

258261
services.AddFileStorage(configuration);

src/Tests/DfE.CoreLibs.FileStorage.Tests/Services/AzureFileStorageServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ public async Task AllMethods_WithCancellationToken_ShouldPassTokenToClient()
335335
_mockFileClient.DownloadAsync(cancellationToken).ThrowsAsync(new OperationCanceledException());
336336

337337
// Act & Assert - All methods should pass the cancellation token and throw when cancelled
338-
await Assert.ThrowsAsync<OperationCanceledException>(() => _service.UploadAsync(path, content, cancellationToken));
338+
await Assert.ThrowsAsync<OperationCanceledException>(() => _service.UploadAsync(path, content, originalFileName: null, cancellationToken));
339339
await Assert.ThrowsAsync<OperationCanceledException>(() => _service.DownloadAsync(path, cancellationToken));
340340
await Assert.ThrowsAsync<OperationCanceledException>(() => _service.DeleteAsync(path, cancellationToken));
341341
await Assert.ThrowsAsync<OperationCanceledException>(() => _service.ExistsAsync(path, cancellationToken));

src/Tests/DfE.CoreLibs.FileStorage.Tests/Services/IFileStorageServiceTests.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@ public void IFileStorageService_UploadAsync_ShouldHaveCorrectSignature()
5656
// Assert
5757
Assert.NotNull(method);
5858
Assert.Equal(typeof(Task), method.ReturnType);
59-
Assert.Equal(3, method.GetParameters().Length);
59+
Assert.Equal(4, method.GetParameters().Length);
6060

6161
var parameters = method.GetParameters();
6262
Assert.Equal(typeof(string), parameters[0].ParameterType);
6363
Assert.Equal(typeof(Stream), parameters[1].ParameterType);
64-
Assert.Equal(typeof(CancellationToken), parameters[2].ParameterType);
64+
Assert.Equal(typeof(string), parameters[2].ParameterType);
65+
Assert.Equal(typeof(CancellationToken), parameters[3].ParameterType);
66+
6567
Assert.True(parameters[2].HasDefaultValue);
6668
}
6769

@@ -140,7 +142,7 @@ public void IFileStorageService_ShouldSupportCancellation()
140142
var cancellationToken = new CancellationToken(true); // Cancelled token
141143

142144
// Act & Assert - All methods should support cancellation
143-
Assert.ThrowsAsync<OperationCanceledException>(() => service.UploadAsync("test.txt", new MemoryStream(), cancellationToken));
145+
Assert.ThrowsAsync<OperationCanceledException>(() => service.UploadAsync("test.txt", new MemoryStream(), originalFileName: null, cancellationToken));
144146
Assert.ThrowsAsync<OperationCanceledException>(() => service.DownloadAsync("test.txt", cancellationToken));
145147
Assert.ThrowsAsync<OperationCanceledException>(() => service.DeleteAsync("test.txt", cancellationToken));
146148
Assert.ThrowsAsync<OperationCanceledException>(() => service.ExistsAsync("test.txt", cancellationToken));

0 commit comments

Comments
 (0)