diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index 5bb97c2..d7a849b 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -3,20 +3,17 @@ import { Hono } from "hono"; import app from "./admin"; import { createMockEnv } from "../test/setup"; import { Env } from "../types"; -import { generateCsrfToken } from "../utils/csrf"; describe("Admin Routes", () => { let testApp: Hono; let mockEnv: Env; - let csrfToken: string; let request: (path: string, init?: RequestInit) => Promise; let loginAndGetCookie: () => Promise; - beforeEach(async () => { + beforeEach(() => { mockEnv = createMockEnv() as unknown as Env; testApp = new Hono(); testApp.route("/admin", app); - csrfToken = await generateCsrfToken("test-password"); request = (path, init = {}) => Promise.resolve(testApp.request(path, init, mockEnv)); loginAndGetCookie = async () => { @@ -97,7 +94,7 @@ describe("Admin Routes", () => { const res = await request("/admin", { headers: { Cookie: authCookie, - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, }); expect(res.status).toBe(200); @@ -143,7 +140,7 @@ describe("Admin Routes", () => { method: "POST", headers: { Cookie: authCookie, - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, body: formData, }); @@ -180,7 +177,7 @@ describe("Admin Routes", () => { method: "POST", headers: { Cookie: authCookie, - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, body: formData, }); @@ -201,7 +198,7 @@ describe("Admin Routes", () => { headers: { Cookie: authCookie, "Content-Type": "application/json", - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, body: JSON.stringify({ title: "", description: "desc" }), }); @@ -248,7 +245,7 @@ describe("Admin Routes", () => { method: "POST", headers: { Cookie: authCookie, - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, body: formData, }); @@ -267,7 +264,7 @@ describe("Admin Routes", () => { method: "POST", headers: { Cookie: authCookie, - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, }); @@ -300,7 +297,7 @@ describe("Admin Routes", () => { method: "POST", headers: { Cookie: authCookie, - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, body: formData, }); @@ -320,7 +317,7 @@ describe("Admin Routes", () => { headers: { Cookie: authCookie, Accept: "application/json", - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, }, ); @@ -340,7 +337,10 @@ describe("Admin Routes", () => { formData.append("description", "Test"); const createRes = await request("/admin/feeds/create", { method: "POST", - headers: { Cookie: authCookie, "X-CSRF-Token": csrfToken }, + headers: { + Cookie: authCookie, + Origin: "https://test.getmynews.app", + }, body: formData, }); expect(createRes.status).toBe(302); @@ -361,7 +361,7 @@ describe("Admin Routes", () => { const bulkDeleteRes = await request("/admin/feeds/bulk-delete", { method: "POST", - headers: { Cookie: authCookie, "X-CSRF-Token": csrfToken }, + headers: { Cookie: authCookie, Origin: "https://test.getmynews.app" }, body: bulkForm, }); @@ -490,7 +490,7 @@ describe("Admin Routes", () => { method: "POST", headers: { Cookie: authCookie, - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, body: formData, }); @@ -540,7 +540,7 @@ describe("Admin Routes", () => { headers: { Cookie: authCookie, Accept: "application/json", - "X-CSRF-Token": csrfToken, + Origin: "https://test.getmynews.app", }, }, ); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 37becfb..7e76cb3 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -15,7 +15,7 @@ import { import { generateFeedId } from "../utils/id-generator"; import { designSystem } from "../styles/index"; import { interactiveScripts } from "../scripts/index"; -import { generateCsrfToken, verifyCsrfToken } from "../utils/csrf"; +import { csrf } from "hono/csrf"; type AppEnv = { Bindings: Env }; @@ -136,46 +136,15 @@ async function authMiddleware(c: Context, next: () => Promise) { // Apply auth middleware to all admin routes app.use("*", authMiddleware); -// CSRF middleware: generates token for GET requests and validates on mutating requests -app.use("*", async (c, next) => { +// CSRF middleware: validates Origin header on mutating requests (POST/PUT/DELETE/PATCH) +// Skip on /admin/login — password itself provides protection for the pre-auth route +const csrfMiddleware = csrf({ + origin: (origin, c) => origin === `https://${c.env.DOMAIN}`, +}); +app.use("*", (c, next) => { const path = new URL(c.req.url).pathname; - // Login route is pre-auth, so CSRF doesn't apply there - if (path === "/admin/login") { - return next(); - } - - const token = await generateCsrfToken(c.env.ADMIN_PASSWORD); - c.set("csrfToken", token); - - if ( - c.req.method === "POST" || - c.req.method === "PUT" || - c.req.method === "DELETE" - ) { - // Accept token from X-CSRF-Token header (JS fetch calls) - const headerToken = c.req.header("X-CSRF-Token") ?? ""; - if (headerToken) { - if (!(await verifyCsrfToken(c.env.ADMIN_PASSWORD, headerToken))) { - return c.text("Invalid CSRF token", 403); - } - return next(); - } - - // For HTML form submissions: clone the request body to read _csrf without consuming the stream - const contentType = c.req.header("Content-Type") ?? ""; - if ( - contentType.includes("application/x-www-form-urlencoded") || - contentType.includes("multipart/form-data") - ) { - const form = await c.req.raw.clone().formData(); - const formToken = form.get("_csrf")?.toString() ?? ""; - if (!(await verifyCsrfToken(c.env.ADMIN_PASSWORD, formToken))) { - return c.text("Invalid CSRF token", 403); - } - } - } - - return next(); + if (path === "/admin/login") return next(); + return csrfMiddleware(c, next); }); // Schema for feed creation @@ -201,7 +170,7 @@ const authSchema = z.object({ // Base HTML layout with design system // eslint-disable-next-line @typescript-eslint/no-explicit-any -const layout = (title: string, content: any, csrfToken = "") => { +const layout = (title: string, content: any) => { return html` @@ -209,7 +178,6 @@ const layout = (title: string, content: any, csrfToken = "") => { - @@ -391,11 +359,6 @@ app.get("/", async (c) => {

Create New Feed

-
@@ -1262,7 +1225,6 @@ app.get("/", async (c) => { method: 'POST', headers: { 'Accept': 'application/json', - 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '', }, credentials: 'same-origin', }); @@ -1519,7 +1481,6 @@ app.get("/", async (c) => { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', - ''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '', }, credentials: 'same-origin', body: JSON.stringify({ feedIds: batch }), @@ -1569,7 +1530,6 @@ app.get("/", async (c) => { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', - ''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '', }, credentials: 'same-origin', body: JSON.stringify({ feedIds: [feedId] }), @@ -1652,7 +1612,6 @@ app.get("/", async (c) => { `)}; `, - c.var.csrfToken ?? "", ), ); }); @@ -1789,11 +1748,6 @@ app.get("/feeds/:feedId/edit", async (c) => {
-
`, - c.var.csrfToken ?? "", ), ); }); @@ -2833,7 +2786,6 @@ app.get("/feeds/:feedId/emails", async (c) => { method: 'POST', headers: { 'Accept': 'application/json', - ''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '', }, credentials: 'same-origin', }); @@ -3085,7 +3037,6 @@ app.get("/feeds/:feedId/emails", async (c) => { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', - 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '', }, credentials: 'same-origin', body: JSON.stringify({ emailKeys: batch }), @@ -3147,7 +3098,6 @@ app.get("/feeds/:feedId/emails", async (c) => { `)}; `, - c.var.csrfToken ?? "", ), ); }); @@ -3508,7 +3458,6 @@ ${emailData.content.replace(//g, ">")} `, - c.var.csrfToken ?? "", ), ); }); diff --git a/src/types/hono.d.ts b/src/types/hono.d.ts index 11365ec..3e87135 100644 --- a/src/types/hono.d.ts +++ b/src/types/hono.d.ts @@ -4,6 +4,5 @@ import { Env } from "./index"; declare module "hono" { interface ContextVariableMap { env: Env; - csrfToken: string; } } diff --git a/src/utils/csrf.ts b/src/utils/csrf.ts deleted file mode 100644 index 23eebaa..0000000 --- a/src/utils/csrf.ts +++ /dev/null @@ -1,38 +0,0 @@ -const BUCKET_MS = 10 * 60 * 1000; // 10-minute window - -async function hmacHex(secret: string, message: string): Promise { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(message), - ); - return Array.from(new Uint8Array(sig)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -export async function generateCsrfToken(secret: string): Promise { - const bucket = Math.floor(Date.now() / BUCKET_MS).toString(); - return hmacHex(secret, bucket); -} - -export async function verifyCsrfToken( - secret: string, - token: string, -): Promise { - if (!token) return false; - const now = Math.floor(Date.now() / BUCKET_MS); - // Accept current and previous bucket to handle boundary cases - for (const bucket of [now, now - 1]) { - const expected = await hmacHex(secret, bucket.toString()); - if (token === expected) return true; - } - return false; -}