Skip to content

Commit

Permalink
fix: reduce middleware syntax error logs, send to logs, tests for CSP…
Browse files Browse the repository at this point in the history
… in each env (#213)

Co-authored-by: Adam Howard <[email protected]>
  • Loading branch information
stefl and codeincontext authored Oct 14, 2024
1 parent 446e6db commit d18a7d2
Show file tree
Hide file tree
Showing 9 changed files with 650 additions and 128 deletions.
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"gdrive",
"Geist",
"gleap",
"gstatic",
"Hardman",
"haroset",
"Hasura",
Expand Down Expand Up @@ -109,6 +110,7 @@
"PSED",
"PSHE",
"psql",
"pusherapp",
"ratelimit",
"Regen",
"remeda",
Expand Down
9 changes: 9 additions & 0 deletions apps/nextjs/src/lib/errors/getRootErrorCause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function getRootErrorCause(error: unknown) {
if (!(error instanceof Error)) {
return error;
}
if ("cause" in error && error.cause) {
return getRootErrorCause(error.cause);
}
return error;
}
273 changes: 273 additions & 0 deletions apps/nextjs/src/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { NextRequest, NextFetchEvent, NextResponse } from "next/server";

import { handleError } from "./middleware";
import { CspConfig, addCspHeaders, buildCspHeaders } from "./middlewares/csp";

jest.mock("./middlewares/csp", () => {
const originalModule = jest.requireActual("./middlewares/csp");
return {
...originalModule,
generateNonce: jest.fn(() => "mocked-nonce"),
};
});

describe("handleError", () => {
let mockRequest: NextRequest;
let mockEvent: NextFetchEvent;

beforeEach(() => {
mockRequest = {
url: "https://example.com",
method: "GET",
headers: new Headers(),
cookies: new Map(),
geo: {},
ip: "127.0.0.1",
nextUrl: { pathname: "/", search: "" },
clone: jest.fn().mockReturnThis(),
text: jest.fn().mockResolvedValue(""),
} as unknown as NextRequest;

mockEvent = {
sourcePage: "/test",
} as NextFetchEvent;
});

afterEach(() => {
jest.resetAllMocks();
});

it("handles SyntaxError correctly", async () => {
const error = new SyntaxError("Test syntax error");
const response = await handleError(error, mockRequest, mockEvent);

expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "Bad Request" });
});

it("handles wrapped SyntaxError correctly", async () => {
const error = new Error("Wrapper error");
error.cause = new SyntaxError("Test syntax error");
const response = await handleError(error, mockRequest, mockEvent);

expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "Bad Request" });
});

it("handles other errors correctly", async () => {
const error = new Error("Test error");
const response = await handleError(error, mockRequest, mockEvent);

expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: "Internal Server Error" });
});
});

