Initial commit — Drive, an S3 file browser with WOPI editing
Lightweight replacement for the upstream La Suite Numérique drive (Django/Celery/Next.js) built as a single Deno binary. Server (Deno + Hono): - S3 file operations via AWS SigV4 (no SDK) with pre-signed URLs - WOPI host for Collabora Online (CheckFileInfo, GetFile, PutFile, locks) - Ory Kratos session auth + CSRF protection - Ory Keto permission model (OPL namespaces, not yet wired to routes) - PostgreSQL metadata with recursive folder sizes - S3 backfill API for registering files uploaded outside the UI - OpenTelemetry tracing + metrics (opt-in via OTEL_ENABLED) Frontend (React 19 + Cunningham v4 + react-aria): - File browser with GridList, keyboard nav, multi-select - Collabora editor iframe (full-screen, form POST, postMessage) - Profile menu, waffle menu, drag-drop upload, asset type badges - La Suite integration service theming (runtime CSS) Testing (549 tests): - 235 server unit tests (Deno) — 90%+ coverage - 278 UI unit tests (Vitest) — 90%+ coverage - 11 E2E tests (Playwright) - 12 integration service tests (Playwright) - 13 WOPI integration tests (Playwright + Docker Compose + Collabora) MIT licensed.
This commit is contained in:
90
server/csrf.ts
Normal file
90
server/csrf.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Context, Next } from "hono";
|
||||
|
||||
const CSRF_COOKIE_SECRET = Deno.env.get("CSRF_COOKIE_SECRET")
|
||||
?? (Deno.env.get("DRIVER_TEST_MODE") === "1" ? "test-csrf-secret" : "");
|
||||
if (!CSRF_COOKIE_SECRET) {
|
||||
throw new Error("CSRF_COOKIE_SECRET must be set (or run with DRIVER_TEST_MODE=1)");
|
||||
}
|
||||
const CSRF_COOKIE_NAME = "driver-csrf-token";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
async function hmacSign(data: string, secret: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
||||
return Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function hmacVerify(data: string, signature: string, secret: string): Promise<boolean> {
|
||||
const expected = await hmacSign(data, secret);
|
||||
if (expected.length !== signature.length) return false;
|
||||
let result = 0;
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
result |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
||||
}
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
export async function generateCsrfToken(): Promise<{ token: string; cookie: string }> {
|
||||
const raw = crypto.randomUUID();
|
||||
const sig = await hmacSign(raw, CSRF_COOKIE_SECRET);
|
||||
const token = `${raw}.${sig}`;
|
||||
const secure = Deno.env.get("DRIVER_TEST_MODE") === "1" ? "" : "; Secure";
|
||||
const cookie = `${CSRF_COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict${secure}`;
|
||||
return { token, cookie };
|
||||
}
|
||||
|
||||
function extractCookie(cookieHeader: string, name: string): string | null {
|
||||
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
||||
for (const cookie of cookies) {
|
||||
if (cookie.startsWith(`${name}=`)) {
|
||||
return cookie.slice(name.length + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function verifyCsrfToken(req: Request): Promise<boolean> {
|
||||
const headerToken = req.headers.get("x-csrf-token");
|
||||
if (!headerToken) return false;
|
||||
|
||||
const cookieHeader = req.headers.get("cookie") ?? "";
|
||||
const cookieToken = extractCookie(cookieHeader, CSRF_COOKIE_NAME);
|
||||
if (!cookieToken) return false;
|
||||
|
||||
if (headerToken !== cookieToken) return false;
|
||||
|
||||
const parts = headerToken.split(".");
|
||||
if (parts.length !== 2) return false;
|
||||
const [raw, sig] = parts;
|
||||
return await hmacVerify(raw, sig, CSRF_COOKIE_SECRET);
|
||||
}
|
||||
|
||||
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
export async function csrfMiddleware(c: Context, next: Next) {
|
||||
// Skip CSRF entirely in test mode (checked at call time so tests can control it)
|
||||
if (Deno.env.get("DRIVER_TEST_MODE") === "1") return await next();
|
||||
// Skip CSRF for WOPI endpoints (use token auth) and API uploads
|
||||
if (c.req.path.startsWith("/wopi/")) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
if (MUTATING_METHODS.has(c.req.method) && c.req.path.startsWith("/api/")) {
|
||||
const valid = await verifyCsrfToken(c.req.raw);
|
||||
if (!valid) {
|
||||
return c.text("CSRF token invalid or missing", 403);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
export { CSRF_COOKIE_NAME };
|
||||
Reference in New Issue
Block a user