From 5581faf47943ea46751c083c98422c7835112ee5 Mon Sep 17 00:00:00 2001 From: Vitali Semianiaka Date: Wed, 11 Feb 2026 16:12:04 +0300 Subject: [PATCH] WiP --- .../Models/ComponentDefinition.cs | 18 + .../Models/ComponentInput.cs | 10 + .../Models/ComponentOutput.cs | 8 + .../Models/PresetDefinition.cs | 14 + .../Models/RuntimeRequest.cs | 8 + .../Models/RuntimeResponse.cs | 7 + MyCompany.MyProject.BackendApi/Program.cs | 468 +++++++++++------- .../playground/ComponentSelectionSidebar.vue | 95 ++-- .../playground/PlaygroundToolbar.vue | 13 + .../components_s8n/ComponentCalculator.vue | 7 +- .../components_s8n/ComponentHttpRequest.vue | 16 +- .../src/composables/useComponentStore.ts | 68 ++- QuasarFrontend/src/pages/PlaygroundPage.vue | 79 ++- components | 2 +- components_user/calculator_addtwonumbers.json | 18 + components_user/httprequest_googletest.json | 22 + components_user/httprequest_postexample.json | 27 + plans/scalar-httprequest-playground.md | 178 ------- 18 files changed, 650 insertions(+), 408 deletions(-) create mode 100644 MyCompany.MyProject.BackendApi/Models/ComponentDefinition.cs create mode 100644 MyCompany.MyProject.BackendApi/Models/ComponentInput.cs create mode 100644 MyCompany.MyProject.BackendApi/Models/ComponentOutput.cs create mode 100644 MyCompany.MyProject.BackendApi/Models/PresetDefinition.cs create mode 100644 MyCompany.MyProject.BackendApi/Models/RuntimeRequest.cs create mode 100644 MyCompany.MyProject.BackendApi/Models/RuntimeResponse.cs create mode 100644 components_user/calculator_addtwonumbers.json create mode 100644 components_user/httprequest_googletest.json create mode 100644 components_user/httprequest_postexample.json delete mode 100644 plans/scalar-httprequest-playground.md diff --git a/MyCompany.MyProject.BackendApi/Models/ComponentDefinition.cs b/MyCompany.MyProject.BackendApi/Models/ComponentDefinition.cs new file mode 100644 index 0000000..c9d8f86 --- /dev/null +++ b/MyCompany.MyProject.BackendApi/Models/ComponentDefinition.cs @@ -0,0 +1,18 @@ +namespace MyCompany.MyProject.BackendApi.Models; + +record ComponentDefinition +{ + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public List Inputs { get; set; } = new(); + public List Outputs { get; set; } = new(); + public string Source { get; set; } = string.Empty; + public string Class { get; set; } = string.Empty; + public List Methods { get; set; } = new(); + public string Gui { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public List Tags { get; set; } = new(); + public List Presets { get; set; } = new(); +} diff --git a/MyCompany.MyProject.BackendApi/Models/ComponentInput.cs b/MyCompany.MyProject.BackendApi/Models/ComponentInput.cs new file mode 100644 index 0000000..4bf86df --- /dev/null +++ b/MyCompany.MyProject.BackendApi/Models/ComponentInput.cs @@ -0,0 +1,10 @@ +namespace MyCompany.MyProject.BackendApi.Models; + +record ComponentInput +{ + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public List? Enum { get; set; } + public bool Required { get; set; } = true; +} diff --git a/MyCompany.MyProject.BackendApi/Models/ComponentOutput.cs b/MyCompany.MyProject.BackendApi/Models/ComponentOutput.cs new file mode 100644 index 0000000..2aed6e7 --- /dev/null +++ b/MyCompany.MyProject.BackendApi/Models/ComponentOutput.cs @@ -0,0 +1,8 @@ +namespace MyCompany.MyProject.BackendApi.Models; + +record ComponentOutput +{ + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; +} diff --git a/MyCompany.MyProject.BackendApi/Models/PresetDefinition.cs b/MyCompany.MyProject.BackendApi/Models/PresetDefinition.cs new file mode 100644 index 0000000..472253a --- /dev/null +++ b/MyCompany.MyProject.BackendApi/Models/PresetDefinition.cs @@ -0,0 +1,14 @@ +namespace MyCompany.MyProject.BackendApi.Models; + +record PresetDefinition +{ + public string PresetId { get; set; } = string.Empty; + public string PresetName { get; set; } = string.Empty; + public string BaseComponentCode { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Author { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public Dictionary Inputs { get; set; } = new(); + public Dictionary Metadata { get; set; } = new(); +} diff --git a/MyCompany.MyProject.BackendApi/Models/RuntimeRequest.cs b/MyCompany.MyProject.BackendApi/Models/RuntimeRequest.cs new file mode 100644 index 0000000..416e3d1 --- /dev/null +++ b/MyCompany.MyProject.BackendApi/Models/RuntimeRequest.cs @@ -0,0 +1,8 @@ +namespace MyCompany.MyProject.BackendApi.Models; + +record RuntimeRequest +{ + public string ClassName { get; set; } = string.Empty; + public string MethodName { get; set; } = string.Empty; + public object? Inputs { get; set; } +} diff --git a/MyCompany.MyProject.BackendApi/Models/RuntimeResponse.cs b/MyCompany.MyProject.BackendApi/Models/RuntimeResponse.cs new file mode 100644 index 0000000..ff5e527 --- /dev/null +++ b/MyCompany.MyProject.BackendApi/Models/RuntimeResponse.cs @@ -0,0 +1,7 @@ +namespace MyCompany.MyProject.BackendApi.Models; + +record RuntimeResponse +{ + public object? Outputs { get; set; } + public string? Error { get; set; } +} diff --git a/MyCompany.MyProject.BackendApi/Program.cs b/MyCompany.MyProject.BackendApi/Program.cs index ecd3275..ff4cf2c 100644 --- a/MyCompany.MyProject.BackendApi/Program.cs +++ b/MyCompany.MyProject.BackendApi/Program.cs @@ -1,9 +1,8 @@ using System.Reflection; using System.Text.Json; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; using Scalar.AspNetCore; +using MyCompany.MyProject.BackendApi.Models; +using System.Security; // Ensure S8n.Components.Packages assembly is loaded _ = typeof(S8n.Components.Basics.Calculator).Assembly; @@ -25,185 +24,197 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); - // Component registry endpoint app.MapGet("/api/components", () => { var components = new List(); - var componentsPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "components"); + var presets = new List(); + // Load components from components/ directory + var componentsPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "components"); if (!Directory.Exists(componentsPath)) { // Fallback to absolute path from workspace root componentsPath = Path.Combine(Directory.GetCurrentDirectory(), "components"); } - if (Directory.Exists(componentsPath)) + // Load presets from components_user/ directory + var userComponentsPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "components_user"); + if (!Directory.Exists(userComponentsPath)) { - var jsonFiles = Directory.GetFiles(componentsPath, "*.json", SearchOption.AllDirectories); + userComponentsPath = Path.Combine(Directory.GetCurrentDirectory(), "components_user"); + } + + // Load all JSON files from both directories + var allPaths = new List(); + if (Directory.Exists(componentsPath)) allPaths.Add(componentsPath); + if (Directory.Exists(userComponentsPath)) allPaths.Add(userComponentsPath); + + foreach (var path in allPaths) + { + var jsonFiles = Directory.GetFiles(path, "*.json", SearchOption.AllDirectories); foreach (var file in jsonFiles) { try { var json = File.ReadAllText(file); - var component = JsonSerializer.Deserialize(json, new JsonSerializerOptions + + // Try to determine if this is a component or preset by checking for "type" field + using var doc = JsonDocument.Parse(json); + var isPreset = doc.RootElement.TryGetProperty("type", out var typeElement) && + typeElement.GetString() == "preset"; + + if (isPreset) { - PropertyNameCaseInsensitive = true - }); - if (component != null) + var preset = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + if (preset != null) + { + presets.Add(preset); + } + } + else { - components.Add(component); + var component = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + if (component != null) + { + components.Add(component); + } } } catch (Exception ex) { // Log error but continue - Console.WriteLine($"Error reading component file {file}: {ex.Message}"); + Console.WriteLine($"Error reading component/preset file {file}: {ex.Message}"); } } } + // Group presets by baseComponentCode and attach to components + var presetsByComponent = presets.GroupBy(p => p.BaseComponentCode) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var component in components) + { + if (presetsByComponent.TryGetValue(component.Code, out var componentPresets)) + { + component.Presets = componentPresets; + } + } + return Results.Ok(components); }) .WithName("GetComponents"); // Runtime execution endpoint -app.MapPost("/api/runtime/execute", async (RuntimeRequest request) => +app.MapPost("/api/runtime/execute", async (RuntimeRequest request, ILogger logger) => { + const int MAX_CLASS_NAME_LENGTH = 500; + const int MAX_METHOD_NAME_LENGTH = 100; + try { - // Get the type from the class name - var type = Type.GetType(request.ClassName); - if (type == null) + // Input validation and sanitization + if (string.IsNullOrWhiteSpace(request.ClassName) || + string.IsNullOrWhiteSpace(request.MethodName) || + request.ClassName.Length > MAX_CLASS_NAME_LENGTH || + request.MethodName.Length > MAX_METHOD_NAME_LENGTH) { - // Try to find in loaded assemblies - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + logger.LogWarning("Invalid input parameters received"); + return Results.BadRequest(new RuntimeResponse { - type = assembly.GetType(request.ClassName); - if (type != null) break; - } + Error = "Invalid class name or method name" + }); } + // Security: Validate class name format to prevent malicious type loading + if (!IsValidTypeName(request.ClassName)) + { + logger.LogWarning("Invalid type name format: {ClassName}", request.ClassName); + return Results.BadRequest(new RuntimeResponse + { + Error = "Invalid class name format" + }); + } + + // Security: Validate method name format + if (!IsValidMethodName(request.MethodName)) + { + logger.LogWarning("Invalid method name format: {MethodName}", request.MethodName); + return Results.BadRequest(new RuntimeResponse + { + Error = "Invalid method name format" + }); + } + + // Get the type from the class name with security restrictions + var type = GetTypeSafely(request.ClassName); if (type == null) { - return Results.BadRequest(new RuntimeResponse + logger.LogWarning("Class not found: {ClassName}", request.ClassName); + return Results.NotFound(new RuntimeResponse { Error = $"Class not found: {request.ClassName}" }); } - // Create instance - var instance = Activator.CreateInstance(type); + // Security: Verify the type is in allowed assemblies/namespaces + if (!IsAllowedAssembly(type.Assembly)) + { + logger.LogWarning("Access to type from disallowed assembly: {Assembly}", type.Assembly.FullName); + return Results.Forbid(); + } + + // Security: Check if type is instantiable and safe + if (!IsSafeInstantiableType(type)) + { + logger.LogWarning("Attempted to instantiate unsafe type: {Type}", type.FullName); + return Results.BadRequest(new RuntimeResponse + { + Error = "Type instantiation is not allowed" + }); + } + + // Create instance with dependency injection if available + var instance = CreateInstanceSafely(type); if (instance == null) { + logger.LogError("Failed to create instance of: {ClassName}", request.ClassName); return Results.BadRequest(new RuntimeResponse { Error = $"Failed to create instance of: {request.ClassName}" }); } - // Get the method - var method = type.GetMethod(request.MethodName); + // Get the method with security checks + var method = type.GetMethod(request.MethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static); if (method == null) { - return Results.BadRequest(new RuntimeResponse + logger.LogWarning("Method not found: {MethodName} in type {Type}", request.MethodName, type.FullName); + return Results.NotFound(new RuntimeResponse { Error = $"Method not found: {request.MethodName}" }); } - // Parse inputs and invoke method - var inputs = request.Inputs as JsonElement?; - object? result; - - if (inputs.HasValue) + // Security: Verify method is safe to execute + if (!IsSafeExecutableMethod(method)) { - // Get method parameters - var parameters = method.GetParameters(); - var args = new List(); - - foreach (var param in parameters) + logger.LogWarning("Unsafe method attempted: {Method} in type {Type}", method.Name, type.FullName); + return Results.BadRequest(new RuntimeResponse { - if (inputs.Value.TryGetProperty(param.Name!, out var property)) - { - var value = JsonSerializer.Deserialize(property.GetRawText(), param.ParameterType); - args.Add(value); - } - else if (param.IsOptional) - { - args.Add(param.DefaultValue); - } - else - { - return Results.BadRequest(new RuntimeResponse - { - Error = $"Missing required parameter: {param.Name}" - }); - } - } - - result = method.Invoke(instance, args.ToArray()); - } - else - { - result = method.Invoke(instance, null); + Error = "Method execution is not allowed" + }); } - Console.Error.WriteLine($"Invocation result type: {result?.GetType()}"); - // If method returns a Task, await it - if (result is Task taskResult) - { - Console.Error.WriteLine($"Detected Task, awaiting..."); - await taskResult.ConfigureAwait(false); - Console.Error.WriteLine($"Task completed, status: {taskResult.Status}"); + // Execute method with proper parameter binding + var result = await ExecuteMethodSafely(instance, method, request.Inputs, logger); - // If it's a Task, get the result via reflection - var resultType = taskResult.GetType(); - Console.Error.WriteLine($"Task result type: {resultType}"); - if (typeof(Task).IsAssignableFrom(resultType)) - { - Console.Error.WriteLine($"Generic Task detected"); - // Get the Result property - var resultProperty = resultType.GetProperty("Result"); - if (resultProperty != null) - { - result = resultProperty.GetValue(taskResult); - Console.Error.WriteLine($"Result value: {result}"); - Console.Error.WriteLine($"Result value type: {result?.GetType()}"); - } - else - { - Console.Error.WriteLine($"Result property not found"); - result = null; - } - } - else - { - // Task (non-generic) returns nothing - Console.Error.WriteLine($"Non-generic Task, setting result to null"); - result = null; - } - } - - Console.Error.WriteLine($"Final result: {result}"); return Results.Ok(new RuntimeResponse { Outputs = result @@ -211,69 +222,188 @@ app.MapPost("/api/runtime/execute", async (RuntimeRequest request) => } catch (TargetInvocationException tie) when (tie.InnerException != null) { + logger.LogError(tie.InnerException, "Method execution failed for {ClassName}.{MethodName}", + request?.ClassName, request?.MethodName); + return Results.BadRequest(new RuntimeResponse { Error = tie.InnerException.Message }); } + catch (SecurityException se) + { + logger.LogError(se, "Security violation during execution of {ClassName}.{MethodName}", + request?.ClassName, request?.MethodName); + + return Results.Forbid(); + } catch (Exception ex) { - return Results.BadRequest(new RuntimeResponse - { - Error = ex.Message - }); + logger.LogError(ex, "Unexpected error during runtime execution for {ClassName}.{MethodName}", + request?.ClassName, request?.MethodName); + + return Results.Problem("An unexpected error occurred during execution", statusCode: 500); } }) .WithName("ExecuteRuntime"); +// Helper methods for enhanced security and functionality +static bool IsValidTypeName(string typeName) +{ + // Basic validation - only allow alphanumeric characters, dots, underscores, and spaces + return System.Text.RegularExpressions.Regex.IsMatch(typeName, @"^[a-zA-Z0-9._]+$") && + !typeName.Contains("..") && // Prevent path traversal attempts + !typeName.StartsWith(".") && // Prevent relative paths + !typeName.EndsWith("."); // Prevent malformed names +} + +static bool IsValidMethodName(string methodName) +{ + // Basic validation for method names + return System.Text.RegularExpressions.Regex.IsMatch(methodName, @"^[a-zA-Z_][a-zA-Z0-9_]*$"); +} + +static Type? GetTypeSafely(string className) +{ + // First try direct lookup + var type = Type.GetType(className); + if (type != null) return type; + + // Search in loaded assemblies with additional safety + var assemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !a.FullName!.StartsWith("System.Private")) + .Take(50); // Limit search scope for performance + + foreach (var assembly in assemblies) + { + type = assembly.GetType(className); + if (type != null) return type; + } + + return null; +} + +static bool IsAllowedAssembly(Assembly assembly) +{ + var assemblyName = assembly.GetName().Name; + + // Allow only specific assemblies (customize based on your needs) + var allowedPrefixes = new[] { + "S8n.Components.", // Replace with your application namespace + "MyCompany.MyProject." // Replace with your library namespaces + }; + + return allowedPrefixes.Any(prefix => assemblyName!.StartsWith(prefix)); +} + +static bool IsSafeInstantiableType(Type type) +{ + // Don't allow instantiation of system types, delegates, interfaces, abstract classes + return !type.IsInterface && + !type.IsAbstract && + !type.IsGenericTypeDefinition && + !type.IsNotPublic && + !type.Namespace!.StartsWith("System.") && // More restrictive than just containing "System" + !type.Namespace.StartsWith("Microsoft."); +} + +static object? CreateInstanceSafely(Type type) +{ + try + { + // Use Activator.CreateInstance with proper exception handling + return Activator.CreateInstance(type, nonPublic: false); + } + catch + { + return null; + } +} + +static bool IsSafeExecutableMethod(MethodInfo method) +{ + // Don't allow access to potentially dangerous methods + var dangerousNames = new[] { "GetType", "MemberwiseClone", "Finalize", "Dispose" }; + + if (dangerousNames.Contains(method.Name, StringComparer.OrdinalIgnoreCase)) + return false; + + // Check if method has dangerous attributes or return types + var returnType = method.ReturnType; + if (returnType == typeof(Type)) + return false; // Potential information disclosure + + return true; +} + +static async Task ExecuteMethodSafely(object instance, MethodInfo method, object? inputs, ILogger logger) +{ + var parameters = method.GetParameters(); + object?[] args; + + if (inputs is JsonElement jsonElement) + { + args = BindParameters(parameters, jsonElement, logger); + } + else + { + args = []; + } + + var result = method.Invoke(instance, args); + + // Handle async methods properly + if (result is Task taskResult) + { + await taskResult.ConfigureAwait(false); + + // Extract result from generic Task + if (taskResult.GetType().IsGenericType) + { + var resultProperty = taskResult.GetType().GetProperty("Result"); + if (resultProperty != null) + { + return resultProperty.GetValue(taskResult); + } + } + return null; // For non-generic Task + } + + return result; +} + +static object?[] BindParameters(ParameterInfo[] parameters, JsonElement jsonElement, ILogger logger) +{ + var args = new object?[parameters.Length]; + + for (int i = 0; i < parameters.Length; i++) + { + var param = parameters[i]; + + if (jsonElement.TryGetProperty(param.Name!, out var property)) + { + try + { + var value = JsonSerializer.Deserialize(property.GetRawText(), param.ParameterType); + args[i] = value; + } + catch (JsonException ex) + { + logger.LogError(ex, "Failed to deserialize parameter {ParamName}", param.Name); + throw new ArgumentException($"Invalid value for parameter '{param.Name}'"); + } + } + else if (param.IsOptional) + { + args[i] = param.DefaultValue; + } + else + { + throw new ArgumentException($"Missing required parameter: {param.Name}"); + } + } + + return args; +} + app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} - -record RuntimeRequest -{ - public string ClassName { get; set; } = string.Empty; - public string MethodName { get; set; } = string.Empty; - public object? Inputs { get; set; } -} - -record RuntimeResponse -{ - public object? Outputs { get; set; } - public string? Error { get; set; } -} - -record ComponentDefinition -{ - public string Code { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string Icon { get; set; } = string.Empty; - public List Inputs { get; set; } = new(); - public List Outputs { get; set; } = new(); - public string Source { get; set; } = string.Empty; - public string Class { get; set; } = string.Empty; - public List Methods { get; set; } = new(); - public string Gui { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; - public List Tags { get; set; } = new(); -} - -record ComponentInput -{ - public string Name { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public List? Enum { get; set; } - public bool Required { get; set; } = true; -} - -record ComponentOutput -{ - public string Name { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; -} diff --git a/QuasarFrontend/src/components/playground/ComponentSelectionSidebar.vue b/QuasarFrontend/src/components/playground/ComponentSelectionSidebar.vue index e2ebd98..9823b5b 100644 --- a/QuasarFrontend/src/components/playground/ComponentSelectionSidebar.vue +++ b/QuasarFrontend/src/components/playground/ComponentSelectionSidebar.vue @@ -27,38 +27,57 @@ - - - - - - {{ component.name }} - - {{ component.description }} - -
- + + + + + {{ component.name }} + - {{ tag }} - -
-
-
+ {{ component.description }} + +
+ + {{ tag }} + +
+ + + + + +
@@ -79,6 +98,7 @@ const emit = defineEmits<{ 'update:selectedComponent': [code: string] 'update:searchQuery': [query: string] refresh: [] + selectPreset: [presetId: string] }>() const searchQueryModel = computed({ @@ -90,7 +110,20 @@ const selectComponent = (code: string) => { emit('update:selectedComponent', code) } +const selectPreset = (presetId: string) => { + emit('selectPreset', presetId) +} + const refresh = () => { emit('refresh') } + + diff --git a/QuasarFrontend/src/components/playground/PlaygroundToolbar.vue b/QuasarFrontend/src/components/playground/PlaygroundToolbar.vue index e078dc4..38ef598 100644 --- a/QuasarFrontend/src/components/playground/PlaygroundToolbar.vue +++ b/QuasarFrontend/src/components/playground/PlaygroundToolbar.vue @@ -12,6 +12,14 @@ @click="refreshComponents" title="Reload components" /> + () const { toggleDarkMode, isDark } = useDarkMode() @@ -45,4 +54,8 @@ const toggleLeftMenu = () => { const refreshComponents = () => { emit('refresh') } + +const copyPreset = () => { + emit('copyPreset') +} diff --git a/QuasarFrontend/src/components_s8n/ComponentCalculator.vue b/QuasarFrontend/src/components_s8n/ComponentCalculator.vue index 84ff2ff..f4fe97d 100644 --- a/QuasarFrontend/src/components_s8n/ComponentCalculator.vue +++ b/QuasarFrontend/src/components_s8n/ComponentCalculator.vue @@ -2,7 +2,6 @@
@@ -80,10 +76,9 @@ defineExpose({