Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/session-csrf-sweep.test.ts
T

210 lines
10 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveRepoPath } from "~/utils/testing/repoPaths";
const appRoot = resolveRepoPath("apps/web/app");
const centralSessionModule = "utils/session.server.ts";
const appSourceExtensions = new Set([".ts", ".tsx"]);
const ignoredDirectories = new Set(["node_modules", ".react-router", "build", "dist", "coverage"]);
const ignoredPathSegments = new Set(["__generated__", "generated"]);
// Public authentication flows and external callbacks are intentionally outside
// the signed-in session CSRF contract. Keep each exception path-specific so new
// protected mutating forms must opt into either csrf input or validator coverage.
const postFormCsrfExceptions = new Map<string, string>([
["modules/users/components/LoginForm.tsx", "public login form creates a session before CSRF exists"],
// D-D0a — die Reset-Request-Form lebt jetzt in ForgotPasswordScreen.tsx (mit name="csrf" -> von der
// Standard-Pruefung abgedeckt, daher kein Exception-Eintrag mehr noetig).
// WF-P2 — Invitation acceptance now submits the root loader's CSRF token,
// so it no longer needs a public-form exception.
// D-D0a — die Reset-Completion-Form lebt jetzt in ResetPasswordScreen.tsx (mit name="csrf").
]);
const protectedPostFormAllowlist = new Map<string, string>([
["modules/users/components/RegisterForm.tsx", "includes hidden csrf input and AuthService validates it server-side"],
]);
const protectedPostFormFilePatterns = [/^modules\/users\/components\/RegisterForm\.tsx$/, /^modules\/users\/components\/SetPasswordScreen\.tsx$/];
type SourceFile = {
relativePath: string;
source: string;
};
function collectAppSourceFiles(directory: string = appRoot): SourceFile[] {
return fs
.readdirSync(directory, { withFileTypes: true })
.flatMap((entry) => {
const absolutePath = path.join(directory, entry.name);
const relativePath = path.relative(appRoot, absolutePath).split(path.sep).join("/");
if (entry.isDirectory()) {
return ignoredDirectories.has(entry.name) || ignoredPathSegments.has(entry.name) ? [] : collectAppSourceFiles(absolutePath);
}
if (!entry.isFile() || !appSourceExtensions.has(path.extname(entry.name))) {
return [];
}
return [
{
relativePath,
source: fs.readFileSync(absolutePath, "utf8"),
},
];
})
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
}
function stripLineComments(source: string) {
return source
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
}
const sourceFiles = collectAppSourceFiles().filter((file) => !/\.(test|spec)\.[tj]sx?$/.test(file.relativePath));
const postFormPattern = /<(?:Form|fetcher\.Form)\b[^>]*\bmethod=(?:"post"|'post'|\{"post"\}|\{'post'\})/;
const csrfFieldPattern = /\bname=(?:"csrf"|'csrf'|\{"csrf"\}|\{'csrf'\})/;
const validatorPattern = /\bvalidateCSRFToken\s*\(/;
describe("CSRF session wiring sweep", () => {
it("does not reintroduce legacy crsf spellings in app source files", () => {
const legacyMatches = sourceFiles.filter((file) => file.source.includes("crsf"));
expect(
legacyMatches.map((file) => file.relativePath),
"legacy crsf spellings must not appear in app TypeScript sources"
).toEqual([]);
});
it("keeps raw csrf session mutations centralized in the session helper", () => {
const rawMutationMatches = sourceFiles
.filter((file) => file.relativePath !== centralSessionModule)
.filter((file) => file.source.includes('session.set("csrf"'));
expect(
rawMutationMatches.map((file) => file.relativePath),
"raw csrf session.set calls must go through apps/web/app/utils/session.server.ts"
).toEqual([]);
});
it("keeps protected post forms tied to csrf inputs or validator coverage", () => {
const protectedPostFormFiles = sourceFiles
.filter((file) => postFormPattern.test(file.source))
.filter((file) => protectedPostFormFilePatterns.some((pattern) => pattern.test(file.relativePath)));
const unclassifiedProtectedForms = protectedPostFormFiles.filter((file) => {
const sourceWithoutCommentOnlyLines = stripLineComments(file.source);
return (
!csrfFieldPattern.test(sourceWithoutCommentOnlyLines) &&
!validatorPattern.test(sourceWithoutCommentOnlyLines) &&
!protectedPostFormAllowlist.has(file.relativePath)
);
});
expect(
unclassifiedProtectedForms.map((file) => file.relativePath),
'protected Form/fetcher.Form method=post files must include name="csrf", call validateCSRFToken, or be path-allowlisted with a reason'
).toEqual([]);
});
it("keeps public csrf exceptions anchored to existing post form files", () => {
const postFormPaths = new Set(sourceFiles.filter((file) => postFormPattern.test(file.source)).map((file) => file.relativePath));
const staleExceptionPaths = [...postFormCsrfExceptions.keys(), ...protectedPostFormAllowlist.keys()].filter(
(allowedPath) => !postFormPaths.has(allowedPath)
);
expect(staleExceptionPaths, "CSRF form allowlist entries must name existing app/ post form files").toEqual([]);
});
it("validates direct tenant feedback actions before parsing form data", () => {
for (const relativePath of ["routes/app.$tenant.tsx"]) {
const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8");
expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeGreaterThanOrEqual(0);
expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeLessThan(source.indexOf("await request.formData()"));
}
});
it("validates delegated workflow actions before parsing submitted form data", () => {
for (const relativePath of [
"modules/workflowEngine/routes/workflow-engine/workflows.index.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.index.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.manual.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.stream.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/credentials.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/credentials/credentials.new.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/danger.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/executions.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/templates.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/variables/variables.$id.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/variables/variables.new.api.server.tsx",
]) {
const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8");
expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeGreaterThanOrEqual(0);
expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeLessThan(source.indexOf("await request.formData()"));
}
});
it("protects telephony actions and the Aircall install boundary with the current session CSRF token", () => {
for (const relativePath of [
"routes/app.$tenant/calls.$id.tsx",
"routes/app.$tenant/settings/integrations/telephony.tsx",
"routes/integrations.aircall.oauth.install.tsx",
]) {
const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8");
expect(source, relativePath).toContain("await validateCSRFToken(request, { formData: form })");
expect(source.indexOf("await request.formData()"), relativePath).toBeLessThan(source.indexOf("await validateCSRFToken(request, { formData: form })"));
}
for (const relativePath of ["routes/app.$tenant/calls.$id.tsx", "routes/app.$tenant/settings/integrations/telephony.tsx"]) {
const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8");
expect(source, relativePath).toContain("appendCSRFToken(form)");
}
const settingsSource = fs.readFileSync(path.join(appRoot, "routes/app.$tenant/settings/integrations/telephony.tsx"), "utf8");
expect(settingsSource).toContain('form.set("intent", "connect")');
expect(settingsSource).toContain('method: "post", action: connectHref');
});
it("protects embedded Click-to-Call actions while keeping tel fallback client-only", () => {
const serverSources = [
{
relativePath: "utils/integrations/crm/crmDetailRoute.server.ts",
validator: "await validateCSRFToken(args.request, { formData: form })",
},
{
relativePath: "routes/app.$tenant/leads.$id.tsx",
validator: "await validateCSRFToken(request, { formData: form })",
},
{
relativePath: "routes/app.$tenant/projects.$id.tsx",
validator: "await validateCSRFToken(request, { formData })",
},
];
for (const { relativePath, validator } of serverSources) {
const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8");
expect(source, relativePath).toContain(validator);
expect(source, relativePath).toContain("startTelephonyEntityCall(");
expect(source.indexOf(validator), relativePath).toBeLessThan(source.indexOf("startTelephonyEntityCall("));
}
for (const relativePath of [
"routes/app.$tenant/contacts.$id.tsx",
"routes/app.$tenant/contacts.companies.$id.tsx",
"routes/app.$tenant/leads.$id.tsx",
"routes/app.$tenant/projects.$id.tsx",
]) {
const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8");
expect(source, relativePath).toContain("appendCSRFToken(form)");
expect(source, relativePath).toContain("window.location.assign(`tel:${number.e164}`)");
}
});
it("posts tenant feedback with the current csrf token", () => {
const source = fs.readFileSync(path.join(appRoot, "components/layouts/buttons/AddFeedbackButton.tsx"), "utf8");
expect(source).toContain("useRootData()");
expect(source).toContain('name="csrf"');
expect(source).toContain('value={csrf ?? ""}');
});
});