From: Sander Saarend <sander@saarend.com>
Date: Mon, 10 Jul 2023 18:26:41 +0000 (+0300)
Subject: Add nonce-based CSP header (#1907)
X-Git-Url: http://these/git/%7B%60/feeds/c/%7BimageSrc%7D?a=commitdiff_plain;h=546f0ad704940aafc5c2bac244f0f150eb7a47cc;p=lemmy-ui.git

Add nonce-based CSP header (#1907)

* Remove websocket config

* Add nonce based CSP
---

diff --git a/package.json b/package.json
index fdc1dfd..5e3535b 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
   "scripts": {
     "analyze": "webpack --mode=none",
     "prebuild:dev": "yarn clean && node generate_translations.js",
-    "build:dev": "webpack --env COMMIT_HASH=$(git rev-parse --short HEAD) --mode=development",
+    "build:dev": "webpack --env LEMMY_UI_DISABLE_CSP=true --env COMMIT_HASH=$(git rev-parse --short HEAD) --mode=development",
     "prebuild:prod": "yarn clean && node generate_translations.js",
     "build:prod": "webpack --env COMMIT_HASH=$(git rev-parse --short HEAD) --mode=production",
     "clean": "yarn run rimraf dist",
diff --git a/src/server/handlers/catch-all-handler.tsx b/src/server/handlers/catch-all-handler.tsx
index 4b01104..06d38f3 100644
--- a/src/server/handlers/catch-all-handler.tsx
+++ b/src/server/handlers/catch-all-handler.tsx
@@ -120,7 +120,7 @@ export default async (req: Request, res: Response) => {
 
     const root = renderToString(wrapper);
 
-    res.send(await createSsrHtml(root, isoData));
+    res.send(await createSsrHtml(root, isoData, res.locals.cspNonce));
   } catch (err) {
     // If an error is caught here, the error page couldn't even be rendered
     console.error(err);
diff --git a/src/server/middleware.ts b/src/server/middleware.ts
index 0420e47..a75d49e 100644
--- a/src/server/middleware.ts
+++ b/src/server/middleware.ts
@@ -1,3 +1,4 @@
+import * as crypto from "crypto";
 import type { NextFunction, Request, Response } from "express";
 import { hasJwtCookie } from "./utils/has-jwt-cookie";
 
@@ -8,9 +9,20 @@ export function setDefaultCsp({
   res: Response;
   next: NextFunction;
 }) {
+  res.locals.cspNonce = crypto.randomBytes(16).toString("hex");
+
   res.setHeader(
     "Content-Security-Policy",
-    `default-src 'self'; manifest-src *; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'; frame-src *; media-src * data:`
+    `default-src 'self';
+     manifest-src *;
+     connect-src *;
+     img-src * data:;
+     script-src 'self' 'nonce-${res.locals.cspNonce}';
+     style-src 'self' 'unsafe-inline';
+     form-action 'self';
+     base-uri 'self';
+     frame-src *;
+     media-src * data:`.replace(/\s+/g, " ")
   );
 
   next();
diff --git a/src/server/utils/create-ssr-html.tsx b/src/server/utils/create-ssr-html.tsx
index ba85228..71fdb68 100644
--- a/src/server/utils/create-ssr-html.tsx
+++ b/src/server/utils/create-ssr-html.tsx
@@ -4,7 +4,7 @@ import { renderToString } from "inferno-server";
 import serialize from "serialize-javascript";
 import sharp from "sharp";
 import { favIconPngUrl, favIconUrl } from "../../shared/config";
-import { ILemmyConfig, IsoDataOptionalSite } from "../../shared/interfaces";
+import { IsoDataOptionalSite } from "../../shared/interfaces";
 import { buildThemeList } from "./build-themes-list";
 import { fetchIconPng } from "./fetch-icon-png";
 
@@ -14,7 +14,8 @@ let appleTouchIcon: string | undefined = undefined;
 
 export async function createSsrHtml(
   root: string,
-  isoData: IsoDataOptionalSite
+  isoData: IsoDataOptionalSite,
+  cspNonce: string
 ) {
   const site = isoData.site_res;
 
@@ -22,6 +23,12 @@ export async function createSsrHtml(
     (await buildThemeList())[0]
   }.css" />`;
 
+  const customHtmlHeaderScriptTag = new RegExp("<script", "g");
+  const customHtmlHeaderWithNonce = customHtmlHeader.replace(
+    customHtmlHeaderScriptTag,
+    `<script nonce="${cspNonce}"`
+  );
+
   if (!appleTouchIcon) {
     appleTouchIcon = site?.site_view.site.icon
       ? `data:image/png;base64,${await sharp(
@@ -45,28 +52,28 @@ export async function createSsrHtml(
     process.env["LEMMY_UI_DEBUG"] === "true"
       ? renderToString(
           <>
-            <script src="//cdn.jsdelivr.net/npm/eruda"></script>
-            <script>eruda.init();</script>
+            <script
+              nonce={cspNonce}
+              src="//cdn.jsdelivr.net/npm/eruda"
+            ></script>
+            <script nonce={cspNonce}>eruda.init();</script>
           </>
         )
       : "";
 
   const helmet = Helmet.renderStatic();
 
-  const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST };
-
   return `
     <!DOCTYPE html>
     <html ${helmet.htmlAttributes.toString()}>
     <head>
-    <script>window.isoData = ${serialize(isoData)}</script>
-    <script>window.lemmyConfig = ${serialize(config)}</script>
+    <script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script>
   
     <!-- A remote debugging utility for mobile -->
     ${erudaStr}
   
     <!-- Custom injected script -->
-    ${customHtmlHeader}
+    ${customHtmlHeaderWithNonce}
   
     ${helmet.title.toString()}
     ${helmet.meta.toString()}
diff --git a/src/shared/interfaces.ts b/src/shared/interfaces.ts
index b37dbd3..7beeec9 100644
--- a/src/shared/interfaces.ts
+++ b/src/shared/interfaces.ts
@@ -18,14 +18,9 @@ export type IsoDataOptionalSite<T extends RouteData = any> = Partial<
 > &
   Pick<IsoData<T>, Exclude<keyof IsoData<T>, "site_res">>;
 
-export interface ILemmyConfig {
-  wsHost?: string;
-}
-
 declare global {
   interface Window {
     isoData: IsoData;
-    lemmyConfig?: ILemmyConfig;
   }
 }