Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
180 changes: 180 additions & 0 deletions src/Analysim.Core/Helper/FileTypeValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Core.Helper
{
/// <summary>
/// Validates file uploads based on extension, MIME type, and file signature
/// Uses an allowlist approach to prevent malicious file uploads
/// </summary>
public class FileTypeValidator
{
private readonly FileValidationSettings _settings;

/// <summary>
/// Magic numbers (file signatures) for common file types
/// Used to verify that file content matches the claimed extension
/// </summary>
private static readonly Dictionary<byte[], string> FileMagicNumbers = new Dictionary<byte[], string>
{
{ 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
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current WEBP signature check only validates the RIFF header, which is shared by other RIFF-based formats (e.g., WAV/AVI). This allows non-WEBP files renamed to .webp to pass validation. Consider validating both RIFF at bytes 0-3 and WEBP at bytes 8-11 (or equivalent robust WEBP signature checks).

Suggested change
{ new byte[] { 0x52, 0x49, 0x46, 0x46 }, ".webp" }, // WEBP

Copilot uses AI. Check for mistakes.
{ 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));
}

/// <summary>
/// Validates a profile image upload
/// </summary>
public (bool IsValid, string ErrorMessage) ValidateProfileImage(string fileName, byte[] fileContent)
{
return ValidateFile(fileName, fileContent, _settings.AllowedImageExtensions, maxSize: _settings.MaxProfileImageSize);
}

/// <summary>
/// Validates a project file upload
/// </summary>
public (bool IsValid, string ErrorMessage) ValidateProjectFile(string fileName, byte[] fileContent)
{
return ValidateFile(fileName, fileContent, _settings.AllowedProjectFileExtensions, maxSize: _settings.MaxProjectFileSize);
}

/// <summary>
/// Validates a notebook file upload
/// </summary>
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);
}

/// <summary>
/// Generic file validation
/// </summary>
private (bool IsValid, string ErrorMessage) ValidateFile(
string fileName,
byte[] fileContent,
List<string> 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))
Comment on lines +93 to +98
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allowedExtensions.Contains(extension) is case-sensitive, but extension lists can be configured with different casing in appsettings. This can cause valid uploads to be rejected depending on configuration values. Consider normalizing configured extensions or using a case-insensitive comparison.

Suggested change
// 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))
// Check if extension is blocked (case-insensitive)
if (_settings.BlockedExtensions?.Any(e => string.Equals(e, extension, StringComparison.OrdinalIgnoreCase)) == true)
return (false, $"File type '{extension}' is not allowed.");
// Check if extension is in allowlist (case-insensitive)
if (!allowedExtensions.Any(e => string.Equals(e, extension, StringComparison.OrdinalIgnoreCase)))

Copilot uses AI. Check for mistakes.
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);
}

/// <summary>
/// Verifies file signature (magic number) matches the claimed extension
/// </summary>
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");
Comment on lines +123 to +129
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.xls is allowlisted, but the signature logic effectively treats .xls as acceptable when the content matches the ZIP-based .xlsx signature. This will reject real legacy .xls files (OLE2 signature) and can allow .xlsx content disguised as .xls. Either remove .xls from the allowed list or implement proper .xls signature validation.

Suggested change
// 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");
// Explicit validation for legacy Excel (.xls) using OLE2 compound file signature
if (extension == ".xls")
{
// OLE2 magic number: D0 CF 11 E0 A1 B1 1A E1
byte[] ole2Signature = new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 };
if (fileContent.Length < ole2Signature.Length)
return false;
for (int i = 0; i < ole2Signature.Length; i++)
{
if (fileContent[i] != ole2Signature[i])
return false;
}
return true;
}
// 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;

Copilot uses AI. Check for mistakes.
}
}

// 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("<");
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VerifyFileSignature attempts to validate XML by calling fileContent.ToString().Contains("<"), but byte[].ToString() returns the type name (e.g., "System.Byte[]"), not the decoded content. This will cause all .xml uploads to fail validation. Decode the bytes to text (e.g., UTF-8) and check the decoded string, or reuse IsValidTextContent/a proper XML parse check.

Suggested change
return IsValidTextContent(fileContent) && fileContent.ToString().Contains("<");
{
var text = System.Text.Encoding.UTF8.GetString(fileContent);
return IsValidTextContent(fileContent) && text.Contains("<");
}

Copilot uses AI. Check for mistakes.

