|
| 1 | +using System.Text.Json; |
| 2 | +using System.Text.Json.Serialization; |
| 3 | +using Microsoft.Extensions.FileProviders; |
| 4 | + |
| 5 | +var builder = WebApplication.CreateBuilder(args); |
| 6 | + |
| 7 | +var hostPort = builder.Configuration.GetValue("HostPort", 8080); |
| 8 | +var sandboxPort = builder.Configuration.GetValue("SandboxPort", 8081); |
| 9 | + |
| 10 | +// Kestrel: listen on two ports for separate origins (security requirement) |
| 11 | +builder.WebHost.ConfigureKestrel(options => |
| 12 | +{ |
| 13 | + options.ListenLocalhost(hostPort); |
| 14 | + options.ListenLocalhost(sandboxPort); |
| 15 | +}); |
| 16 | + |
| 17 | +builder.Services.AddCors(o => |
| 18 | + o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); |
| 19 | + |
| 20 | +var app = builder.Build(); |
| 21 | +app.UseCors(); |
| 22 | + |
| 23 | +// Resolve Angular dist directory |
| 24 | +var distRelPath = builder.Configuration["FrontendDistPath"] ?? "../frontend/dist/browser"; |
| 25 | +var distPath = Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, distRelPath)); |
| 26 | + |
| 27 | +// ── Sandbox server (port 8081) ────────────────────────────────────────────── |
| 28 | +// Must run before static files middleware so it intercepts all sandbox-port requests. |
| 29 | +app.Use(async (context, next) => |
| 30 | +{ |
| 31 | + if (context.Connection.LocalPort != sandboxPort) |
| 32 | + { |
| 33 | + await next(); |
| 34 | + return; |
| 35 | + } |
| 36 | + |
| 37 | + var path = context.Request.Path.Value ?? "/"; |
| 38 | + |
| 39 | + if (path is "/" or "/sandbox.html") |
| 40 | + { |
| 41 | + McpUiResourceCsp? csp = null; |
| 42 | + if (context.Request.Query.TryGetValue("csp", out var cspJson)) |
| 43 | + { |
| 44 | + try |
| 45 | + { |
| 46 | + csp = JsonSerializer.Deserialize<McpUiResourceCsp>( |
| 47 | + cspJson!, |
| 48 | + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); |
| 49 | + } |
| 50 | + catch (JsonException ex) |
| 51 | + { |
| 52 | + Console.Error.WriteLine($"[Sandbox] Invalid CSP query param: {ex.Message}"); |
| 53 | + } |
| 54 | + } |
| 55 | + |
| 56 | + context.Response.Headers.ContentSecurityPolicy = BuildCspHeader(csp); |
| 57 | + context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; |
| 58 | + context.Response.Headers.Pragma = "no-cache"; |
| 59 | + context.Response.Headers.Expires = "0"; |
| 60 | + context.Response.ContentType = "text/html"; |
| 61 | + await context.Response.SendFileAsync(Path.Combine(distPath, "sandbox.html")); |
| 62 | + } |
| 63 | + else if (path == "/sandbox.js") |
| 64 | + { |
| 65 | + context.Response.ContentType = "application/javascript"; |
| 66 | + await context.Response.SendFileAsync(Path.Combine(distPath, "sandbox.js")); |
| 67 | + } |
| 68 | + else |
| 69 | + { |
| 70 | + context.Response.StatusCode = 404; |
| 71 | + await context.Response.WriteAsync("Only sandbox files are served on this port"); |
| 72 | + } |
| 73 | +}); |
| 74 | + |
| 75 | +// ── Host server (port 8080) ───────────────────────────────────────────────── |
| 76 | + |
| 77 | +// Block sandbox files on host port — they must come from the sandbox origin. |
| 78 | +app.Use(async (context, next) => |
| 79 | +{ |
| 80 | + if (context.Request.Path.Value is "/sandbox.html" or "/sandbox.js") |
| 81 | + { |
| 82 | + context.Response.StatusCode = 404; |
| 83 | + await context.Response.WriteAsync("Sandbox is served on a different port"); |
| 84 | + return; |
| 85 | + } |
| 86 | + |
| 87 | + await next(); |
| 88 | +}); |
| 89 | + |
| 90 | +// /api/servers — configuration required, no hardcoded fallback |
| 91 | +var servers = builder.Configuration.GetSection("Servers").Get<string[]>() |
| 92 | + ?? throw new InvalidOperationException("'Servers' configuration is required in appsettings.json"); |
| 93 | + |
| 94 | +app.MapGet("/api/servers", () => Results.Json(servers)); |
| 95 | + |
| 96 | +// Angular SPA static files |
| 97 | +app.UseStaticFiles(new StaticFileOptions |
| 98 | +{ |
| 99 | + FileProvider = new PhysicalFileProvider(distPath), |
| 100 | +}); |
| 101 | + |
| 102 | +// SPA fallback — serve index.html for unknown routes (client-side routing) |
| 103 | +app.MapFallback(async context => |
| 104 | +{ |
| 105 | + context.Response.ContentType = "text/html"; |
| 106 | + await context.Response.SendFileAsync(Path.Combine(distPath, "index.html")); |
| 107 | +}); |
| 108 | + |
| 109 | +Console.WriteLine($"Host server: http://localhost:{hostPort}"); |
| 110 | +Console.WriteLine($"Sandbox server: http://localhost:{sandboxPort}"); |
| 111 | +Console.WriteLine($"Frontend dist: {distPath}"); |
| 112 | +Console.WriteLine(); |
| 113 | + |
| 114 | +app.Run(); |
| 115 | + |
| 116 | +// ── CSP helpers ────────────────────────────────────────────────────────────── |
| 117 | + |
| 118 | +static string BuildCspHeader(McpUiResourceCsp? csp) |
| 119 | +{ |
| 120 | + var rd = string.Join(" ", SanitizeCspDomains(csp?.ResourceDomains)); |
| 121 | + var cd = string.Join(" ", SanitizeCspDomains(csp?.ConnectDomains)); |
| 122 | + var frame = csp?.FrameDomains?.Length > 0 |
| 123 | + ? $"frame-src {string.Join(" ", SanitizeCspDomains(csp.FrameDomains))}" |
| 124 | + : "frame-src 'none'"; |
| 125 | + var baseUri = csp?.BaseUriDomains?.Length > 0 |
| 126 | + ? $"base-uri {string.Join(" ", SanitizeCspDomains(csp.BaseUriDomains))}" |
| 127 | + : "base-uri 'none'"; |
| 128 | + |
| 129 | + return string.Join("; ", new[] |
| 130 | + { |
| 131 | + "default-src 'self' 'unsafe-inline'", |
| 132 | + $"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: {rd}".TrimEnd(), |
| 133 | + $"style-src 'self' 'unsafe-inline' blob: data: {rd}".TrimEnd(), |
| 134 | + $"img-src 'self' data: blob: {rd}".TrimEnd(), |
| 135 | + $"font-src 'self' data: blob: {rd}".TrimEnd(), |
| 136 | + $"media-src 'self' data: blob: {rd}".TrimEnd(), |
| 137 | + $"connect-src 'self' {cd}".TrimEnd(), |
| 138 | + $"worker-src 'self' blob: {rd}".TrimEnd(), |
| 139 | + frame, |
| 140 | + "object-src 'none'", |
| 141 | + baseUri, |
| 142 | + }); |
| 143 | +} |
| 144 | + |
| 145 | +static string[] SanitizeCspDomains(string[]? domains) => |
| 146 | + domains? |
| 147 | + .Where(d => !string.IsNullOrEmpty(d) |
| 148 | + && !d.Any(c => c is ';' or '\r' or '\n' or '\'' or '"' or ' ')) |
| 149 | + .ToArray() ?? []; |
| 150 | + |
| 151 | +record McpUiResourceCsp( |
| 152 | + [property: JsonPropertyName("resourceDomains")] string[]? ResourceDomains, |
| 153 | + [property: JsonPropertyName("connectDomains")] string[]? ConnectDomains, |
| 154 | + [property: JsonPropertyName("frameDomains")] string[]? FrameDomains, |
| 155 | + [property: JsonPropertyName("baseUriDomains")] string[]? BaseUriDomains |
| 156 | +); |
0 commit comments