Skip to content
Draft
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
55 changes: 55 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";

/**
* Middleware that handles content negotiation for markdown requests.
* When a request includes Accept: text/markdown header, it rewrites
* the request to the markdown API endpoint to return raw markdown content.
*/
export function middleware(request: NextRequest) {
const acceptHeader = request.headers.get("accept") || "";

// Check if the client is requesting markdown content
const wantsMarkdown =
acceptHeader.includes("text/markdown") ||
acceptHeader.includes("text/x-markdown");

if (!wantsMarkdown) {
return NextResponse.next();
}

const pathname = request.nextUrl.pathname;

// Skip API routes, static files, and special paths
if (
pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/images/") ||
pathname.includes(".") // Skip files with extensions (e.g., .png, .js, .css)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dot check blocks valid paths with periods

Medium Severity

The check pathname.includes(".") is intended to skip files with extensions like .png or .css, but it's too broad. It blocks any path containing a period anywhere, including legitimate documentation paths with version numbers like /docs/v1.2/intro or /docs/node.js/setup. These paths would incorrectly skip markdown serving.

Fix in Cursor Fix in Web

) {
return NextResponse.next();
}

// Skip the root path
if (pathname === "/") {
return NextResponse.next();
}

// Rewrite to the markdown API endpoint
const url = request.nextUrl.clone();
url.pathname = `/api/markdown${pathname}`;

return NextResponse.rewrite(url);
}

export const config = {
// Match all paths except static files and API routes
matcher: [
/*
* Match all request paths except for:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};
72 changes: 72 additions & 0 deletions pages/api/markdown/[...slug].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import fs from "fs";
import { join } from "path";
import type { NextApiRequest, NextApiResponse } from "next";
import { CONTENT_DIR, DOCS_FILE_EXTENSIONS } from "@/lib/content.server";

/**
* API route that serves raw markdown/MDX content for a given slug.
* This enables content negotiation - when a client requests a page with
* Accept: text/markdown, they receive the raw markdown instead of HTML.
*/
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") {
return res
.status(405)
.setHeader("Allow", "GET")
.json({ error: `${req.method} method is not accepted.` });
}

// Get slug from query params, or parse from URL path if not available
// (middleware rewrites may not populate req.query properly)
let slug: string | string[] | undefined = req.query.slug;

if (!slug || (Array.isArray(slug) && slug.length === 0)) {
// Parse slug from the URL path: /api/markdown/path/to/page -> ["path", "to", "page"]
const urlPath = req.url?.split("?")[0] || "";
const pathSegments = urlPath.replace(/^\/api\/markdown\/?/, "").split("/");
slug = pathSegments.filter((s) => s.length > 0);
}

if (!slug || (Array.isArray(slug) && slug.length === 0)) {
return res.status(400).json({ error: "Invalid slug parameter" });
}

// Ensure slug is an array
const slugArray = Array.isArray(slug) ? slug : [slug];

// Try to find the content file with .mdx or .md extension
let source: Buffer | null = null;
let sourcePath: string | null = null;

for (const ext of DOCS_FILE_EXTENSIONS) {
const candidatePath = join(
CONTENT_DIR,
...slugArray.slice(0, slugArray.length - 1),
`${slugArray[slugArray.length - 1]}${ext}`,
);

if (fs.existsSync(candidatePath)) {
source = fs.readFileSync(candidatePath);
sourcePath = candidatePath;
break;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path traversal allows reading arbitrary files

High Severity

The API endpoint uses user-provided slug segments to construct a file path via path.join() without validating that the resolved path stays within CONTENT_DIR. Since path.join() resolves .. components, an attacker could request paths like /api/markdown/../../etc/passwd to read arbitrary files outside the content directory. The API route can be called directly, bypassing middleware protections.

Fix in Cursor Fix in Web

}

// Skip API reference pages
if (
sourcePath &&
(sourcePath.includes("content/__mapi-reference") ||
sourcePath.includes("content/__api-reference"))
) {
return res.status(404).json({ error: "Page not found" });
}

if (!source) {
return res.status(404).json({ error: "Page not found" });
}

// Return the raw markdown content
res.setHeader("Content-Type", "text/markdown; charset=utf-8");
res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600");
res.status(200).send(source.toString());
}
Loading