describe("addCspHeaders", () => {
let mockRequest: NextRequest;
let mockResponse: NextResponse;
let defaultConfig: CspConfig;

beforeEach(() => {
mockRequest = new NextRequest("https://example.com", {
method: "GET",
headers: new Headers(),
});

mockResponse = new NextResponse();

defaultConfig = {
strictCsp: true,
environment: "production",
sentryEnv: "test",
sentryRelease: "1.0.0",
sentryReportUri: "https://sentry.io/report",
cspReportSampleRate: "1",
vercelEnv: "production",
enabledPolicies: {
clerk: false,
avo: false,
posthog: false,
devConsent: false,
mux: true,
vercel: false,
},
};
});

afterEach(() => {
jest.resetAllMocks();
});

it("adds CSP headers to the response", () => {
const result = addCspHeaders(mockResponse, mockRequest, defaultConfig);

expect(result.headers.has("Content-Security-Policy")).toBe(true);
expect(result.headers.get("x-middleware-csp-nonce")).toBeTruthy();
});

it("does not add CSP headers for _next/static paths", () => {
mockRequest = new NextRequest("https://example.com/_next/static/chunk.js", {
method: "GET",
headers: new Headers(),
});

const result = addCspHeaders(mockResponse, mockRequest, defaultConfig);

expect(result.headers.has("Content-Security-Policy")).toBe(false);
expect(result.headers.has("x-middleware-csp-nonce")).toBe(false);
});

it("does not add CSP headers for next-router-prefetch requests", () => {
mockRequest = new NextRequest("https://example.com", {
method: "GET",
headers: new Headers({ "next-router-prefetch": "1" }),
});

const result = addCspHeaders(mockResponse, mockRequest, defaultConfig);

expect(result.headers.has("Content-Security-Policy")).toBe(false);
expect(result.headers.has("x-middleware-csp-nonce")).toBe(false);
});

it("adds CSP-Report-Only header when strictCsp is false", () => {
const config = { ...defaultConfig, strictCsp: false };
const result = addCspHeaders(mockResponse, mockRequest, config);

expect(result.headers.has("Content-Security-Policy-Report-Only")).toBe(
true,
);
});

it("does not add CSP-Report-Only header when strictCsp is true", () => {
const result = addCspHeaders(mockResponse, mockRequest, defaultConfig);

expect(result.headers.has("Content-Security-Policy-Report-Only")).toBe(
false,
);
});

it("includes Clerk policies when enabled", () => {
const config = {
...defaultConfig,
enabledPolicies: { ...defaultConfig.enabledPolicies, clerk: true },
};
const result = addCspHeaders(mockResponse, mockRequest, config);

const cspHeader = result.headers.get("Content-Security-Policy");
expect(cspHeader).toContain("*.clerk.accounts.dev");
});

it("includes development-specific directives when environment is development", () => {
const config = { ...defaultConfig, environment: "development" };
const result = addCspHeaders(mockResponse, mockRequest, config);

const cspHeader = result.headers.get("Content-Security-Policy");
expect(cspHeader).toContain("'unsafe-eval'");
expect(cspHeader).not.toContain("upgrade-insecure-requests");
});

it("includes all Mux policies when enabled", () => {
const config = {
...defaultConfig,
enabledPolicies: { ...defaultConfig.enabledPolicies, mux: true },
};
const result = addCspHeaders(mockResponse, mockRequest, config);

const cspHeader = result.headers.get("Content-Security-Policy");
expect(cspHeader).toContain("https://cdn.mux.com");
expect(cspHeader).toContain("https://stream.mux.com");
expect(cspHeader).toContain("https://inferred.litix.io");
});

it("includes all required policies", () => {
const result = addCspHeaders(mockResponse, mockRequest, defaultConfig);
const cspHeader = result.headers.get("Content-Security-Policy");

// Check for base policies
expect(cspHeader).toContain("default-src 'self'");
expect(cspHeader).toContain("script-src 'self'");
expect(cspHeader).toContain("style-src 'self' 'unsafe-inline'");

// Check for specific domains
expect(cspHeader).toContain("*.thenational.academy");
expect(cspHeader).toContain("*.hubspot.com");
expect(cspHeader).toContain("https://img.clerk.com");
expect(cspHeader).toContain("https://res.cloudinary.com");

// Check for nonce
expect(cspHeader).toMatch(/'nonce-[a-zA-Z0-9+/=]{24}'/);
});

it("includes Clerk policies when enabled", () => {
const config = {
...defaultConfig,
enabledPolicies: { ...defaultConfig.enabledPolicies, clerk: true },
};
const result = addCspHeaders(mockResponse, mockRequest, config);

const cspHeader = result.headers.get("Content-Security-Policy");
expect(cspHeader).toContain("*.clerk.accounts.dev");
});

it("includes Vercel policies when enabled", () => {
const config = {
...defaultConfig,
enabledPolicies: { ...defaultConfig.enabledPolicies, vercel: true },
};
const result = addCspHeaders(mockResponse, mockRequest, config);

const cspHeader = result.headers.get("Content-Security-Policy");
expect(cspHeader).toContain("https://vercel.live/");
expect(cspHeader).toContain("https://vercel.com");
expect(cspHeader).toContain("*.pusher.com");
});
});

