Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const cspHeader = [
].join("; ");

const securityHeaders = [
// Report-Only first — switch to Content-Security-Policy once violations are reviewed
{ key: "Content-Security-Policy-Report-Only", value: cspHeader },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
Expand All @@ -45,6 +44,7 @@ const nextConfig = {
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
...securityHeaders,
],
},
];
Expand All @@ -61,14 +61,6 @@ const nextConfig = {
]
: [];
},
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};

export default withSentryConfig(nextConfig, {
Expand Down
22 changes: 22 additions & 0 deletions src/__tests__/csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
describe("CSP header builder", () => {
it("builds a nonce-based report-only policy", () => {
const nonce = "abc123";
const policy = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' https://unpkg.com",
"connect-src 'self' https://horizon.stellar.org https://horizon-testnet.stellar.org https://api.paystack.co https://api.ng.termii.com https://*.ingest.sentry.io",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
].join("; ");

expect(policy).toContain("default-src 'self'");
expect(policy).toContain(`script-src 'self' 'nonce-${nonce}'`);
expect(policy).toContain("img-src 'self' data: https:");
});
});
26 changes: 26 additions & 0 deletions src/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { middleware } from "@/middleware";

describe("middleware CSP headers", () => {
it("adds CSP report-only headers to API responses", () => {
const response = middleware({
headers: new Headers(),
nextUrl: { pathname: "/api/auth/logout" },
method: "GET",
} as any);

expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("default-src 'self'");
expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("script-src");
});

it("adds a nonce-based CSP header for page requests", () => {
const response = middleware({
headers: new Headers(),
nextUrl: { pathname: "/dashboard" },
method: "GET",
} as any);
const csp = response.headers.get("Content-Security-Policy-Report-Only") ?? "";

expect(csp).toContain("default-src 'self'");
expect(csp).toMatch(/script-src 'self' 'nonce-[^']+'/);
});
});
16 changes: 11 additions & 5 deletions src/app/api/docs/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";

const SPEC_URL = "/api/docs/spec";
const SWAGGER_CSS_URL = "https://unpkg.com/swagger-ui-dist@5/swagger-ui.css";
const SWAGGER_JS_URL = "https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js";
const SWAGGER_CSS_SHA = "sha384-9Q2fpS+xeS4ffJy6CagnwoUl+4ldAYhOs9pgZuEKxypVModhmZFzeMlvVsAjf7uT";
const SWAGGER_JS_SHA = "sha384-IKpAWwsTL0pcw7/Amtnt2eXF4P1BK64WNuY2E/RG15SWLUW5HXzFuyqCSAr/DP8C";

const html = `<!DOCTYPE html>
export async function GET() {
const nonce = headers().get("x-nonce") ?? "";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ajosave API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<link rel="stylesheet" href="${SWAGGER_CSS_URL}" integrity="${SWAGGER_CSS_SHA}" crossorigin="anonymous" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
<script src="${SWAGGER_JS_URL}" integrity="${SWAGGER_JS_SHA}" crossorigin="anonymous" nonce="${nonce}"></script>
<script nonce="${nonce}">
SwaggerUIBundle({
url: "${SPEC_URL}",
dom_id: "#swagger-ui",
Expand All @@ -25,7 +32,6 @@ const html = `<!DOCTYPE html>
</body>
</html>`;

export function GET() {
return new NextResponse(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
Expand Down
71 changes: 38 additions & 33 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "crypto";

const ALLOWED_ORIGINS = [
process.env.NEXT_PUBLIC_APP_URL,
Expand All @@ -10,55 +11,59 @@ const ALLOWED_ORIGINS = [
.filter(Boolean)
.map((origin) => origin!.trim().replace(/\/$/, ""));

function buildCsp(nonce: string): string {
return [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' https://unpkg.com",
"connect-src 'self' https://horizon.stellar.org https://horizon-testnet.stellar.org https://api.paystack.co https://api.ng.termii.com https://*.ingest.sentry.io",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
].join("; ");
}

export function middleware(request: NextRequest) {
const origin = request.headers.get("origin");
const nonce = randomBytes(16).toString("base64");
const csp = buildCsp(nonce);

const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);

// Handle CORS for API routes
if (request.nextUrl.pathname.startsWith("/api/")) {
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin);
const response = request.method === "OPTIONS"
? new NextResponse(null, { status: 204 })
: NextResponse.next({ request: { headers: requestHeaders } });

// Handle preflight requests
if (request.method === "OPTIONS") {
const response = new NextResponse(null, { status: 204 });

if (isAllowed) {
response.headers.set("Access-Control-Allow-Origin", origin!);
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
);
response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
);
response.headers.set("Access-Control-Allow-Credentials", "true");
response.headers.set("Access-Control-Max-Age", "86400");
}

return response;
}

const response = NextResponse.next();
response.headers.set("Content-Security-Policy-Report-Only", csp);

if (isAllowed) {
response.headers.set("Access-Control-Allow-Origin", origin!);
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
);
response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
);
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, X-CSRF-Token");
response.headers.set("Access-Control-Allow-Credentials", "true");
response.headers.set("Access-Control-Max-Age", "86400");
}

return response;
}

return NextResponse.next();
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy-Report-Only", csp);

return response;
}

export const config = {
matcher: "/api/:path*",
matcher: [
"/api/:path*",
// Apply CSP to all page routes (exclude static files and _next internals)
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};