Skip to content

Commit bc893aa

Browse files
committed
fix: Add path traversal protection and transactional batch generation
Document downloads now validate the file path stays within the storage dir. Batch generation uses a transaction and cleans up files on failure. Added background service to clean up orphaned PDF files daily. Template ownership is now checked during document generation. PDF service has a 30s timeout and better error handling. Pagination clamped to 1-100.
1 parent d98353f commit bc893aa

File tree

12 files changed

+295
-63
lines changed

12 files changed

+295
-63
lines changed

DocumentGenerator.API/Controllers/DocumentsController.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ public async Task<ActionResult<PaginatedResult<DocumentDto>>> GetAll(
4747
[FromQuery] int page = 1,
4848
[FromQuery] int pageSize = 20)
4949
{
50+
// Enforce pagination bounds
51+
page = Math.Max(1, page);
52+
pageSize = Math.Clamp(pageSize, 1, 100);
53+
5054
var userId = this.GetUserId();
5155
return Ok(await _documentService.GetAllAsync(userId, page, pageSize));
5256
}

DocumentGenerator.API/Controllers/TemplatesController.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ public async Task<ActionResult<PaginatedResult<TemplateDto>>> GetAll(
2525
[FromQuery] int page = 1,
2626
[FromQuery] int pageSize = 20)
2727
{
28+
// Enforce pagination bounds
29+
page = Math.Max(1, page);
30+
pageSize = Math.Clamp(pageSize, 1, 100);
31+
2832
var userId = this.GetUserId();
2933
return Ok(await _templateService.GetAllAsync(userId, page, pageSize));
3034
}

DocumentGenerator.API/Extensions/ServiceCollectionExtensions.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
2424
var jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>();
2525
services.Configure<JwtSettings>(configuration.GetSection("JwtSettings"));
2626

27+
// Storage Settings
28+
services.Configure<StorageSettings>(configuration.GetSection("Storage"));
29+
2730
// Authentication
2831
services.AddAuthentication(options =>
2932
{
@@ -40,7 +43,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
4043
ValidateIssuerSigningKey = true,
4144
ValidIssuer = jwtSettings?.Issuer,
4245
ValidAudience = jwtSettings?.Audience,
43-
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings?.Secret ?? throw new InvalidOperationException("JWT Secret is missing")))
46+
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings?.Secret ?? throw new InvalidOperationException("JWT Secret is missing"))),
47+
ClockSkew = TimeSpan.FromMinutes(1)
4448
};
4549
});
4650

@@ -54,6 +58,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
5458
services.AddScoped<ITemplateService, TemplateService>();
5559
services.AddScoped<IDocumentService, DocumentService>();
5660
services.AddSingleton<IPdfService, PdfService>();
61+
services.AddHostedService<OrphanCleanupService>();
5762

5863
// AutoMapper
5964
services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());

DocumentGenerator.API/appsettings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
"ExpiryMinutes": 60,
1616
"RefreshTokenExpiryDays": 7
1717
},
18+
"Storage": {
19+
"DocumentsPath": "GeneratedDocuments"
20+
},
1821
"AllowedOrigins": [
1922
"http://localhost:5173"
2023
]

DocumentGenerator.Core/DTOs/DocumentDtos.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public class BatchGenerationRequestDto
3131

3232
[Required]
3333
[MinLength(1, ErrorMessage = "At least one data item is required")]
34+
[MaxLength(100, ErrorMessage = "Batch size cannot exceed 100 items")]
3435
public List<object> DataItems { get; set; } = new();
3536
}
3637

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace DocumentGenerator.Core.Settings
2+
{
3+
public class StorageSettings
4+
{
5+
public string DocumentsPath { get; set; } = "GeneratedDocuments";
6+
}
7+
}

DocumentGenerator.Core/Validators/TemplateValidators.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,18 @@ public CreateTemplateDtoValidator()
1313

1414
RuleFor(x => x.Content)
1515
.NotEmpty().WithMessage("Template content is required")
16+
.MaximumLength(1_000_000).WithMessage("Template content exceeds maximum size (1MB)")
1617
.Must(BeValidHandlebars).WithMessage("Invalid Handlebars syntax");
1718
}
1819