describe("buildCspHeaders", () => {
const mockNonce = "test-nonce";
const mockConfig: CspConfig = {
strictCsp: true,
environment: "production",
sentryEnv: "test",
sentryRelease: "1.0.0",
sentryReportUri: "https://sentry.io/report",
cspReportSampleRate: "1",
vercelEnv: "production",
enabledPolicies: {
clerk: false,
avo: false,
posthog: false,
devConsent: false,
mux: true,
vercel: false,
},
};

it("generates correct CSP headers", () => {
const result = buildCspHeaders(mockNonce, mockConfig);

expect(result.policy).toContain(`'nonce-${mockNonce}'`);
expect(result.policy).toContain("upgrade-insecure-requests");

expect(result.policy).toContain("default-src 'self'");
expect(result.policy).toContain("script-src 'self'");
expect(result.policy).toContain("style-src 'self' 'unsafe-inline'");

if (mockConfig.enabledPolicies.mux) {
expect(result.policy).toContain("https://cdn.mux.com");
}
if (mockConfig.enabledPolicies.clerk) {
expect(result.policy).toContain("*.clerk.accounts.dev");
}
});

it("generates report-only CSP when strictCsp is false", () => {
const nonStrictConfig = { ...mockConfig, strictCsp: false };
const result = buildCspHeaders(mockNonce, nonStrictConfig);

expect(result.policy).toContain("frame-ancestors 'self'");
expect(result.reportOnly).toBeDefined();
expect(result.reportOnly).toContain("default-src 'self'");
});
});
52 changes: 40 additions & 12 deletions apps/nextjs/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,48 @@
import * as Sentry from "@sentry/nextjs";
import { authMiddleware } from "middlewares/auth.middleware";
import { addCspHeaders } from "middlewares/csp";
import { NextMiddlewareResult } from "next/dist/server/web/types";
import { NextMiddleware, NextResponse } from "next/server";
import {
NextFetchEvent,
NextMiddleware,
NextRequest,
NextResponse,
} from "next/server";

import { getRootErrorCause } from "./lib/errors/getRootErrorCause";
import { sentryCleanup } from "./lib/sentry/sentryCleanup";
import { authMiddleware } from "./middlewares/auth.middleware";
import { CspConfig, addCspHeaders } from "./middlewares/csp";
import { logError } from "./middlewares/middlewareErrorLogging";

function handleError(error: unknown, extra: Record<string, unknown>): Response {
if (error instanceof SyntaxError) {
console.error("Handled SyntaxError in nextMiddleware", error);
const cspConfig: CspConfig = {
strictCsp: process.env.STRICT_CSP === "true",
environment: process.env.NEXT_PUBLIC_ENVIRONMENT || "",
sentryEnv: process.env.NEXT_PUBLIC_SENTRY_ENV || "",
sentryRelease: process.env.NEXT_PUBLIC_APP_VERSION || "",
sentryReportUri: process.env.SENTRY_REPORT_URI || "",
cspReportSampleRate: process.env.NEXT_PUBLIC_CSP_REPORT_SAMPLE_RATE || "1",
vercelEnv: process.env.VERCEL_ENV || "",
enabledPolicies: {
clerk: ["dev", "stg"].includes(process.env.NEXT_PUBLIC_ENVIRONMENT || ""),
avo: ["dev", "stg"].includes(process.env.NEXT_PUBLIC_ENVIRONMENT || ""),
posthog: process.env.NEXT_PUBLIC_ENVIRONMENT === "dev",
devConsent: process.env.NEXT_PUBLIC_ENVIRONMENT === "dev",
mux: true,
vercel: process.env.VERCEL_ENV === "preview",
},
};

export async function handleError(
error: unknown,
request: NextRequest,
event: NextFetchEvent,
): Promise<Response> {
const rootError = getRootErrorCause(error);

await logError(rootError, request, event);

if (rootError instanceof SyntaxError) {
return NextResponse.json({ error: "Bad Request" }, { status: 400 });
}

const wrappedError = new Error("Error in nextMiddleware", { cause: error });
Sentry.captureException(wrappedError, { extra });

return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}

Expand All @@ -23,9 +51,9 @@ const nextMiddleware: NextMiddleware = async (request, event) => {

try {
response = await authMiddleware(request, event);
response = addCspHeaders(response, request);
response = addCspHeaders(response, request, cspConfig);
} catch (error) {
response = handleError(error, { request, event });
response = await handleError(error, request, event);
} finally {
await sentryCleanup();
}
Expand Down
Loading

0 comments on commit d18a7d2

Please sign in to comment.