mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
refactor: replace custom HMAC CSRF with hono/csrf middleware
Removes 38-line hand-rolled HMAC-SHA256 implementation in favour of the built-in hono/csrf, which validates the Origin header natively. - Delete src/utils/csrf.ts - Replace custom CSRF middleware with hono/csrf (Origin-header check) - Remove csrfToken from ContextVariableMap, layout(), forms, and JS fetch() calls - Update admin tests: swap X-CSRF-Token for Origin header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+16
-16
@@ -3,20 +3,17 @@ import { Hono } from "hono";
|
|||||||
import app from "./admin";
|
import app from "./admin";
|
||||||
import { createMockEnv } from "../test/setup";
|
import { createMockEnv } from "../test/setup";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { generateCsrfToken } from "../utils/csrf";
|
|
||||||
|
|
||||||
describe("Admin Routes", () => {
|
describe("Admin Routes", () => {
|
||||||
let testApp: Hono;
|
let testApp: Hono;
|
||||||
let mockEnv: Env;
|
let mockEnv: Env;
|
||||||
let csrfToken: string;
|
|
||||||
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
||||||
let loginAndGetCookie: () => Promise<string>;
|
let loginAndGetCookie: () => Promise<string>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
mockEnv = createMockEnv() as unknown as Env;
|
mockEnv = createMockEnv() as unknown as Env;
|
||||||
testApp = new Hono();
|
testApp = new Hono();
|
||||||
testApp.route("/admin", app);
|
testApp.route("/admin", app);
|
||||||
csrfToken = await generateCsrfToken("test-password");
|
|
||||||
request = (path, init = {}) =>
|
request = (path, init = {}) =>
|
||||||
Promise.resolve(testApp.request(path, init, mockEnv));
|
Promise.resolve(testApp.request(path, init, mockEnv));
|
||||||
loginAndGetCookie = async () => {
|
loginAndGetCookie = async () => {
|
||||||
@@ -97,7 +94,7 @@ describe("Admin Routes", () => {
|
|||||||
const res = await request("/admin", {
|
const res = await request("/admin", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -143,7 +140,7 @@ describe("Admin Routes", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -180,7 +177,7 @@ describe("Admin Routes", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -201,7 +198,7 @@ describe("Admin Routes", () => {
|
|||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ title: "", description: "desc" }),
|
body: JSON.stringify({ title: "", description: "desc" }),
|
||||||
});
|
});
|
||||||
@@ -248,7 +245,7 @@ describe("Admin Routes", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -267,7 +264,7 @@ describe("Admin Routes", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,7 +297,7 @@ describe("Admin Routes", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -320,7 +317,7 @@ describe("Admin Routes", () => {
|
|||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -340,7 +337,10 @@ describe("Admin Routes", () => {
|
|||||||
formData.append("description", "Test");
|
formData.append("description", "Test");
|
||||||
const createRes = await request("/admin/feeds/create", {
|
const createRes = await request("/admin/feeds/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Cookie: authCookie, "X-CSRF-Token": csrfToken },
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
expect(createRes.status).toBe(302);
|
expect(createRes.status).toBe(302);
|
||||||
@@ -361,7 +361,7 @@ describe("Admin Routes", () => {
|
|||||||
|
|
||||||
const bulkDeleteRes = await request("/admin/feeds/bulk-delete", {
|
const bulkDeleteRes = await request("/admin/feeds/bulk-delete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Cookie: authCookie, "X-CSRF-Token": csrfToken },
|
headers: { Cookie: authCookie, Origin: "https://test.getmynews.app" },
|
||||||
body: bulkForm,
|
body: bulkForm,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -490,7 +490,7 @@ describe("Admin Routes", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -540,7 +540,7 @@ describe("Admin Routes", () => {
|
|||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"X-CSRF-Token": csrfToken,
|
Origin: "https://test.getmynews.app",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-61
@@ -15,7 +15,7 @@ import {
|
|||||||
import { generateFeedId } from "../utils/id-generator";
|
import { generateFeedId } from "../utils/id-generator";
|
||||||
import { designSystem } from "../styles/index";
|
import { designSystem } from "../styles/index";
|
||||||
import { interactiveScripts } from "../scripts/index";
|
import { interactiveScripts } from "../scripts/index";
|
||||||
import { generateCsrfToken, verifyCsrfToken } from "../utils/csrf";
|
import { csrf } from "hono/csrf";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
@@ -136,46 +136,15 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
|||||||
// Apply auth middleware to all admin routes
|
// Apply auth middleware to all admin routes
|
||||||
app.use("*", authMiddleware);
|
app.use("*", authMiddleware);
|
||||||
|
|
||||||
// CSRF middleware: generates token for GET requests and validates on mutating requests
|
// CSRF middleware: validates Origin header on mutating requests (POST/PUT/DELETE/PATCH)
|
||||||
app.use("*", async (c, next) => {
|
// 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;
|
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();
|
||||||
if (path === "/admin/login") {
|
return csrfMiddleware(c, next);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schema for feed creation
|
// Schema for feed creation
|
||||||
@@ -201,7 +170,7 @@ const authSchema = z.object({
|
|||||||
|
|
||||||
// Base HTML layout with design system
|
// Base HTML layout with design system
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const layout = (title: string, content: any, csrfToken = "") => {
|
const layout = (title: string, content: any) => {
|
||||||
return html`<!DOCTYPE html>
|
return html`<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -209,7 +178,6 @@ const layout = (title: string, content: any, csrfToken = "") => {
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<meta name="csrf-token" content="${csrfToken}" />
|
|
||||||
<style>
|
<style>
|
||||||
${raw(designSystem)}
|
${raw(designSystem)}
|
||||||
</style>
|
</style>
|
||||||
@@ -391,11 +359,6 @@ app.get("/", async (c) => {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Create New Feed</h2>
|
<h2>Create New Feed</h2>
|
||||||
<form action="/admin/feeds/create" method="post">
|
<form action="/admin/feeds/create" method="post">
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="_csrf"
|
|
||||||
value="${c.var.csrfToken ?? ""}"
|
|
||||||
/>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Feed Title</label>
|
<label for="title">Feed Title</label>
|
||||||
<input type="text" id="title" name="title" required />
|
<input type="text" id="title" name="title" required />
|
||||||
@@ -1262,7 +1225,6 @@ app.get("/", async (c) => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
@@ -1519,7 +1481,6 @@ app.get("/", async (c) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',
|
|
||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ feedIds: batch }),
|
body: JSON.stringify({ feedIds: batch }),
|
||||||
@@ -1569,7 +1530,6 @@ app.get("/", async (c) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',
|
|
||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ feedIds: [feedId] }),
|
body: JSON.stringify({ feedIds: [feedId] }),
|
||||||
@@ -1652,7 +1612,6 @@ app.get("/", async (c) => {
|
|||||||
`)};
|
`)};
|
||||||
</script>
|
</script>
|
||||||
`,
|
`,
|
||||||
c.var.csrfToken ?? "",
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1789,11 +1748,6 @@ app.get("/feeds/:feedId/edit", async (c) => {
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form action="/admin/feeds/${feedId}/edit" method="post">
|
<form action="/admin/feeds/${feedId}/edit" method="post">
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="_csrf"
|
|
||||||
value="${c.var.csrfToken ?? ""}"
|
|
||||||
/>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Feed Title</label>
|
<label for="title">Feed Title</label>
|
||||||
<input
|
<input
|
||||||
@@ -1838,7 +1792,6 @@ ${(feedConfig.allowed_senders || []).join("\n")}</textarea
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
c.var.csrfToken ?? "",
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -2833,7 +2786,6 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',
|
|
||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
@@ -3085,7 +3037,6 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ emailKeys: batch }),
|
body: JSON.stringify({ emailKeys: batch }),
|
||||||
@@ -3147,7 +3098,6 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
`)};
|
`)};
|
||||||
</script>
|
</script>
|
||||||
`,
|
`,
|
||||||
c.var.csrfToken ?? "",
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -3508,7 +3458,6 @@ ${emailData.content.replace(/</g, "<").replace(/>/g, ">")}</pre
|
|||||||
`)};
|
`)};
|
||||||
</script>
|
</script>
|
||||||
`,
|
`,
|
||||||
c.var.csrfToken ?? "",
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
-1
@@ -4,6 +4,5 @@ import { Env } from "./index";
|
|||||||
declare module "hono" {
|
declare module "hono" {
|
||||||
interface ContextVariableMap {
|
interface ContextVariableMap {
|
||||||
env: Env;
|
env: Env;
|
||||||
csrfToken: string;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
const BUCKET_MS = 10 * 60 * 1000; // 10-minute window
|
|
||||||
|
|
||||||
async function hmacHex(secret: string, message: string): Promise<string> {
|
|
||||||
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<string> {
|
|
||||||
const bucket = Math.floor(Date.now() / BUCKET_MS).toString();
|
|
||||||
return hmacHex(secret, bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyCsrfToken(
|
|
||||||
secret: string,
|
|
||||||
token: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user