155 lines
7.8 KiB
TypeScript
155 lines
7.8 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).
|
|
["routes/invitation.$id.tsx", "public invitation acceptance surface"],
|
|
// 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$/];
|
|
|
|
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 delegated row actions before parsing submitted form data", () => {
|
|
const source = fs.readFileSync(path.join(appRoot, "modules/rows/utils/RowsRequestUtils.ts"), "utf8");
|
|
|
|
expect(source.indexOf("await validateCSRFToken(request)")).toBeGreaterThanOrEqual(0);
|
|
expect(source.indexOf("await validateCSRFToken(request)")).toBeLessThan(source.indexOf("await request.formData()"));
|
|
});
|
|
|
|
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("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 ?? \"\"}");
|
|
});
|
|
});
|