19-
private bool BeValidHandlebars(string content)
20+
private static bool BeValidHandlebars(string content)
2021
{
2122
try
2223
{
2324
Handlebars.Compile(content);
2425
return true;
2526
}
26-
catch
27+
catch (HandlebarsException)
2728
{
2829
return false;
2930
}
@@ -35,19 +36,23 @@ public class UpdateTemplateDtoValidator : AbstractValidator<UpdateTemplateDto>
3536
public UpdateTemplateDtoValidator()
3637
{
3738
RuleFor(x => x.Content)
38-
.Must(BeValidHandlebars)
39+
.MaximumLength(1_000_000).WithMessage("Template content exceeds maximum size (1MB)")
40+
.When(x => !string.IsNullOrEmpty(x.Content));
41+
42+
RuleFor(x => x.Content)
43+
.Must(BeValidHandlebars!)
3944
.When(x => !string.IsNullOrEmpty(x.Content))
4045
.WithMessage("Invalid Handlebars syntax");
4146
}
4247

43-
private bool BeValidHandlebars(string content)
48+
private static bool BeValidHandlebars(string content)
4449
{
4550
try
4651
{
4752
Handlebars.Compile(content);
4853
return true;
4954
}
50-
catch
55+
catch (HandlebarsException)
5156
{
5257
return false;
5358
}

DocumentGenerator.Infrastructure/DocumentGenerator.Infrastructure.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
1111
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
1212
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
13+
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
1314
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
1415
<PackageReference Include="PuppeteerSharp" Version="20.2.4" />
1516
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />

DocumentGenerator.Infrastructure/Services/DocumentService.cs

Lines changed: 92 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
using DocumentGenerator.Core.DTOs;
33
using DocumentGenerator.Core.Entities;
44
using DocumentGenerator.Core.Interfaces;
5+
using DocumentGenerator.Core.Settings;
56
using DocumentGenerator.Infrastructure.Data;
67
using HandlebarsDotNet;
78
using Microsoft.EntityFrameworkCore;
89
using Microsoft.Extensions.Logging;
10+
using Microsoft.Extensions.Options;
911
using System.Text.Json;
1012

1113
namespace DocumentGenerator.Infrastructure.Services
@@ -22,13 +24,19 @@ public DocumentService(
2224
ApplicationDbContext context,
2325
IPdfService pdfService,
2426
IMapper mapper,
25-
ILogger<DocumentService> logger)
27+
ILogger<DocumentService> logger,
28+
IOptions<StorageSettings> storageSettings)
2629
{
2730
_context = context;
2831
_pdfService = pdfService;
2932
_mapper = mapper;
3033
_logger = logger;
31-
_storagePath = Path.Combine(Directory.GetCurrentDirectory(), "GeneratedDocuments");
34+
35+
var documentsPath = storageSettings.Value.DocumentsPath;
36+
_storagePath = Path.IsPathRooted(documentsPath)
37+
? documentsPath
38+
: Path.Combine(Directory.GetCurrentDirectory(), documentsPath);
39+
3240
if (!Directory.Exists(_storagePath))
3341
{
3442
Directory.CreateDirectory(_storagePath);
@@ -37,10 +45,11 @@ public DocumentService(
3745

3846
public async Task<DocumentDto> GenerateDocumentAsync(GenerationRequestDto request, Guid userId)
3947
{
40-
var template = await _context.Templates.FindAsync(request.TemplateId);
48+
var template = await _context.Templates
49+
.FirstOrDefaultAsync(t => t.Id == request.TemplateId && t.CreatedByUserId == userId);
4150
if (template == null)
4251
{
43-
_logger.LogWarning("Template {TemplateId} not found for document generation", request.TemplateId);
52+
_logger.LogWarning("Template {TemplateId} not found or not owned by user {UserId}", request.TemplateId, userId);
4453
throw new KeyNotFoundException("Template not found");
4554
}
4655

@@ -87,29 +96,41 @@ public async Task<BatchGenerationResultDto> GenerateDocumentBatchAsync(BatchGene
8796
TotalRequested = request.DataItems.Count
8897
};
8998

90-
var template = await _context.Templates.FindAsync(request.TemplateId);
99+
var template = await _context.Templates
100+
.FirstOrDefaultAsync(t => t.Id == request.TemplateId && t.CreatedByUserId == userId);
91101
if (template == null)
92102
{
93-
_logger.LogWarning("Template {TemplateId} not found for batch document generation", request.TemplateId);
103+
_logger.LogWarning("Template {TemplateId} not found or not owned by user {UserId} for batch generation", request.TemplateId, userId);
94104
throw new KeyNotFoundException("Template not found");
95105
}
96106

97107
_logger.LogInformation("Starting batch generation of {Count} documents from template {TemplateId} for user {UserId}",
98108
request.DataItems.Count, request.TemplateId, userId);
99109

100110
var compiledTemplate = Handlebars.Compile(template.Content);
111+
var generatedFiles = new List<string>();
101112

102-
for (int i = 0; i < request.DataItems.Count; i++)
113+
await using var transaction = await _context.Database.BeginTransactionAsync();
114+
try
103115
{
104-
try
116+
for (int i = 0; i < request.DataItems.Count; i++)
105117
{
106118
var data = request.DataItems[i];
107119
var htmlContent = compiledTemplate(data);
108120
var pdfBytes = await _pdfService.GeneratePdfAsync(htmlContent);
109121

110122
var fileName = $"doc-{Guid.NewGuid()}.pdf";
111123
var filePath = Path.Combine(_storagePath, fileName);
112-
await File.WriteAllBytesAsync(filePath, pdfBytes);
124+
125+
try
126+
{
127+
await File.WriteAllBytesAsync(filePath, pdfBytes);
128+
generatedFiles.Add(filePath);
129+
}
130+
catch (IOException ex)
131+
{
132+
throw new InvalidOperationException($"Failed to save document file at index {i}", ex);
133+
}
113134

114135
var document = new Document
115136
{
@@ -130,24 +151,27 @@ public async Task<BatchGenerationResultDto> GenerateDocumentBatchAsync(BatchGene
130151
result.Documents.Add(dto);
131152
result.SuccessCount++;
132153
}
133-
catch (Exception ex)
134-
{
135-
_logger.LogWarning(ex, "Failed to generate document at index {Index} in batch", i);
136-
result.Errors.Add(new BatchGenerationError
137-
{
138-
Index = i,
139-
Message = ex.Message
140-
});
141-
result.FailureCount++;
142-
}
154+
155+
await _context.SaveChangesAsync();
156+
await transaction.CommitAsync();
157+
158+
_logger.LogInformation("Batch generation completed successfully: {Count} documents generated", result.SuccessCount);
159+
return result;
143160
}
161+
catch (Exception ex)
162+
{
163+
_logger.LogError(ex, "Batch generation failed, rolling back {Count} files", generatedFiles.Count);
144164

145-
await _context.SaveChangesAsync();
165+
await transaction.RollbackAsync();
146166

147-
_logger.LogInformation("Batch generation completed: {Success} succeeded, {Failed} failed",
148-
result.SuccessCount, result.FailureCount);
167+
// Clean up any files that were written
168+
foreach (var file in generatedFiles)
169+
{
170+
DeleteFileIfExists(file);
171+
}
149172

150-
return result;
173+
throw;
174+
}
151175
}
152176

153177
public async Task<DocumentDto?> GetByIdAsync(Guid id, Guid userId)
@@ -195,11 +219,26 @@ public async Task<PaginatedResult<DocumentDto>> GetAllAsync(Guid userId, int pag
195219
var document = await _context.Documents.FindAsync(id);
196220
if (document == null || document.UserId != userId) return null;
197221

222+
// Validate path is within storage directory (path traversal protection)
223+
if (!IsPathSafe(document.StoragePath))
224+
{
225+
_logger.LogWarning("Attempted path traversal detected for document {DocumentId}", id);
226+
throw new UnauthorizedAccessException("Invalid document path");
227+
}
228+
198229
if (!File.Exists(document.StoragePath))
199230
throw new FileNotFoundException("Document file not found on server");
200231

201-
var bytes = await File.ReadAllBytesAsync(document.StoragePath);
202-
return (bytes, Path.GetFileName(document.StoragePath));
232+
try
233+
{
234+
var bytes = await File.ReadAllBytesAsync(document.StoragePath);
235+
return (bytes, Path.GetFileName(document.StoragePath));
236+
}
237+
catch (IOException ex)
238+
{
239+
_logger.LogError(ex, "Failed to read document file {DocumentId}", id);
240+
throw new InvalidOperationException("Failed to read document file", ex);
241+
}
203242
}
204243

205244
public async Task<bool> DeleteAsync(Guid id, Guid userId)
@@ -211,11 +250,15 @@ public async Task<bool> DeleteAsync(Guid id, Guid userId)
211250
return false;
212251
}
213252

214-
DeleteFileIfExists(document.StoragePath);
253+
var storagePath = document.StoragePath;
215254

255+
// Delete from database first to prevent race conditions
216256
_context.Documents.Remove(document);
217257
await _context.SaveChangesAsync();
218258

259+
// Then delete the file (after DB commit)
260+
DeleteFileIfExists(storagePath);
261+
219262
_logger.LogInformation("Document {DocumentId} deleted by user {UserId}", id, userId);
220263
return true;
221264
}
@@ -226,14 +269,22 @@ public async Task DeleteByTemplateIdAsync(Guid templateId)
226269
.Where(d => d.TemplateId == templateId)
227270
.ToListAsync();
228271

272+
// Collect file paths before removing from context
273+
var filePaths = documents.Select(d => d.StoragePath).ToList();
274+
229275
foreach (var doc in documents)
230276
{
231-
DeleteFileIfExists(doc.StoragePath);
232277
_context.Documents.Remove(doc);
233278
}
234279

235280
await _context.SaveChangesAsync();
236281

282+
// Delete files after DB commit
283+
foreach (var path in filePaths)
284+
{
285+
DeleteFileIfExists(path);
286+
}
287+
237288
_logger.LogInformation("Deleted {Count} documents for template {TemplateId}", documents.Count, templateId);
238289
}
239290

@@ -251,5 +302,19 @@ private void DeleteFileIfExists(string path)
251302
}
252303
}
253304
}
305+
306+
private bool IsPathSafe(string filePath)
307+
{
308+
try
309+
{
310+
var fullPath = Path.GetFullPath(filePath);
311+
var storagePath = Path.GetFullPath(_storagePath);
312+
return fullPath.StartsWith(storagePath, StringComparison.OrdinalIgnoreCase);
313+
}
314+
catch
315+
{
316+
return false;
317+
}
318+
}
254319
}
255320
}

0 commit comments

Comments
 (0)