Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
6 changes: 6 additions & 0 deletions apps/trustlab/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { withPayload } from "@payloadcms/next/withPayload";
import { fileURLToPath } from "url";
import path from "path";

import { securityHeaders } from "./src/utils/securityHeaders.mjs";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Expand Down Expand Up @@ -63,6 +65,10 @@ const nextConfig = {
],
async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
{
source: "/api/media/file/:path*",
headers: [
Expand Down
3 changes: 2 additions & 1 deletion apps/trustlab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"require-in-the-middle": "catalog:payload-v3",
"sharp": "catalog:",
"swr": "catalog:",
"validate-color": "catalog:"
"validate-color": "catalog:",
"xss": "catalog:"
},
"devDependencies": {
"@commons-ui/testing-library": "workspace:*",
Expand Down
20 changes: 10 additions & 10 deletions apps/trustlab/payload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,17 @@ import { defaultLocale, locales } from "@/trustlab/payload/utils/locales";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const defaultAllowedOrigin = site.url.replace(/\/+$/, "");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
const defaultAllowedOrigin = site.url.replace(/\/+$/, "");
const serverUrl = normalizeOrigins(site.url);
  1. I think serverUrl is much clearer than defaultAllowedOrigin
  2. Use the created normalizeOrigin to trim trailing / (should it be normalize or normalise)?


const cors =
process.env.PAYLOAD_CORS?.split(",")
const normalizeOrigins = (value?: string) =>
value
?.split(",")
.map((d) => d.trim())
.map((d) => d.replace(/\/+$/, ""))
.filter(Boolean) ?? [];

const csrf =
process.env.PAYLOAD_CSRF?.split(",")
.map((d) => d.trim())
.filter(Boolean) ?? [];
const cors = normalizeOrigins(process.env.PAYLOAD_CORS);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

May be:

Suggested change
const cors = normalizeOrigins(process.env.PAYLOAD_CORS);
const cors = normalizeOrigins(process.env.PAYLOAD_CORS?.trim() || serverUrl);

So that there is no need to check for if the return array is empty later on?

const csrf = normalizeOrigins(process.env.PAYLOAD_CSRF);

let nodemailerAdapterArgs: NodemailerAdapterArgs | undefined;
if (process.env.SMTP_HOST && process.env.SMTP_PASS) {
Expand Down Expand Up @@ -87,8 +88,8 @@ export default buildConfig({
Users,
Opportunities,
] as CollectionConfig[],
cors,
csrf,
cors: cors.length ? cors : [defaultAllowedOrigin],
csrf: csrf.length ? csrf : [defaultAllowedOrigin],
db: mongooseAdapter({
url: process.env.DATABASE_URL ?? false,
}),
Expand All @@ -106,8 +107,7 @@ export default buildConfig({
: undefined),
plugins: [...plugins],
secret: process.env.PAYLOAD_SECRET || "",
// Just the origin, without trailing slash.
serverURL: site.url.slice(0, -1),
serverURL: defaultAllowedOrigin,
sharp,
telemetry: process.env.NEXT_TELEMETRY_DISABLED === "0",
typescript: {
Expand Down
6 changes: 4 additions & 2 deletions apps/trustlab/src/components/ActionBanner/ActionBanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import React, { forwardRef, useState } from "react";

import CloseIcon from "@/trustlab/assets/icons/close.svg";
import Button from "@/trustlab/components/StyledButton";
import sanitizeEmbedHtml from "@/trustlab/utils/sanitizeEmbedHtml";

const ActionBanner = forwardRef(function ActionBanner(
{
Expand All @@ -28,7 +29,8 @@ const ActionBanner = forwardRef(function ActionBanner(
},
ref,
) {
const hasEmbed = Boolean(embedCode);
const sanitizedEmbedCode = sanitizeEmbedHtml(embedCode);
const hasEmbed = Boolean(sanitizedEmbedCode);
const [open, setOpen] = useState(false);

const handleOpen = () => {
Expand Down Expand Up @@ -145,7 +147,7 @@ const ActionBanner = forwardRef(function ActionBanner(
</DialogTitle>
<DialogContent>
<Box
dangerouslySetInnerHTML={{ __html: embedCode }}
dangerouslySetInnerHTML={{ __html: sanitizedEmbedCode }}
sx={{
width: "100%",
"& iframe": {
Expand Down
7 changes: 5 additions & 2 deletions apps/trustlab/src/components/HelplineCard/HelplineCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import React, { useState } from "react";

import HelplineEmbedDialog from "./HelplineEmbedDialog";

import sanitizeEmbedHtml from "@/trustlab/utils/sanitizeEmbedHtml";

function HelplineCard({
title,
icon: media,
Expand All @@ -23,7 +25,8 @@ function HelplineCard({
embedButtonLabel,
embedCloseLabel,
}) {
const hasEmbed = Boolean(embedCode);
const sanitizedEmbedCode = sanitizeEmbedHtml(embedCode);
const hasEmbed = Boolean(sanitizedEmbedCode);
const [open, setOpen] = useState(false);
const buttonLabel = embedButtonLabel || link?.label || title;

Expand Down Expand Up @@ -124,7 +127,7 @@ function HelplineCard({
</CardActions>
<HelplineEmbedDialog
closeLabel={embedCloseLabel}
embedCode={embedCode}
embedCode={sanitizedEmbedCode}
onClose={handleClose}
open={open}
title={title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import React from "react";

function HelplineEmbedDialog({ closeLabel, embedCode, onClose, open, title }) {
const dialogTitleId = React.useId();
const hasEmbed = Boolean(embedCode);

return (
<Dialog
aria-labelledby={dialogTitleId}
fullWidth
maxWidth={false}
onClose={onClose}
open={Boolean(embedCode && open)}
open={Boolean(hasEmbed && open)}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't this used inside HelplineCard and we've already checked embedCode, etc. there?

PaperProps={{
sx: {
width: "100%",
Expand Down Expand Up @@ -47,7 +48,7 @@ function HelplineEmbedDialog({ closeLabel, embedCode, onClose, open, title }) {
},
}}
dangerouslySetInnerHTML={
embedCode
hasEmbed
? {
__html: embedCode,
}
Expand Down
6 changes: 4 additions & 2 deletions apps/trustlab/src/components/RowCard/RowCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useState, forwardRef } from "react";
import RowCardActionButton from "./RowCardActionButton";

import HelplineEmbedDialog from "@/trustlab/components/HelplineCard/HelplineEmbedDialog";
import sanitizeEmbedHtml from "@/trustlab/utils/sanitizeEmbedHtml";

const RowCard = forwardRef(function RowCard(props, ref) {
const {
Expand All @@ -18,7 +19,8 @@ const RowCard = forwardRef(function RowCard(props, ref) {
embedButtonLabel,
embedCloseLabel,
} = props;
const hasEmbed = Boolean(embedCode);
const sanitizedEmbedCode = sanitizeEmbedHtml(embedCode);
const hasEmbed = Boolean(sanitizedEmbedCode);
const [open, setOpen] = useState(false);
const buttonLabel = embedButtonLabel || actionLabel;

Expand Down Expand Up @@ -109,7 +111,7 @@ const RowCard = forwardRef(function RowCard(props, ref) {
</Stack>
<HelplineEmbedDialog
closeLabel={embedCloseLabel}
embedCode={embedCode}
embedCode={sanitizedEmbedCode}
onClose={handleClose}
open={open}
title={title}
Expand Down
37 changes: 37 additions & 0 deletions apps/trustlab/src/utils/sanitizeEmbedHtml.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { FilterXSS } from "xss";

const embedSanitizer = new FilterXSS({
whiteList: {
a: ["href", "target", "rel", "aria-label"],
br: [],
div: ["class"],
iframe: [
"allow",
"allowfullscreen",
"class",
"frameborder",
"height",
"loading",
"name",
"referrerpolicy",
"sandbox",
"scrolling",
"src",
"title",
"width",
],
p: ["class"],
section: ["class"],
span: ["class"],
},
stripIgnoreTag: true,
stripIgnoreTagBody: ["script", "style"],
});

export default function sanitizeEmbedHtml(html) {
if (!html?.trim()) {
return "";
}

return embedSanitizer.process(html);
}
67 changes: 67 additions & 0 deletions apps/trustlab/src/utils/securityHeaders.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
function getContentSecurityPolicy(nodeEnv) {
const isProduction = nodeEnv === "production";
const connectSrc = ["'self'", "https:"];
const scriptSrc = ["'self'", "'unsafe-inline'", "https:"];

// Next.js dev tooling relies on websocket connections and eval-based refresh
// helpers. Keep those allowances scoped to non-production environments.
if (!isProduction) {
connectSrc.push("http:", "ws:", "wss:");
scriptSrc.splice(1, 0, "'unsafe-eval'");
}

return [
"default-src 'self'",
"base-uri 'self'",
`connect-src ${connectSrc.join(" ")}`,
"font-src 'self' data: https:",
"form-action 'self'",
"frame-ancestors 'self'",
"frame-src 'self' https:",
"img-src 'self' data: blob: https:",
"media-src 'self' blob: https:",
"object-src 'none'",
// Inline script/style support is still required by the current app and MUI
// setup, so this policy tightens sources without breaking rendering.
`script-src ${scriptSrc.join(" ")}`,
"style-src 'self' 'unsafe-inline' https:",
Comment on lines +28 to +29
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Aren't script-src and style-src just the same value?

].join("; ");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why not just use

  `
    ...;
    ...;
  `

on the whole thing instead of

  [
    '...',
    '...',
  ].join('; ')

?

}

export function getSecurityHeaders(nodeEnv = process.env.NODE_ENV) {
const headers = [
{
key: "Content-Security-Policy",
value: getContentSecurityPolicy(nodeEnv),
},
{
key: "Permissions-Policy",
value: "camera=(), geolocation=(), microphone=(), payment=(), usb=()",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
];

if (nodeEnv === "production") {
// HSTS is intentionally production-only so local HTTP development and
// non-TLS preview setups do not get sticky HTTPS behavior.
headers.push({
key: "Strict-Transport-Security",
Comment thread
kelvinkipruto marked this conversation as resolved.
Outdated
value: "max-age=31536000; includeSubDomains; preload",
});
}

return headers;
}

export const securityHeaders = getSecurityHeaders();
36 changes: 21 additions & 15 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ catalog:
videojs-youtube: ^3.0.1
webpack: ^5.93.0
xlsx: ^0.18.5
xss: ^1.0.15
yup: ^0.32.11
catalogs:
# @mui/styles was deprecated with the release of MUI Core v5 in late 2021
Expand Down
Loading