diff --git a/package.json b/package.json index 93e8c8c..ff9ece9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.10", + "version": "1.1.11", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/server/app.ts b/server/app.ts index 3d9e27b..ce760ba 100644 --- a/server/app.ts +++ b/server/app.ts @@ -590,6 +590,13 @@ function conflict(database: DatabaseContext, id: string): never { } export async function buildApp(database: DatabaseContext, config: AppConfig) { + // Helmet's defaults include `upgrade-insecure-requests`, HSTS, COOP and + // Origin-Agent-Cluster. Those headers are appropriate for HTTPS, but an + // explicitly opted-in HTTP deployment must remain HTTP all the way through + // the asset graph; otherwise browsers upgrade `/assets/*` to HTTPS and the + // plain HTTP listener appears as a blank page. Keep the transport-sensitive + // headers protocol-aware while retaining the other hardening headers. + const secureOrigin = config.publicOrigin.startsWith("https:"); const app = Fastify({ logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false, // Fastify's runtime accepts a numeric hop count, while its v5 typings do @@ -604,10 +611,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { await app.register(cookie); await app.register(helmet, { - ...(config.isLocalOrigin ? { hsts: false } : {}), + ...(!secureOrigin || config.isLocalOrigin ? { hsts: false } : {}), frameguard: { action: "deny" }, referrerPolicy: { policy: "no-referrer" }, - crossOriginOpenerPolicy: { policy: "same-origin" }, + ...(secureOrigin ? { crossOriginOpenerPolicy: { policy: "same-origin" }, originAgentCluster: true } : { crossOriginOpenerPolicy: false, originAgentCluster: false }), crossOriginResourcePolicy: { policy: "same-origin" }, contentSecurityPolicy: { directives: { @@ -618,7 +625,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { "frame-ancestors": ["'none'"], "base-uri": ["'none'"], "form-action": ["'self'"], - ...(config.isLocalOrigin ? { "upgrade-insecure-requests": null } : {}), + ...(!secureOrigin ? { "upgrade-insecure-requests": null } : {}), }, }, }); diff --git a/tests/api.test.ts b/tests/api.test.ts index 576c980..af10956 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -87,6 +87,48 @@ describe("TallyNote API", () => { expect(missing.json().error.requestId).toBeTruthy(); }); + it("显式允许的公网 HTTP 不会把静态资源升级到 HTTPS", async () => { + const publicHttpConfig = { + ...config, + publicOrigin: "http://192.0.2.10:3999", + isLocalOrigin: false, + allowInsecureHttp: true, + cookieSecure: false, + }; + const publicHttpApp = await buildApp(database, publicHttpConfig); + try { + const response = await publicHttpApp.inject({ method: "GET", url: "/health" }); + expect(response.statusCode).toBe(200); + expect(response.headers["content-security-policy"]).not.toContain("upgrade-insecure-requests"); + expect(response.headers["strict-transport-security"]).toBeUndefined(); + expect(response.headers["cross-origin-opener-policy"]).toBeUndefined(); + expect(response.headers["origin-agent-cluster"]).toBeUndefined(); + } finally { + await publicHttpApp.close(); + } + }); + + it("HTTPS 仍保留传输安全响应头", async () => { + const secureConfig = { + ...config, + publicOrigin: "https://example.test:3999", + isLocalOrigin: false, + allowInsecureHttp: false, + cookieSecure: true, + }; + const secureApp = await buildApp(database, secureConfig); + try { + const response = await secureApp.inject({ method: "GET", url: "/health" }); + expect(response.statusCode).toBe(200); + expect(response.headers["content-security-policy"]).toContain("upgrade-insecure-requests"); + expect(response.headers["strict-transport-security"]).toContain("max-age="); + expect(response.headers["cross-origin-opener-policy"]).toBe("same-origin"); + expect(response.headers["origin-agent-cluster"]).toBe("?1"); + } finally { + await secureApp.close(); + } + }); + it("拒绝没有 Origin 的写请求", async () => { const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } }); expect(response.statusCode).toBe(403);