diff --git a/README.md b/README.md
index 2c89b004..a6cd2ff1 100644
--- a/README.md
+++ b/README.md
@@ -78,11 +78,24 @@ Analysim requires two databases to operate: one SQL database (PostgreSQL) for re
"AdminUsers": [
"ADMIN",
"XXX"
- ]
+ ],
+ "FileValidation": {
+ "MaxProfileImageSize": 5242880,
+ "MaxProjectFileSize": 104857600,
+ "MaxNotebookFileSize": 52428800,
+ "AllowedImageExtensions": [".jpg", ".jpeg", ".png", ".gif", ".webp"],
+ "AllowedProjectFileExtensions": [".csv", ".json", ".txt", ".xlsx", ".xls", ".pdf", ".xml", ".tsv", ".dat"],
+ "AllowedNotebookExtensions": [".ipynb"],
+ "BlockedExtensions": [".exe", ".bat", ".cmd", ".sh", ".ps1", ".app", ".dll", ".so", ".dmg", ".pkg", ".msi", ".deb", ".rpm", ".apk", ".zip", ".rar", ".7z", ".tar", ".gz", ".scr", ".vbs", ".js", ".py", ".rb", ".pl"]
+ }
}
```
+#### File validation settings
+
+The `FileValidation` section controls the file upload validation rules. You can customize the maximum file sizes (in bytes) and the lists of allowed/blocked file extensions for profile images, project data files, and notebook uploads. These settings are read at startup and injected into the controllers via dependency injection.
+
#### Adding admin users
Admin access in Analysim is controlled through the AdminUsers section of the `appsettings.json` and `appsettings.Development.json`. Each entry in the list corresponds to the username of a registered Analysim user. Admin users will see an Admin link in the navigation bar and can access the /admin section of the platform. To add or remove admin privileges, simply update this list and restart the server.
diff --git a/src/Analysim.Core/Helper/FileTypeValidator.cs b/src/Analysim.Core/Helper/FileTypeValidator.cs
new file mode 100644
index 00000000..2cc7c517
--- /dev/null
+++ b/src/Analysim.Core/Helper/FileTypeValidator.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace Core.Helper
+{
+ ///
+ /// Validates file uploads based on extension, MIME type, and file signature
+ /// Uses an allowlist approach to prevent malicious file uploads
+ ///
+ public class FileTypeValidator
+ {
+ private readonly FileValidationSettings _settings;
+
+ ///
+ /// Magic numbers (file signatures) for common file types
+ /// Used to verify that file content matches the claimed extension
+ ///
+ private static readonly Dictionary FileMagicNumbers = new Dictionary
+ {
+ { new byte[] { 0xFF, 0xD8, 0xFF }, ".jpg" }, // JPEG
+ { new byte[] { 0x89, 0x50, 0x4E, 0x47 }, ".png" }, // PNG
+ { new byte[] { 0x47, 0x49, 0x46 }, ".gif" }, // GIF
+ { new byte[] { 0x52, 0x49, 0x46, 0x46 }, ".webp" }, // WEBP
+ { new byte[] { 0x50, 0x4B, 0x03, 0x04 }, ".xlsx" }, // XLSX (zip-based)
+ { new byte[] { 0x50, 0x4B, 0x03, 0x04 }, ".zip" }, // ZIP
+ { new byte[] { 0x25, 0x50, 0x44, 0x46 }, ".pdf" }, // PDF
+ { new byte[] { 0x7B, 0x0A }, ".json" }, // JSON (basic check)
+ { new byte[] { 0x23, 0x0A }, ".ipynb" } // Jupyter (text-based)
+ };
+
+ public FileTypeValidator(FileValidationSettings settings)
+ {
+ _settings = settings ?? throw new ArgumentNullException(nameof(settings));
+ }
+
+ ///
+ /// Validates a profile image upload
+ ///
+ public (bool IsValid, string ErrorMessage) ValidateProfileImage(string fileName, byte[] fileContent)
+ {
+ return ValidateFile(fileName, fileContent, _settings.AllowedImageExtensions, maxSize: _settings.MaxProfileImageSize);
+ }
+
+ ///
+ /// Validates a project file upload
+ ///
+ public (bool IsValid, string ErrorMessage) ValidateProjectFile(string fileName, byte[] fileContent)
+ {
+ return ValidateFile(fileName, fileContent, _settings.AllowedProjectFileExtensions, maxSize: _settings.MaxProjectFileSize);
+ }
+
+ ///
+ /// Validates a notebook file upload
+ ///
+ public (bool IsValid, string ErrorMessage) ValidateNotebookFile(string fileName, byte[] fileContent)
+ {
+ // Notebooks must be .ipynb only
+ var result = ValidateFile(fileName, fileContent, _settings.AllowedNotebookExtensions, maxSize: _settings.MaxNotebookFileSize);
+
+ if (!result.IsValid)
+ return result;
+
+ // Additional validation: Notebooks should be JSON text files
+ if (!IsValidJsonContent(fileContent))
+ return (false, "Invalid Jupyter notebook format. Must be a valid JSON file.");
+
+ return (true, string.Empty);
+ }
+
+ ///
+ /// Generic file validation
+ ///
+ private (bool IsValid, string ErrorMessage) ValidateFile(
+ string fileName,
+ byte[] fileContent,
+ List allowedExtensions,
+ int maxSize)
+ {
+ if (fileContent == null || fileContent.Length == 0)
+ return (false, "File content is empty.");
+
+ // Check file size
+ if (fileContent.Length > maxSize)
+ return (false, $"File size exceeds maximum allowed size of {maxSize} bytes.");
+
+ // Get extension
+ var extension = Path.GetExtension(fileName)?.ToLower();
+ if (string.IsNullOrEmpty(extension))
+ return (false, "File has no extension.");
+
+ // Check if extension is blocked
+ if (_settings.BlockedExtensions?.Contains(extension) == true)
+ return (false, $"File type '{extension}' is not allowed.");
+
+ // Check if extension is in allowlist
+ if (!allowedExtensions.Contains(extension))
+ return (false, $"File type '{extension}' is not allowed. Allowed types: {string.Join(", ", allowedExtensions)}");
+
+ // Verify file signature matches extension (magic number check)
+ if (!VerifyFileSignature(fileContent, extension))
+ return (false, $"File content does not match the file extension. Possible file type mismatch or corruption.");
+
+ return (true, string.Empty);
+ }
+
+ ///
+ /// Verifies file signature (magic number) matches the claimed extension
+ ///
+ private bool VerifyFileSignature(byte[] fileContent, string extension)
+ {
+ if (fileContent == null || fileContent.Length == 0)
+ return false;
+
+ // For text-based formats, perform basic validation
+ if (extension == ".json" || extension == ".ipynb")
+ return IsValidJsonContent(fileContent);
+
+ if (extension == ".csv" || extension == ".txt" || extension == ".tsv" || extension == ".dat")
+ return IsValidTextContent(fileContent);
+
+ // For binary formats, check magic numbers
+ foreach (var kvp in FileMagicNumbers)
+ {
+ if (fileContent.Length >= kvp.Key.Length &&
+ fileContent.Take(kvp.Key.Length).SequenceEqual(kvp.Key))
+ {
+ return kvp.Value == extension || (kvp.Value == ".xlsx" && extension == ".xls");
+ }
+ }
+
+ // If no magic number matched, assume it's okay for formats we can't verify
+ // (like CSV, TXT, XML without specific magic numbers)
+ if (extension == ".xml")
+ return IsValidTextContent(fileContent) && fileContent.ToString().Contains("<");
+
+ return true; // Allow if we can't determine a signature
+ }
+
+ ///
+ /// Checks if file content is valid JSON
+ ///
+ private bool IsValidJsonContent(byte[] fileContent)
+ {
+ try
+ {
+ var text = System.Text.Encoding.UTF8.GetString(fileContent);
+ var trimmed = text.Trim();
+
+ // Basic JSON structure check
+ return (trimmed.StartsWith("{") && trimmed.EndsWith("}")) ||
+ (trimmed.StartsWith("[") && trimmed.EndsWith("]"));
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Checks if file content is valid text
+ ///
+ private bool IsValidTextContent(byte[] fileContent)
+ {
+ try
+ {
+ // Attempt to decode as UTF-8
+ var text = System.Text.Encoding.UTF8.GetString(fileContent);
+
+ // Check for null characters (indicator of binary content)
+ return !text.Contains("\0");
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Analysim.Core/Helper/FileValidationSettings.cs b/src/Analysim.Core/Helper/FileValidationSettings.cs
new file mode 100644
index 00000000..a040c0aa
--- /dev/null
+++ b/src/Analysim.Core/Helper/FileValidationSettings.cs
@@ -0,0 +1,60 @@
+using System.Collections.Generic;
+
+namespace Core.Helper
+{
+ ///
+ /// Configuration settings for file validation
+ /// Loaded from appsettings.json FileValidation section
+ ///
+ public class FileValidationSettings
+ {
+ ///
+ /// Maximum size for profile image uploads in bytes (default: 5 MB)
+ ///
+ public int MaxProfileImageSize { get; set; } = 5 * 1024 * 1024; // 5 MB
+
+ ///
+ /// Maximum size for project file uploads in bytes (default: 100 MB)
+ ///
+ public int MaxProjectFileSize { get; set; } = 100 * 1024 * 1024; // 100 MB
+
+ ///
+ /// Maximum size for notebook file uploads in bytes (default: 50 MB)
+ ///
+ public int MaxNotebookFileSize { get; set; } = 50 * 1024 * 1024; // 50 MB
+
+ ///
+ /// Allowed file extensions for profile images
+ ///
+ public List AllowedImageExtensions { get; set; } = new List
+ {
+ ".jpg", ".jpeg", ".png", ".gif", ".webp"
+ };
+
+ ///
+ /// Allowed file extensions for project data files
+ ///
+ public List AllowedProjectFileExtensions { get; set; } = new List
+ {
+ ".csv", ".json", ".txt", ".xlsx", ".xls", ".pdf", ".xml", ".tsv", ".dat"
+ };
+
+ ///
+ /// Allowed file extensions for notebook files
+ ///
+ public List AllowedNotebookExtensions { get; set; } = new List
+ {
+ ".ipynb"
+ };
+
+ ///
+ /// File extensions to block (dangerous executables and scripts)
+ ///
+ public List BlockedExtensions { get; set; } = new List
+ {
+ ".exe", ".bat", ".cmd", ".sh", ".ps1", ".app", ".dll", ".so", ".dmg",
+ ".pkg", ".msi", ".deb", ".rpm", ".apk", ".zip", ".rar", ".7z", ".tar",
+ ".gz", ".scr", ".vbs", ".js", ".py", ".rb", ".pl"
+ };
+ }
+}
diff --git a/src/Analysim.Web/Controllers/AccountController.cs b/src/Analysim.Web/Controllers/AccountController.cs
index d782b51b..9a457b88 100644
--- a/src/Analysim.Web/Controllers/AccountController.cs
+++ b/src/Analysim.Web/Controllers/AccountController.cs
@@ -41,18 +41,21 @@ public class AccountController : ControllerBase
private readonly ApplicationDbContext _dbContext;
private readonly ILoggerManager _loggerManager;
private readonly IMailNetService _mailNetService;
+ private readonly FileValidationSettings _fileValidationSettings;
private readonly IConfiguration _configuration;
public AccountController(IOptions jwtSettings, UserManager userManager,
SignInManager signManager, ApplicationDbContext dbContext,
ILoggerManager loggerManager,
- IMailNetService mailNetService,IConfiguration configuration)
+ IMailNetService mailNetService,IConfiguration configuration,
+ IOptions fileValidationSettings)
{
_jwtSettings = jwtSettings.Value;
_userManager = userManager;
_signManager = signManager;
_dbContext = dbContext;
+ _fileValidationSettings = fileValidationSettings.Value;
_loggerManager = loggerManager;
_mailNetService = mailNetService;
_configuration = configuration;
@@ -79,7 +82,7 @@ public IActionResult GetUserByID([FromRoute] int id)
// user.EmailConfirmed ;
return Ok(new
{
- result = user,
+ result = ViewModels.Account.UserSafeDTO.FromUser(user),
message = "Received User: " + user.UserName
});
}
@@ -126,7 +129,7 @@ public IActionResult GetUserByName([FromRoute] string username)
if (user == null) return NotFound(new { message = "User Not Found" });
return Ok(new
{
- result = user,
+ result = ViewModels.Account.UserSafeDTO.FromUser(user),
message = "Received User: " + user.UserName
});
}
@@ -152,7 +155,7 @@ public IActionResult GetUserRange([FromQuery(Name = "id")] List ids)
return Ok(new
{
- result = users,
+ result = ViewModels.Account.UserSafeDTO.FromUsers(users),
message = "Received User Range"
});
}
@@ -176,7 +179,7 @@ public IActionResult GetUserList()
return Ok(new
{
- result = users,
+ result = ViewModels.Account.UserSafeDTO.FromUsers(users),
message = "Received User List"
});
}
@@ -224,7 +227,7 @@ public IActionResult Search([FromQuery(Name = "term")] List searchTerms)
return Ok(new
{
- result = matchedUser,
+ result = ViewModels.Account.UserSafeDTO.FromUsers(matchedUser.ToList()),
message = "Search Successful"
});
}
@@ -783,6 +786,12 @@ public async Task UploadProfileImage([FromForm] AccountUploadVM f
await formdata.File.CopyToAsync(memoryStream);
var fileContent = memoryStream.ToArray();
+ // Validate file type and size for profile image
+ var fileValidator = new Core.Helper.FileTypeValidator(_fileValidationSettings);
+ var validationResult = fileValidator.ValidateProfileImage(formdata.File.FileName, fileContent);
+ if (!validationResult.IsValid)
+ return BadRequest(validationResult.ErrorMessage);
+
// Check For Existing
var blobFile = _dbContext.BlobFiles.FirstOrDefault(x => x.UserID == user.Id && x.Name == "profileImage");
if (blobFile != null)
diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs
index f4d01fb6..4888e4d7 100644
--- a/src/Analysim.Web/Controllers/ProjectController.cs
+++ b/src/Analysim.Web/Controllers/ProjectController.cs
@@ -1,4 +1,4 @@
-using Internal;
+using Internal;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.AspNetCore.Mvc;
@@ -29,6 +29,8 @@
using Analysim.Core.Entities;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
+using Core.Helper;
+using Microsoft.Extensions.Options;
namespace Web.Controllers
{
@@ -39,11 +41,14 @@ public class ProjectController : ControllerBase
private readonly ApplicationDbContext _dbContext;
private readonly IConfiguration _configuration;
+ private readonly FileValidationSettings _fileValidationSettings;
- public ProjectController(ApplicationDbContext dbContext, IConfiguration configuration)
+ public ProjectController(ApplicationDbContext dbContext, IConfiguration configuration,
+ IOptions fileValidationSettings)
{
_dbContext = dbContext;
_configuration = configuration;
+ _fileValidationSettings = fileValidationSettings.Value;
}
#region GET REQUEST
@@ -1045,6 +1050,12 @@ public async Task UploadFile([FromForm] ProjectFileUploadVM formd
await formdata.File.CopyToAsync(memoryStream);
var fileContent = memoryStream.ToArray();
+ // Validate file type and size
+ var fileValidator = new Core.Helper.FileTypeValidator(_fileValidationSettings);
+ var validationResult = fileValidator.ValidateProjectFile(formdata.File.FileName, fileContent);
+ if (!validationResult.IsValid)
+ return BadRequest(validationResult.ErrorMessage);
+
// Create BlobFile
var newBlobFile = new BlobFile
{
@@ -1142,6 +1153,12 @@ public async Task UploadNotebook([FromForm] ProjectNotebookUpload
await noteBookData.NotebookFile.CopyToAsync(memoryStream);
var fileContent = memoryStream.ToArray();
+ // Validate notebook file type and size
+ var fileValidator = new Core.Helper.FileTypeValidator(_fileValidationSettings);
+ var validationResult = fileValidator.ValidateNotebookFile(noteBookData.NotebookFile.FileName, fileContent);
+ if (!validationResult.IsValid)
+ return BadRequest(validationResult.ErrorMessage);
+
Notebook newNotebook = new Notebook
{
Container = "notebook-" + project.Name.ToLower(),
@@ -1245,6 +1262,12 @@ public async Task UploadNotebookNewVersion([FromForm] ProjectNote
await noteBookData.NotebookFile.CopyToAsync(memoryStream);
var fileContent = memoryStream.ToArray();
+ // Validate notebook file type and size
+ var fileValidator = new Core.Helper.FileTypeValidator(_fileValidationSettings);
+ var validationResult = fileValidator.ValidateNotebookFile(noteBookData.NotebookFile.FileName, fileContent);
+ if (!validationResult.IsValid)
+ return BadRequest(validationResult.ErrorMessage);
+
NotebookContent newNotebookContent = new NotebookContent
{
NotebookID = existingNotebook.NotebookID,
@@ -1350,11 +1373,19 @@ public async Task UploadExistingNotebook([FromForm] ExistingProje
using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
+ byte[] fileContent = System.IO.File.ReadAllBytes(filePath);
+
+ // Validate notebook file type and size for colab downloads
+ var fileValidator = new Core.Helper.FileTypeValidator(_fileValidationSettings);
+ var validationResult = fileValidator.ValidateNotebookFile(fileName, fileContent);
+ if (!validationResult.IsValid)
+ return BadRequest(validationResult.ErrorMessage);
+
NotebookContent notebookContent = new NotebookContent
{
NotebookID = newNotebook.NotebookID,
Version = 1,
- Content = System.IO.File.ReadAllBytes(filePath),
+ Content = fileContent,
Author = "hello",
Size = (int)fileStream.Length,
DateCreated = DateTime.UtcNow
diff --git a/src/Analysim.Web/Startup.cs b/src/Analysim.Web/Startup.cs
index 028ed82d..b981fdaf 100644
--- a/src/Analysim.Web/Startup.cs
+++ b/src/Analysim.Web/Startup.cs
@@ -1,4 +1,5 @@
using AutoMapper;
+using Core.Helper;
using Core.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -25,6 +26,8 @@ public Startup(IConfiguration configuration)
public void ConfigureServices(IServiceCollection services)
{
+ services.Configure(Configuration.GetSection("FileValidation"));
+
services.ConfigureCors();
services.ConfigureAuthorization();
diff --git a/src/Analysim.Web/ViewModels/Account/UserSafeDTO.cs b/src/Analysim.Web/ViewModels/Account/UserSafeDTO.cs
new file mode 100644
index 00000000..5b00a714
--- /dev/null
+++ b/src/Analysim.Web/ViewModels/Account/UserSafeDTO.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Web.ViewModels.Account
+{
+ ///
+ /// Safe DTO for returning user information in API responses.
+ /// Excludes sensitive authentication fields like PasswordHash, SecurityStamp, etc.
+ ///
+ public class UserSafeDTO
+ {
+ public int Id { get; set; }
+ public string UserName { get; set; }
+ public string Email { get; set; }
+ public string Bio { get; set; }
+ public DateTimeOffset DateCreated { get; set; }
+
+ ///
+ /// Maps from User entity to safe DTO, excluding sensitive fields
+ ///
+ public static UserSafeDTO FromUser(Core.Entities.User user)
+ {
+ if (user == null)
+ return null;
+
+ return new UserSafeDTO
+ {
+ Id = user.Id,
+ UserName = user.UserName,
+ Email = user.Email,
+ Bio = user.Bio,
+ DateCreated = user.DateCreated
+ };
+ }
+
+ ///
+ /// Maps collection of users to safe DTOs
+ ///
+ public static List FromUsers(IEnumerable users)
+ {
+ if (users == null)
+ return new List();
+
+ return users.Select(FromUser).ToList();
+ }
+ }
+}