Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dashboard/public/_headers
Original file line number Diff line number Diff line change
@@ -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=()
Expand Down
26 changes: 24 additions & 2 deletions apps/dashboard/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -22,14 +23,35 @@ const JSON_HEADERS = {
};

export default {
fetch(request: Request, env: Env, ctx: ExecutionContextLike): Promise<Response> {
return handleRequest(request, env, ctx);
async fetch(request: Request, env: Env, ctx: ExecutionContextLike): Promise<Response> {
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<Response> {
const url = new URL(request.url);
if (!url.pathname.startsWith("/api/")) return new Response("Not found", { status: 404 });
Expand Down
24 changes: 24 additions & 0 deletions apps/dashboard/worker/index.worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,20 @@ async function expectQuiet(socket: WebSocket, durationMs = 400): Promise<void> {
}

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);
Expand All @@ -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" }));
Expand Down
Loading