From d68f857a0efa754ff01cb0a2881ba4dcd5100a3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 14:28:48 +0000 Subject: [PATCH] Send HSTS for HTTPS production web responses only Add Strict-Transport-Security: max-age=31536000 on hosted web responses that arrive over HTTPS. Omit includeSubDomains and preload. Desktop and HTTP remain unchanged, and the Content-Security-Policy is not modified. Co-authored-by: Patrick --- server/src/app.ts | 19 +++++++++++++ server/tests/hsts.test.ts | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 server/tests/hsts.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 8a9ce010..37371d0a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -84,6 +84,24 @@ function shouldSendRobotsTag(pathname: string, mode: string) { return mode === "web" && (pathname === "/index.html" || pathname === "/app" || pathname === "/spotify-callback"); } +function requestIsHttps(req: FastifyRequest) { + if (req.protocol === "https") return true; + const forwarded = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim().toLowerCase(); + return forwarded === "https"; +} + +function publicOriginIsHttps(publicOrigin: string) { + try { + return new URL(publicOrigin).protocol === "https:"; + } catch { + return false; + } +} + +function shouldSendHsts(req: FastifyRequest, mode: string, publicOrigin: string) { + return mode === "web" && publicOriginIsHttps(publicOrigin) && requestIsHttps(req); +} + function requestOriginAllowed(req: FastifyRequest, publicOrigin: string) { const originHeader = String(req.headers.origin || ""); if (!originHeader) return true; @@ -136,6 +154,7 @@ export async function createApp(overrides: CreateAppOverrides = {}) { app.decorateRequest("principal", null); app.addHook("onSend", async (req, reply, payload) => { for (const [name, value] of Object.entries(securityHeaders)) reply.header(name, value); + if (shouldSendHsts(req, cfg.mode, cfg.publicOrigin)) reply.header("strict-transport-security", "max-age=31536000"); const pathname = requestPathname(req.url); if (pathname === "/") reply.header("link", homepageLinks); const cacheControl = publicCacheControl(publicCacheClass(req.url)); diff --git a/server/tests/hsts.test.ts b/server/tests/hsts.test.ts new file mode 100644 index 00000000..2019da45 --- /dev/null +++ b/server/tests/hsts.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createApp } from "../src/app.js"; +import { fixtureDatabase, root } from "./helpers.js"; + +const cspDefaultSrc = /default-src 'self'/; + +test("HSTS is sent only for HTTPS production web responses", async () => { + const productionFixture = fixtureDatabase(); + const desktopFixture = fixtureDatabase(); + const httpOriginFixture = fixtureDatabase(); + const production = await createApp({ + databasePath: productionFixture.filename, root, allowTestAuth: true, legacyUpstream: "", + mode: "web", publicOrigin: "https://journeydeck.me" + }); + const desktop = await createApp({ + databasePath: desktopFixture.filename, root, allowTestAuth: true, legacyUpstream: "", + mode: "desktop", publicOrigin: "https://journeydeck.me" + }); + const httpOrigin = await createApp({ + databasePath: httpOriginFixture.filename, root, allowTestAuth: true, legacyUpstream: "", + mode: "web", publicOrigin: "http://127.0.0.1:8791" + }); + try { + const httpsProduction = await production.app.inject({ + method: "GET", url: "/", headers: { "x-forwarded-proto": "https" } + }); + assert.equal(httpsProduction.statusCode, 200, httpsProduction.body); + assert.equal(httpsProduction.headers["strict-transport-security"], "max-age=31536000"); + assert.doesNotMatch(String(httpsProduction.headers["strict-transport-security"]), /includeSubDomains|preload/i); + assert.match(String(httpsProduction.headers["content-security-policy"]), cspDefaultSrc); + + const httpProduction = await production.app.inject({ method: "GET", url: "/" }); + assert.equal(httpProduction.statusCode, 200, httpProduction.body); + assert.equal(httpProduction.headers["strict-transport-security"], undefined); + assert.match(String(httpProduction.headers["content-security-policy"]), cspDefaultSrc); + + const httpsDesktop = await desktop.app.inject({ + method: "GET", url: "/privacy", headers: { "x-forwarded-proto": "https" } + }); + assert.equal(httpsDesktop.statusCode, 200, httpsDesktop.body); + assert.equal(httpsDesktop.headers["strict-transport-security"], undefined); + + const httpsHttpOrigin = await httpOrigin.app.inject({ + method: "GET", url: "/", headers: { "x-forwarded-proto": "https" } + }); + assert.equal(httpsHttpOrigin.statusCode, 200, httpsHttpOrigin.body); + assert.equal(httpsHttpOrigin.headers["strict-transport-security"], undefined); + } finally { + await production.app.close(); + await desktop.app.close(); + await httpOrigin.app.close(); + productionFixture.cleanup(); + desktopFixture.cleanup(); + httpOriginFixture.cleanup(); + } +});