209 lines
6.3 KiB
TypeScript
209 lines
6.3 KiB
TypeScript
|
|
import {
|
||
|
|
assertEquals,
|
||
|
|
assertStringIncludes,
|
||
|
|
} from "https://deno.land/std@0.224.0/assert/mod.ts";
|
||
|
|
import { Hono } from "hono";
|
||
|
|
import { csrfMiddleware, generateCsrfToken, CSRF_COOKIE_NAME } from "../../server/csrf.ts";
|
||
|
|
|
||
|
|
Deno.test("CSRF - generateCsrfToken returns token and cookie", async () => {
|
||
|
|
const { token, cookie } = await generateCsrfToken();
|
||
|
|
assertEquals(typeof token, "string");
|
||
|
|
assertEquals(token.includes("."), true);
|
||
|
|
assertEquals(cookie.includes(CSRF_COOKIE_NAME), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - token has UUID.signature format", async () => {
|
||
|
|
const { token } = await generateCsrfToken();
|
||
|
|
const parts = token.split(".");
|
||
|
|
assertEquals(parts.length, 2);
|
||
|
|
// UUID is 36 chars
|
||
|
|
assertEquals(parts[0].length, 36);
|
||
|
|
// Signature is a hex string (64 chars for SHA-256)
|
||
|
|
assertEquals(parts[1].length, 64);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - cookie contains correct attributes", async () => {
|
||
|
|
const { cookie } = await generateCsrfToken();
|
||
|
|
assertStringIncludes(cookie, "Path=/");
|
||
|
|
assertStringIncludes(cookie, "HttpOnly");
|
||
|
|
assertStringIncludes(cookie, "SameSite=Strict");
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - GET requests bypass CSRF check", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.get("/api/files", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const res = await app.request("/api/files");
|
||
|
|
assertEquals(res.status, 200);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - HEAD requests bypass CSRF check", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.get("/api/files", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const res = await app.request("/api/files", { method: "HEAD" });
|
||
|
|
// HEAD on a GET route should pass
|
||
|
|
assertEquals(res.status >= 200 && res.status < 400, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - POST without token returns 403", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.post("/api/files", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const res = await app.request("/api/files", { method: "POST" });
|
||
|
|
assertEquals(res.status, 403);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - PUT without token returns 403", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.put("/api/files/abc", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const res = await app.request("/api/files/abc", { method: "PUT" });
|
||
|
|
assertEquals(res.status, 403);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - PATCH without token returns 403", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.patch("/api/files/abc", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const res = await app.request("/api/files/abc", { method: "PATCH" });
|
||
|
|
assertEquals(res.status, 403);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - DELETE without token returns 403", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.delete("/api/files/abc", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const res = await app.request("/api/files/abc", { method: "DELETE" });
|
||
|
|
assertEquals(res.status, 403);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - POST with valid token succeeds", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.post("/api/files", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const { token } = await generateCsrfToken();
|
||
|
|
const res = await app.request("/api/files", {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"x-csrf-token": token,
|
||
|
|
cookie: `${CSRF_COOKIE_NAME}=${token}`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assertEquals(res.status, 200);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - WOPI POST bypasses CSRF", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.post("/wopi/files/abc", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const res = await app.request("/wopi/files/abc", { method: "POST" });
|
||
|
|
assertEquals(res.status, 200);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - mismatched token rejected", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.post("/api/files", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const { token } = await generateCsrfToken();
|
||
|
|
const { token: otherToken } = await generateCsrfToken();
|
||
|
|
const res = await app.request("/api/files", {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"x-csrf-token": token,
|
||
|
|
cookie: `${CSRF_COOKIE_NAME}=${otherToken}`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assertEquals(res.status, 403);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - POST to non-api path bypasses CSRF", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.post("/login", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const res = await app.request("/login", { method: "POST" });
|
||
|
|
assertEquals(res.status, 200);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - token without cookie header rejected", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.post("/api/files", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const { token } = await generateCsrfToken();
|
||
|
|
const res = await app.request("/api/files", {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"x-csrf-token": token,
|
||
|
|
// no cookie
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assertEquals(res.status, 403);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - cookie without header token rejected", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.post("/api/files", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const { token } = await generateCsrfToken();
|
||
|
|
const res = await app.request("/api/files", {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
// no x-csrf-token header
|
||
|
|
cookie: `${CSRF_COOKIE_NAME}=${token}`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assertEquals(res.status, 403);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - malformed token (no dot) rejected", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.post("/api/files", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const badToken = "no-dot-in-this-token";
|
||
|
|
const res = await app.request("/api/files", {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"x-csrf-token": badToken,
|
||
|
|
cookie: `${CSRF_COOKIE_NAME}=${badToken}`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assertEquals(res.status, 403);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - token with wrong signature rejected", async () => {
|
||
|
|
const app = new Hono();
|
||
|
|
app.use("/*", csrfMiddleware);
|
||
|
|
app.post("/api/files", (c) => c.json({ ok: true }));
|
||
|
|
|
||
|
|
const { token } = await generateCsrfToken();
|
||
|
|
const parts = token.split(".");
|
||
|
|
const tamperedToken = `${parts[0]}.${"a".repeat(64)}`;
|
||
|
|
const res = await app.request("/api/files", {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"x-csrf-token": tamperedToken,
|
||
|
|
cookie: `${CSRF_COOKIE_NAME}=${tamperedToken}`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assertEquals(res.status, 403);
|
||
|
|
});
|
||
|
|
|
||
|
|
Deno.test("CSRF - two generated tokens are different", async () => {
|
||
|
|
const { token: t1 } = await generateCsrfToken();
|
||
|
|
const { token: t2 } = await generateCsrfToken();
|
||
|
|
assertEquals(t1 !== t2, true);
|
||
|
|
});
|