return true; // Allow if we can't determine a signature
Comment on lines +124 to +138
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VerifyFileSignature defaults to allowing the file when no known magic number matches. That makes signature validation bypassable for many allowed types (e.g., an arbitrary binary renamed to .png/.pdf will pass if it doesn't match a different known signature). For extensions with known signatures, consider requiring a positive match rather than default-allowing.

Suggested change
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
var signatureMatched = false;
foreach (var kvp in FileMagicNumbers)
{
if (fileContent.Length >= kvp.Key.Length &&
fileContent.Take(kvp.Key.Length).SequenceEqual(kvp.Key))
{
signatureMatched = kvp.Value == extension || (kvp.Value == ".xlsx" && extension == ".xls");
if (signatureMatched)
{
return true;
}
}
}
// If no magic number matched, and the extension has a known signature, reject the file
var hasKnownSignature = FileMagicNumbers.Any(kvp =>
kvp.Value == extension || (kvp.Value == ".xlsx" && extension == ".xls"));
if (hasKnownSignature)
{
return false;
}
// For formats without specific magic numbers (like XML), perform basic sanity checks
if (extension == ".xml")
{
if (!IsValidTextContent(fileContent))
return false;
var text = System.Text.Encoding.UTF8.GetString(fileContent);
return text.Contains("<");
}
// Allow if we can't determine a signature and no signature is expected
return true;

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// Checks if file content is valid JSON
/// </summary>
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;
}
}

/// <summary>
/// Checks if file content is valid text
/// </summary>
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;
}
}
}
}
60 changes: 60 additions & 0 deletions src/Analysim.Core/Helper/FileValidationSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Collections.Generic;

namespace Core.Helper
{
/// <summary>
/// Configuration settings for file validation
/// Loaded from appsettings.json FileValidation section
/// </summary>
public class FileValidationSettings
{
/// <summary>
/// Maximum size for profile image uploads in bytes (default: 5 MB)
/// </summary>
public int MaxProfileImageSize { get; set; } = 5 * 1024 * 1024; // 5 MB

/// <summary>
/// Maximum size for project file uploads in bytes (default: 100 MB)
/// </summary>
public int MaxProjectFileSize { get; set; } = 100 * 1024 * 1024; // 100 MB

/// <summary>
/// Maximum size for notebook file uploads in bytes (default: 50 MB)
/// </summary>
public int MaxNotebookFileSize { get; set; } = 50 * 1024 * 1024; // 50 MB

/// <summary>
/// Allowed file extensions for profile images
/// </summary>
public List<string> AllowedImageExtensions { get; set; } = new List<string>
{
".jpg", ".jpeg", ".png", ".gif", ".webp"
};

/// <summary>
/// Allowed file extensions for project data files
/// </summary>
public List<string> AllowedProjectFileExtensions { get; set; } = new List<string>
{
".csv", ".json", ".txt", ".xlsx", ".xls", ".pdf", ".xml", ".tsv", ".dat"
};

/// <summary>
/// Allowed file extensions for notebook files
/// </summary>
public List<string> AllowedNotebookExtensions { get; set; } = new List<string>
{
".ipynb"
};

/// <summary>
/// File extensions to block (dangerous executables and scripts)
/// </summary>
public List<string> BlockedExtensions { get; set; } = new List<string>
{
".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"
};
}
}
21 changes: 15 additions & 6 deletions src/Analysim.Web/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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> jwtSettings, UserManager<User> userManager,
SignInManager<User> signManager, ApplicationDbContext dbContext,
ILoggerManager loggerManager,
IMailNetService mailNetService,IConfiguration configuration)
IMailNetService mailNetService,IConfiguration configuration,
IOptions<FileValidationSettings> fileValidationSettings)
{
_jwtSettings = jwtSettings.Value;
_userManager = userManager;
_signManager = signManager;
_dbContext = dbContext;
_fileValidationSettings = fileValidationSettings.Value;
_loggerManager = loggerManager;
_mailNetService = mailNetService;
_configuration = configuration;
Expand All @@ -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
});
}
Expand Down Expand Up @@ -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
});
}
Expand All @@ -152,7 +155,7 @@ public IActionResult GetUserRange([FromQuery(Name = "id")] List<int> ids)

return Ok(new
{
result = users,
result = ViewModels.Account.UserSafeDTO.FromUsers(users),
message = "Received User Range"
});
}
Expand All @@ -176,7 +179,7 @@ public IActionResult GetUserList()

return Ok(new
{
result = users,
result = ViewModels.Account.UserSafeDTO.FromUsers(users),
message = "Received User List"
});
}
Expand Down Expand Up @@ -224,7 +227,7 @@ public IActionResult Search([FromQuery(Name = "term")] List<string> searchTerms)

return Ok(new
{
result = matchedUser,
result = ViewModels.Account.UserSafeDTO.FromUsers(matchedUser.ToList()),
message = "Search Successful"
});
}
Expand Down Expand Up @@ -783,6 +786,12 @@ public async Task<IActionResult> 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)
Expand Down
Loading
Loading