@docubook/flame 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,19 +3,10 @@ import { resolve } from "node:path";
3
3
  import { logger } from "./logger";
4
4
  import { DIST_DIR } from "./paths";
5
5
  import { getContentType } from "./utils";
6
+ import { SECURITY_HEADERS, generateNonce, cspHeader, injectNonce } from "./security";
6
7
 
7
8
  const PORT = process.env.PORT || "4173";
8
9
 
9
- const SECURITY_HEADERS: Record<string, string> = {
10
- "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
11
- "X-Frame-Options": "DENY",
12
- "X-Content-Type-Options": "nosniff",
13
- "Referrer-Policy": "strict-origin-when-cross-origin",
14
- "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
15
- "Content-Security-Policy":
16
- "default-src 'self'; script-src 'self' 'sha256-4XrQ+xtH2goQdpEv7dRmUWACF3uhF9KRPpgayFex7QU=' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'",
17
- };
18
-
19
10
  logger.buildStart();
20
11
 
21
12
  if (!existsSync(DIST_DIR)) {
@@ -50,22 +41,42 @@ const notFoundPath = resolve(DIST_DIR, "404.html");
50
41
  const server = Bun.serve({
51
42
  port: PORT,
52
43
 
53
- fetch(req) {
44
+ async fetch(req) {
54
45
  const url = new URL(req.url);
55
46
  const pathname = decodeURIComponent(url.pathname);
56
47
 
57
48
  const filePath = resolveFile(pathname);
49
+
58
50
  if (filePath) {
59
51
  const contentType = getContentType(filePath);
60
- const headers: Record<string, string> = { "Content-Type": contentType };
61
- if (contentType === "text/html") Object.assign(headers, SECURITY_HEADERS);
62
- return new Response(Bun.file(filePath), { headers });
52
+ if (contentType === "text/html") {
53
+ const nonce = generateNonce();
54
+ const html = await Bun.file(filePath).text();
55
+ const modified = injectNonce(html, nonce);
56
+ return new Response(modified, {
57
+ headers: {
58
+ "Content-Type": "text/html",
59
+ ...SECURITY_HEADERS,
60
+ "Content-Security-Policy": cspHeader(nonce, true),
61
+ },
62
+ });
63
+ }
64
+ return new Response(Bun.file(filePath), {
65
+ headers: { "Content-Type": contentType },
66
+ });
63
67
  }
64
68
 
65
69
  if (existsSync(notFoundPath)) {
66
- return new Response(Bun.file(notFoundPath), {
70
+ const nonce = generateNonce();
71
+ const html = await Bun.file(notFoundPath).text();
72
+ const modified = injectNonce(html, nonce);
73
+ return new Response(modified, {
67
74
  status: 404,
68
- headers: { "Content-Type": "text/html", ...SECURITY_HEADERS },
75
+ headers: {
76
+ "Content-Type": "text/html",
77
+ ...SECURITY_HEADERS,
78
+ "Content-Security-Policy": cspHeader(nonce, true),
79
+ },
69
80
  });
70
81
  }
71
82
 
@@ -1,3 +1,5 @@
1
+ import { resolve } from "node:path";
2
+
1
3
  export const SECURITY_HEADERS: Record<string, string> = {
2
4
  "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
3
5
  "X-Frame-Options": "DENY",
@@ -10,10 +12,13 @@ export function generateNonce(): string {
10
12
  return crypto.randomUUID();
11
13
  }
12
14
 
13
- export function cspHeader(nonce: string): string {
15
+ export function cspHeader(nonce: string, allowEval = false): string {
16
+ const scriptSrc = allowEval
17
+ ? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval'`
18
+ : `script-src 'self' 'nonce-${nonce}'`;
14
19
  return [
15
20
  "default-src 'self'",
16
- `script-src 'self' 'nonce-${nonce}' 'unsafe-eval'`,
21
+ scriptSrc,
17
22
  "style-src 'self' 'unsafe-inline'",
18
23
  "img-src 'self' https: data:",
19
24
  "font-src 'self' data:",
@@ -23,13 +28,38 @@ export function cspHeader(nonce: string): string {
23
28
  ].join("; ");
24
29
  }
25
30
 
26
- export function htmlResponse(html: string, nonce: string, status = 200): Response {
31
+ export function isPathSafe(pathname: string, baseDir: string): boolean {
32
+ const decoded = decodeURIComponent(pathname);
33
+ const resolved = resolve(baseDir, decoded.slice(1));
34
+ const baseDirSlash = baseDir.endsWith("/") ? baseDir : baseDir + "/";
35
+ return resolved === baseDir || resolved.startsWith(baseDirSlash);
36
+ }
37
+
38
+ export function isSlugSafe(slug: string, docsDir: string): boolean {
39
+ const resolved = resolve(docsDir, slug);
40
+ const docsDirSlash = docsDir.endsWith("/") ? docsDir : docsDir + "/";
41
+ return resolved === docsDir || resolved.startsWith(docsDirSlash);
42
+ }
43
+
44
+ export function injectNonce(html: string, nonce: string): string {
45
+ return html.replace(/<script\b(?![^>]*\bsrc\s*=)([^>]*)>/gi, (match) => {
46
+ if (/nonce\s*=/i.test(match)) return match;
47
+ return match.replace(/>$/, ` nonce="${nonce}">`);
48
+ });
49
+ }
50
+
51
+ export function htmlResponse(
52
+ html: string,
53
+ nonce: string,
54
+ status = 200,
55
+ allowEval = false
56
+ ): Response {
27
57
  return new Response(html, {
28
58
  status,
29
59
  headers: {
30
60
  "Content-Type": "text/html",
31
61
  ...SECURITY_HEADERS,
32
- "Content-Security-Policy": cspHeader(nonce),
62
+ "Content-Security-Policy": cspHeader(nonce, allowEval),
33
63
  },
34
64
  });
35
65
  }
@@ -15,7 +15,7 @@ import { buildClientBundle, computeInlineThemeCss } from "./hydrate";
15
15
  import { generateSearchIndex } from "./search-indexer";
16
16
  import { logger } from "./logger";
17
17
  import { initSentry, captureException } from "./sentry";
18
- import { SECURITY_HEADERS, generateNonce, htmlResponse } from "./security";
18
+ import { SECURITY_HEADERS, generateNonce, isPathSafe, isSlugSafe, htmlResponse } from "./security";
19
19
  import { htmlShell as createHtmlShell, hmrScript } from "./html";
20
20
 
21
21
  const docuConfig = loadDocuConfig();
@@ -95,12 +95,11 @@ function createHtmlResponse(
95
95
  extraScripts: hmrScript(nonce),
96
96
  themeCss: inlineThemeCss,
97
97
  });
98
- return htmlResponse(html, nonce, status);
98
+ return htmlResponse(html, nonce, status, process.env.NODE_ENV !== "production");
99
99
  }
100
100
 
101
101
  async function getDocsForSlug(slug: string) {
102
- const resolved = resolve(DOCS_DIR, slug);
103
- if (!resolved.startsWith(DOCS_DIR)) return null;
102
+ if (!isSlugSafe(slug, DOCS_DIR)) return null;
104
103
 
105
104
  const paths = [
106
105
  join(DOCS_DIR, slug, "index.mdx"),
@@ -218,9 +217,9 @@ function handleIndex(): Response {
218
217
  }
219
218
 
220
219
  function serveStatic(pathname: string): Response | null {
220
+ if (!isPathSafe(pathname, DIST_DIR)) return null;
221
221
  const decoded = decodeURIComponent(pathname);
222
222
  const assetPath = resolve(DIST_DIR, decoded.slice(1));
223
- if (!assetPath.startsWith(DIST_DIR)) return null;
224
223
  try {
225
224
  const s = statSync(assetPath);
226
225
  if (s.isFile()) {
@@ -234,7 +233,9 @@ function serveStatic(pathname: string): Response | null {
234
233
 
235
234
  if (decoded.startsWith("/docs/assets/")) {
236
235
  const docsAsset = resolve(DOCS_DIR, "assets", decoded.replace("/docs/assets/", ""));
237
- if (!docsAsset.startsWith(resolve(DOCS_DIR, "assets"))) return null;
236
+ const docsAssetsDir = resolve(DOCS_DIR, "assets");
237
+ const docsAssetsDirSlash = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/";
238
+ if (docsAsset !== docsAssetsDir && !docsAsset.startsWith(docsAssetsDirSlash)) return null;
238
239
  try {
239
240
  const s = statSync(docsAsset);
240
241
  if (s.isFile()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docubook/flame",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "A blazing-fast React + MDX framework powered by Bun, built for modern documentation experiences.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,10 +51,10 @@
51
51
  "lucide-react": "^1.14.0",
52
52
  "react": "^19.0.0",
53
53
  "react-dom": "^19.0.0",
54
- "@docubook/core": "^1.7.0",
55
54
  "@docubook/mdx-content": "^3.2.1",
56
- "@docubook/ui-react": "^0.1.3",
57
- "@docubook/themes-colors": "^0.10.0"
55
+ "@docubook/core": "^1.7.0",
56
+ "@docubook/themes-colors": "^0.10.1",
57
+ "@docubook/ui-react": "^0.1.3"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@sentry/bun": "^9.0.0"