diff --git a/README.md b/README.md index 62c8aae..54683d6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ |---|---| | 协议、API、威胁模型 | 已定稿(`docs/`) | | `test-vectors/` | 已完成。由 `node:crypto` 独立生成,服务端(.NET)和 `@webdrop/core`(WebCrypto)各自复现 | -| `server/` | 频道与便捷分享 API 已实现并通过 132 个测试;win-x64 与 linux-x64 NativeAOT 发布此前已验证零警告 | +| `server/` | 频道与便捷分享 API 已实现;含独立的可选 `WebDrop.Frontend` 静态资源模块;NativeAOT 发布 | | `packages/core/` | 已完成并验证。168 个测试,含对着真实服务端的端到端 | | `web/` | 前端,开发中 | @@ -45,6 +45,6 @@ webdrop-proj/ ├─ docs/ ├─ test-vectors/ 协议测试向量,所有实现共用 ├─ packages/core/ @webdrop/core,TS,加解密与帧编解码(网页、将来的 CLI 共用) -├─ server/ .NET 10,NativeAOT +├─ server/ .NET 10,NativeAOT;可将 Vite dist 嵌入 WebDrop.Frontend └─ web/ Vite + React + TypeScript ``` diff --git a/docs/architecture.md b/docs/architecture.md index 4335306..3378485 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ ## 1. 组件 ``` -浏览器 (React, 独立域名 + SRI) 服务端 (.NET 10, NativeAOT) +浏览器 (React, 独立托管或 API 同源嵌入) 服务端 (.NET 10, NativeAOT) ┌──────────────────────────────┐ ┌─────────────────────────────┐ │ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │ │ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │ @@ -15,7 +15,7 @@ (元数据、日志) (磁盘,预留 S3) ``` -服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。 +服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 `WebDrop.Frontend` 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。 ## 2. 技术栈 @@ -84,8 +84,9 @@ SQLite 存元数据和小帧,磁盘存大帧。 ## 5. 部署 -- API 与前端使用**不同域名**:前端静态资源可以放在 CDN 或独立静态主机,API 被攻破不影响前端。 -- 前端构建产物为所有脚本和样式生成 SRI,发布时公布构建哈希。 +- 安全隔离部署时,API 与前端使用**不同域名**:前端静态资源可以放在独立静态主机,API 被攻破不影响前端代码。 +- Compose 单机模式可以把 Vite dist 嵌入 `WebDrop.Frontend`,由 API 同源提供页面;此模式简化部署,但 API 主机也是前端代码的信任根。 +- 独立托管时,前端资源应使用 HTTPS 发布;若部署 SRI,发布时公布构建哈希。 - 响应头:`Referrer-Policy: no-referrer`、严格 CSP、`X-Content-Type-Options: nosniff`。 - 反向代理不得记录请求头(令牌)和完整 URL。 - 服务端日志不含频道 id 明文,只含截断后的哈希。 diff --git a/docs/deployment.md b/docs/deployment.md index 6e7cc45..93e5dda 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # 本地后端 Docker Compose -Compose 当前只启动 WebDrop API。前端继续使用现有的开发/独立托管方式;本地 Vite 开发服务器通过 `/api/v1` 代理访问 `127.0.0.1:5000`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。 +Compose 当前只启动一个 WebDrop API 容器;构建镜像时会先构建 `web/`,再由独立的 `WebDrop.Frontend` 模块将静态资源嵌入后端。访问 `http://127.0.0.1:5000/` 打开前端,API 使用同源 `/api/v1`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。 ## 启动 @@ -15,10 +15,12 @@ API 地址为 `http://127.0.0.1:5000`。停止服务使用 `docker compose down` ## 前端连接 -本机 `npm run dev` 使用 Vite 代理连接默认 API。独立部署前端时,在前端托管层把 `/api/v1` 代理到此 API 地址,这样无需开放 CORS。也可以为跨域托管配置 `WebDrop__CorsOrigins`,值应为明确的前端源列表。 +本机开发仍可使用 `cd web; npm run dev`,Vite 会代理 `/api/v1` 到本地 API。独立托管前端时,可单独构建 `web/` 并在前端托管层反向代理 `/api/v1`;跨域直连则配置明确的 `WebDrop__CorsOrigins`。 公网部署时,在 API 前配置 HTTPS 反向代理,并让代理连接 API 的 loopback 端口。不要把未加密 HTTP 直接暴露到公网。只有当该 API 端口仅能由可信代理访问时,才将 `WEBDROP_TRUST_FORWARDED_HEADERS` 设为 `true`。 +嵌入模式减少自托管组件,但前端代码与 API 来自同一个部署信任域。若要让 API 节点被攻破时仍不能替换前端,应使用独立域名托管前端。 + ## 验证码 默认 `CAPTCHA_PROVIDER=none`。启用 Cap 时,在 `.env` 设置: diff --git a/docs/threat-model.md b/docs/threat-model.md index 3540a5b..b849552 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -62,10 +62,13 @@ 端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。 -第一版的缓解: -- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端。 -- 所有脚本使用 SRI,构建产物带版本哈希,发布时公布哈希。 -- 前端托管方被攻破的风险仍然存在。 +隔离部署的缓解: +- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端代码。 +- 可由独立托管方校验脚本 SRI 与构建哈希;前端托管方被攻破的风险仍然存在。 + +嵌入式部署的取舍: +- `WebDrop.Frontend` 将 Vite dist 嵌入后端程序集,让 API 以同源方式提供页面。 +- 这只简化部署,不增强前端与后端的隔离。若部署主机或镜像被控制,攻击者仍可替换页面并窃取用户后续输入的密钥。 后续:发布签名加 PWA 校验、CLI 客户端(协议不依赖浏览器,第三方可自行实现)。 diff --git a/server/Dockerfile b/server/Dockerfile index d971db7..12bbd19 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,3 +1,12 @@ +FROM node:24.21.0-alpine3.23 AS frontend +WORKDIR /src +COPY web/package.json web/package-lock.json ./web/ +COPY packages/core/package.json ./packages/core/package.json +RUN cd web && npm ci --no-audit --no-fund +COPY packages/core/ ./packages/core/ +COPY web/ ./web/ +RUN npm run build --prefix web + FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src RUN apt-get update \ @@ -6,10 +15,12 @@ RUN apt-get update \ COPY server/Directory.Build.props ./server/ COPY server/src/WebDrop.Api/WebDrop.Api.csproj ./server/src/WebDrop.Api/ COPY server/src/WebDrop.Core/WebDrop.Core.csproj ./server/src/WebDrop.Core/ +COPY server/src/WebDrop.Frontend/WebDrop.Frontend.csproj ./server/src/WebDrop.Frontend/ COPY server/src/WebDrop.Storage/WebDrop.Storage.csproj ./server/src/WebDrop.Storage/ RUN dotnet restore server/src/WebDrop.Api/WebDrop.Api.csproj \ --runtime linux-x64 COPY server/ ./server/ +COPY --from=frontend /src/web/dist/ ./web/dist/ RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \ --configuration Release \ --runtime linux-x64 \ diff --git a/server/README.md b/server/README.md index 47d8548..401ac85 100644 --- a/server/README.md +++ b/server/README.md @@ -8,6 +8,7 @@ |---|---| | `src/WebDrop.Core` | 领域类型、规则和业务逻辑(`ChannelService`、`ShareService`),不做 IO,只依赖抽象 | | `src/WebDrop.Storage` | SQLite 存储(`SqliteChannelStore`)和磁盘 blob(`DiskBlobStore`) | +| `src/WebDrop.Frontend` | 可选嵌入 Vite dist,提供静态文件、SPA fallback 与页面 CSP;不依赖业务层 | | `src/WebDrop.Api` | Minimal API、WebSocket / SSE / 长轮询、订阅枢纽、后台清理、限流、安全头 | | `tests/` | `Core.Tests`(单元与服务层,真实 SQLite)、`Api.Tests`(真实 Kestrel 上的集成测试) | @@ -16,6 +17,7 @@ ```powershell dotnet test # 全部测试 dotnet run --project src/WebDrop.Api # 开发运行,默认 http://localhost:5000 +npm --prefix ../web run build # 先构建前端时,dotnet publish 会自动嵌入 web/dist # NativeAOT 发布(Windows 需要 vswhere.exe 在 PATH 里) $env:PATH += ";${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" @@ -47,6 +49,7 @@ python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用 - 单实例部署:订阅枢纽在进程内,多实例需要换成外部发布订阅。 - 日志不记录请求头、请求体、完整 URL;Hosting 诊断日志已关闭,避免频道 id 进入日志。 - NativeAOT:不用 SignalR、EF Core、MVC、反射式 JSON;所有 AOT / 裁剪警告视为错误。 +- `WebDrop.Frontend` 只负责嵌入静态资源与 SPA fallback。Dockerfile 自动构建 `web/`;单独发布 API 时,只有 `web/dist` 存在或指定 `WebDropFrontendDist` 后才包含前端。 ## 验证码(Cap) diff --git a/server/WebDrop.slnx b/server/WebDrop.slnx index 527c2e2..f65ed20 100644 --- a/server/WebDrop.slnx +++ b/server/WebDrop.slnx @@ -2,6 +2,7 @@ + diff --git a/server/src/WebDrop.Api/WebDrop.Api.csproj b/server/src/WebDrop.Api/WebDrop.Api.csproj index 53d91f6..01be5fe 100644 --- a/server/src/WebDrop.Api/WebDrop.Api.csproj +++ b/server/src/WebDrop.Api/WebDrop.Api.csproj @@ -11,6 +11,7 @@ + diff --git a/server/src/WebDrop.Api/WebDropApp.cs b/server/src/WebDrop.Api/WebDropApp.cs index 5db4c16..086301c 100644 --- a/server/src/WebDrop.Api/WebDropApp.cs +++ b/server/src/WebDrop.Api/WebDropApp.cs @@ -1,5 +1,6 @@ using System.Threading.RateLimiting; using WebDrop.Core; +using WebDrop.Frontend; using WebDrop.Storage; namespace WebDrop.Api; @@ -80,6 +81,7 @@ public static class WebDropApp if (options.CorsOrigins.Length > 0) app.UseCors(); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat }); app.UseRateLimiter(); + EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint); app.MapApi(options, app.Services.GetRequiredService()); return app; } @@ -114,7 +116,7 @@ public static class WebDropApp QueueLimit = 0, }; - /// API 不提供任何页面。禁用缓存、禁止嗅探、不发送 Referer、不允许被嵌入。 + /// API 默认禁止缓存、嗅探和嵌入;嵌入式前端的页面策略由静态模块单独设置。 private static Task SecurityHeaders(HttpContext ctx, RequestDelegate next) { var h = ctx.Response.Headers; diff --git a/server/src/WebDrop.Frontend/EmbeddedFrontend.cs b/server/src/WebDrop.Frontend/EmbeddedFrontend.cs new file mode 100644 index 0000000..badefd8 --- /dev/null +++ b/server/src/WebDrop.Frontend/EmbeddedFrontend.cs @@ -0,0 +1,138 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace WebDrop.Frontend; + +/// 在同源模式下,从本程序集资源提供 Vite 前端。 +public static class EmbeddedFrontend +{ + private const string ResourcePrefix = "WebDrop.Frontend.Assets/"; + + public static void Use(IApplicationBuilder app, string? extraConnectOrigin = null) + { + var assembly = typeof(EmbeddedFrontend).Assembly; + var resources = assembly.GetManifestResourceNames() + .Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal)) + .ToDictionary( + name => name[ResourcePrefix.Length..].Replace('\\', '/'), + StringComparer.Ordinal); + + if (!resources.ContainsKey("index.html")) return; + + var contentSecurityPolicy = BuildContentSecurityPolicy(extraConnectOrigin); + app.Use((HttpContext context, RequestDelegate next) => + HandleAsync(context, next, assembly, resources, contentSecurityPolicy)); + } + + private static async Task HandleAsync( + HttpContext context, + RequestDelegate next, + System.Reflection.Assembly assembly, + IReadOnlyDictionary resources, + string contentSecurityPolicy) + { + if (!(HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method))) + { + await next(context); + return; + } + + var normalized = NormalizePath(context.Request.Path.Value); + if (normalized is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + if (IsApiPath(normalized)) + { + await next(context); + return; + } + + if (resources.TryGetValue(normalized, out var resourceName)) + { + await SendAsync(context, assembly, resources, normalized, contentSecurityPolicy, resourceName); + return; + } + + // A missing Vite asset must stay a 404. Other unknown paths are client-side routes. + if (normalized.StartsWith("assets/", StringComparison.Ordinal) || normalized.Equals("favicon.ico", StringComparison.OrdinalIgnoreCase)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + await SendAsync(context, assembly, resources, "index.html", contentSecurityPolicy); + } + + private static async Task SendAsync( + HttpContext context, + System.Reflection.Assembly assembly, + IReadOnlyDictionary resources, + string path, + string contentSecurityPolicy, + string? resourceName = null) + { + resourceName ??= resources[path]; + await using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = ContentType(path); + context.Response.Headers["Cache-Control"] = path.StartsWith("assets/", StringComparison.Ordinal) + ? "public, max-age=31536000, immutable" + : "no-store"; + + if (path.Equals("index.html", StringComparison.Ordinal)) + context.Response.Headers["Content-Security-Policy"] = contentSecurityPolicy; + + if (!HttpMethods.IsHead(context.Request.Method)) + await stream.CopyToAsync(context.Response.Body, context.RequestAborted); + } + + private static string? NormalizePath(string? path) + { + if (string.IsNullOrEmpty(path)) return "index.html"; + var normalized = path.Replace('\\', '/').Trim('/'); + if (normalized.Length == 0) return "index.html"; + if (normalized.Contains('\0') || normalized.Split('/').Any(segment => segment is ".." or "." or "")) + return null; + return normalized; + } + + private static bool IsApiPath(string path) => + path.Equals("api", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("api/", StringComparison.OrdinalIgnoreCase); + + private static string ContentType(string path) => Path.GetExtension(path).ToLowerInvariant() switch + { + ".html" => "text/html; charset=utf-8", + ".js" => "text/javascript; charset=utf-8", + ".css" => "text/css; charset=utf-8", + ".json" => "application/json; charset=utf-8", + ".svg" => "image/svg+xml", + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".webp" => "image/webp", + ".ico" => "image/x-icon", + ".woff2" => "font/woff2", + ".wasm" => "application/wasm", + _ => "application/octet-stream", + }; + + private static string BuildContentSecurityPolicy(string? extraConnectOrigin) + { + var connectSources = new List { "'self'", "ws:", "wss:" }; + if (Uri.TryCreate(extraConnectOrigin, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + connectSources.Add(uri.GetLeftPart(UriPartial.Authority)); + + return "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; " + + "form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; worker-src 'self' blob:; " + + $"connect-src {string.Join(' ', connectSources)}"; + } +} diff --git a/server/src/WebDrop.Frontend/README.md b/server/src/WebDrop.Frontend/README.md new file mode 100644 index 0000000..152c766 --- /dev/null +++ b/server/src/WebDrop.Frontend/README.md @@ -0,0 +1,22 @@ +# WebDrop.Frontend + +独立的 ASP.NET Core 模块,负责提供嵌入程序集的 Vite 前端资源。 + +## 构建 + +如果 `web/dist/index.html` 存在,项目会将 `web/dist` 作为 embedded resources 编入 `WebDrop.Frontend.dll`。也可通过 MSBuild 属性指定资源目录: + +```powershell +dotnet publish ..\WebDrop.Api\WebDrop.Api.csproj -p:WebDropFrontendDist=C:\build\web-dist +``` + +Dockerfile 会构建 `web/`,再把 dist 传给 .NET 发布阶段。`EmbeddedFrontend.Map` 只映射页面与静态文件,不依赖 `WebDrop.Core` 或存储层。 + +开发时可以不构建 dist,此时模块不映射页面路由;使用 `npm run dev` 配合 Vite API 代理。 + +## 路由与响应 + +- `/` 和未知的前端路由返回 `index.html`,支持 React SPA 深层链接。 +- `/assets/*` 从程序集资源读取;缺失资源和 `/api/*` 返回 404。 +- 页面使用同源 CSP,并允许配置的 Cap API origin、WebSocket 与 Worker。 +- `index.html` 不缓存;Vite `assets/` 使用 immutable 缓存。 diff --git a/server/src/WebDrop.Frontend/WebDrop.Frontend.csproj b/server/src/WebDrop.Frontend/WebDrop.Frontend.csproj new file mode 100644 index 0000000..6ef9648 --- /dev/null +++ b/server/src/WebDrop.Frontend/WebDrop.Frontend.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + WebDrop.Frontend + WebDrop.Frontend + $(MSBuildProjectDirectory)/../../../web/dist + + + + + + + + + WebDrop.Frontend.Assets/%(RecursiveDir)%(Filename)%(Extension) + + + +