diff --git a/apps/dashboard/public/_headers b/apps/dashboard/public/_headers index ada78f5..184f722 100644 --- a/apps/dashboard/public/_headers +++ b/apps/dashboard/public/_headers @@ -1,4 +1,5 @@ /* + Strict-Transport-Security: max-age=63072000; includeSubDomains X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() diff --git a/apps/dashboard/worker/index.ts b/apps/dashboard/worker/index.ts index 86c3af9..5309fbc 100644 --- a/apps/dashboard/worker/index.ts +++ b/apps/dashboard/worker/index.ts @@ -14,6 +14,7 @@ export { LiveRoom }; const MAX_BODY_BYTES = 512 * 1024; const MAX_FUTURE_RECORD_SKEW_MS = 60_000; +const STRICT_TRANSPORT_SECURITY = "max-age=63072000; includeSubDomains"; const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", @@ -22,14 +23,35 @@ const JSON_HEADERS = { }; export default { - fetch(request: Request, env: Env, ctx: ExecutionContextLike): Promise { - return handleRequest(request, env, ctx); + async fetch(request: Request, env: Env, ctx: ExecutionContextLike): Promise { + const url = new URL(request.url); + if (url.protocol !== "https:") { + url.protocol = "https:"; + return new Response(null, { + status: 308, + headers: { location: url.toString(), "cache-control": "no-store" } + }); + } + return withStrictTransportSecurity(await handleRequest(request, env, ctx)); }, scheduled(controller: ScheduledControllerLike, env: Env, ctx: ExecutionContextLike): void { ctx.waitUntil(runScheduled(controller, env)); } }; +function withStrictTransportSecurity(response: Response): Response { + const headers = new Headers(response.headers); + headers.set("strict-transport-security", STRICT_TRANSPORT_SECURITY); + if (response.status === 101 && response.webSocket) { + return new Response(null, { status: 101, headers, webSocket: response.webSocket }); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers + }); +} + export async function handleRequest(request: Request, env: Env, _ctx?: ExecutionContextLike): Promise { const url = new URL(request.url); if (!url.pathname.startsWith("/api/")) return new Response("Not found", { status: 404 }); diff --git a/apps/dashboard/worker/index.worker.test.ts b/apps/dashboard/worker/index.worker.test.ts index d5f94ff..0f04556 100644 --- a/apps/dashboard/worker/index.worker.test.ts +++ b/apps/dashboard/worker/index.worker.test.ts @@ -121,6 +121,20 @@ async function expectQuiet(socket: WebSocket, durationMs = 400): Promise { } describe("Worker API", () => { + it("redirects insecure API requests before handling and emits HSTS over HTTPS", async () => { + const insecure = await SELF.fetch("http://snapmeter.test/api/v1/metadata?probe=transport", { + redirect: "manual" + }); + expect(insecure.status).toBe(308); + expect(insecure.headers.get("location")).toBe("https://snapmeter.test/api/v1/metadata?probe=transport"); + expect(insecure.headers.get("cache-control")).toBe("no-store"); + expect(insecure.headers.get("strict-transport-security")).toBeNull(); + + const secure = await SELF.fetch("https://snapmeter.test/api/v1/metadata"); + expect(secure.status).toBe(200); + expect(secure.headers.get("strict-transport-security")).toBe("max-age=63072000; includeSubDomains"); + }); + it("reports pinned source metadata and disconnected status honestly", async () => { const metadata = await SELF.fetch("https://snapmeter.test/api/v1/metadata"); expect(metadata.status).toBe(200); @@ -139,6 +153,16 @@ describe("Worker API", () => { expect(response.status).toBe(403); }); + it("emits HSTS on successful WebSocket upgrades", async () => { + const response = await SELF.fetch("https://snapmeter.test/api/v1/live", { + headers: { Upgrade: "websocket" } + }); + expect(response.status).toBe(101); + expect(response.headers.get("strict-transport-security")).toBe("max-age=63072000; includeSubDomains"); + response.webSocket?.accept(); + response.webSocket?.close(1000, "done"); + }); + it("authenticates an empty doctor probe without storing it", async () => { const payload = batch(); const response = await SELF.fetch(await signedRequest(payload, { "x-snapmeter-doctor": "1" }));