diff --git a/Directory.Packages.props b/Directory.Packages.props index c9be3b9..f7f6319 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index b423dfb..47a463f 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -29,6 +29,7 @@ + diff --git a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs index a08c967..ee86725 100644 --- a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs +++ b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs @@ -2,6 +2,7 @@ using Ganss.Xss; using Microsoft.Extensions.Options; using NexusReader.Application.Abstractions.Services; using NexusReader.Infrastructure.Configuration; +using Markdig; namespace NexusReader.Infrastructure.Services; @@ -11,10 +12,12 @@ namespace NexusReader.Infrastructure.Services; public class HtmlSanitizerService : ISanitizerService { private readonly HtmlSanitizer _sanitizer; + private readonly MarkdownPipeline _pipeline; public HtmlSanitizerService(IOptions? options = null) { _sanitizer = new HtmlSanitizer(); + _pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); if (options?.Value != null) { @@ -65,6 +68,9 @@ public class HtmlSanitizerService : ISanitizerService return input; } - return _sanitizer.Sanitize(input); + // Translate raw Markdown input to HTML strictly before running HtmlSanitizer + var html = Markdown.ToHtml(input, _pipeline); + + return _sanitizer.Sanitize(html).Trim(); } } diff --git a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs index 6b61ec5..d8803f8 100644 --- a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs +++ b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs @@ -24,7 +24,7 @@ public class LocalStorageService : IStorageService public async Task UploadFileAsync(Stream fileStream, string fileName, string contentType) { - var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads", "media"); + var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads"); var resolvedMediaFolder = Path.GetFullPath(mediaFolder); var folderWithSeparator = resolvedMediaFolder.EndsWith(Path.DirectorySeparatorChar) ? resolvedMediaFolder @@ -53,6 +53,6 @@ public class LocalStorageService : IStorageService } // Return the public web-relative URL - return $"/uploads/media/{uniqueFileName}"; + return $"/uploads/{uniqueFileName}"; } } diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor index 01ce06b..0529d26 100644 --- a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor +++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor @@ -82,12 +82,18 @@ } [JSInvokable] - public async Task UploadImageFromJs(string filename, string contentType, byte[] fileBytes) + public async Task UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef) { try { + const long maxFileSize = 5 * 1024 * 1024; // 5MB limit + using var stream = await streamRef.OpenReadStreamAsync(maxFileSize, _cts.Token); + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream, _cts.Token); + var fileBytes = memoryStream.ToArray(); + using var content = new MultipartFormDataContent(); - var fileContent = new ByteArrayContent(fileBytes); + using var fileContent = new ByteArrayContent(fileBytes); fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType); content.Add(fileContent, "file", filename); @@ -96,19 +102,19 @@ { var result = await response.Content.ReadFromJsonAsync( NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto, _cts.Token); - return result?.Url ?? string.Empty; + return result?.Url ?? "https://placehold.co/600x400?text=Upload+Failed"; } else { - var errorMsg = await response.Content.ReadAsStringAsync(); + var errorMsg = await response.Content.ReadAsStringAsync(_cts.Token); Console.WriteLine($"[MarkdownEditor] Image upload failed: {response.StatusCode} - {errorMsg}"); - return string.Empty; + return "https://placehold.co/600x400?text=Upload+Failed"; } } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Exception during image upload: {ex.Message}"); - return string.Empty; + return "https://placehold.co/600x400?text=Upload+Failed"; } } diff --git a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js index 2d89599..fcb7c14 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js @@ -50,12 +50,11 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) { [Crepe.Feature.ImageBlock]: { onUpload: async (file) => { try { - const arrayBuffer = await file.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - const url = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, uint8Array); + const streamRef = DotNet.createJSStreamReference(file); + const url = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, streamRef); return url; } catch (err) { - console.error("[Milkdown] Failed to upload image from JS:", err); + console.error("[Milkdown] Failed to upload image from JS (onUpload):", err); throw err; } } @@ -63,6 +62,36 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) { } }); + // Configure custom uploader using the uploadConfig context slice + crepe.editor.config((ctx) => { + try { + ctx.update('uploadConfig', (prev) => ({ + ...prev, + uploader: async (files, schema) => { + const nodes = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.type.startsWith('image/')) { + try { + const streamRef = DotNet.createJSStreamReference(file); + const uploadedUrl = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, streamRef); + if (uploadedUrl) { + const node = schema.nodes.image.create({ src: uploadedUrl, alt: file.name }); + nodes.push(node); + } + } catch (err) { + console.error("[Milkdown] Failed to upload image in custom uploader:", err); + } + } + } + return nodes; + } + })); + } catch (err) { + console.error("[Milkdown] Failed to configure uploadConfig uploader:", err); + } + }); + // Store the editor instance in the map editorCache.set(elementId, crepe); diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 24e54e5..cf7f6cb 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -802,15 +802,15 @@ app.MapPost("/api/media/upload", async ( fileBytes = memoryStream.ToArray(); } - // Validate signature - if (!ValidateImageSignature(fileBytes, file.ContentType)) + // Validate signature without trusting browser content-type, enforcing extension matching + if (!ImageValidator.ValidateImageSignature(fileBytes, file.FileName, out var detectedContentType)) { - logger.LogWarning("File signature validation failed for file {FileName} with content type {ContentType}.", file.FileName, file.ContentType); - return Results.BadRequest("Invalid image signature. Legitimate JPEG, PNG, or WEBP images only."); + logger.LogWarning("File signature validation failed for file {FileName} with browser content type {ContentType}.", file.FileName, file.ContentType); + return Results.BadRequest("Invalid file signature or extension mismatch. Legitimate JPEG, PNG, WEBP, or GIF images only."); } - // Save using IStorageService - var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, file.ContentType); + // Save using IStorageService with the verified content type + var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, detectedContentType); return Results.Ok(new NexusReader.Application.DTOs.Media.UploadResultDto(fileUrl)); }).DisableAntiforgery(); @@ -878,32 +878,56 @@ async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider se } } -static bool ValidateImageSignature(byte[] bytes, string contentType) +public static class ImageValidator { - if (bytes.Length < 4) return false; - - // Check PNG signature: 89 50 4E 47 - if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) + public static bool ValidateImageSignature(byte[] bytes, string fileName, out string detectedContentType) { - return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase); - } + detectedContentType = string.Empty; + if (bytes.Length < 4) return false; - // Check JPEG signature: FF D8 FF - if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) - { - return contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) || - contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase); - } + // Check PNG signature: 89 50 4E 47 + if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) + { + detectedContentType = "image/png"; + } + // Check JPEG signature: FF D8 FF + else if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + { + detectedContentType = "image/jpeg"; + } + // Check WEBP signature: RIFF ... WEBP + else if (bytes.Length >= 12 && + bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && // RIFF + bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) // WEBP + { + detectedContentType = "image/webp"; + } + // Check GIF signature: GIF87a or GIF89a + else if (bytes.Length >= 6 && + bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 && + (bytes[4] == 0x37 || bytes[4] == 0x39) && bytes[5] == 0x61) + { + detectedContentType = "image/gif"; + } - // Check WEBP signature: RIFF ... WEBP - if (bytes.Length >= 12 && - bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && // RIFF - bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) // WEBP - { - return contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase); - } + if (string.IsNullOrEmpty(detectedContentType)) + { + return false; + } - return false; + // Verify that the file extension matches the detected content type (extension-spoofing guard) + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + var isMatch = detectedContentType switch + { + "image/png" => ext == ".png", + "image/jpeg" => ext == ".jpg" || ext == ".jpeg", + "image/webp" => ext == ".webp", + "image/gif" => ext == ".gif", + _ => false + }; + + return isMatch; + } } public record KnowledgeRequest(string Text, Guid? EbookId = null); diff --git a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj index 9ed3835..7b764a7 100644 --- a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj +++ b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj @@ -17,5 +17,6 @@ + diff --git a/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs b/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs index 013b78c..6f69ef4 100644 --- a/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs +++ b/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs @@ -51,4 +51,20 @@ public class HtmlSanitizerServiceTests result.Should().NotContain("alert"); result.Should().Contain(""); } + + [Fact] + public void Sanitize_WithMarkdownCodeBlockContainingAngleBrackets_DoesNotStripAngleBrackets() + { + // Arrange + var service = new HtmlSanitizerService(); + var input = "Here is some code:\n\n```csharp\nif (x < y && y > z) { Console.WriteLine(\"test\"); }\n```"; + + // Act + var result = service.Sanitize(input); + + // Assert + result.Should().Contain("<"); + result.Should().Contain(">"); + result.Should().NotContain("