From 397bb3bd08eba477f5f85b17fb5ca803ceb52b01 Mon Sep 17 00:00:00 2001
From: MaryWylde
Date: Tue, 12 May 2026 17:43:41 +0400
Subject: [PATCH] chore(security): sanitize CMS HTML, add security headers,
refine auth UI
Co-Authored-By: Claude Opus 4.7 (1M context)
---
next.config.js | 59 ++++
package.json | 2 +
src/api/auth.ts | 49 +++
.../AboutProjects/AboutProjects.tsx | 8 +-
src/components/ArticleInfo/ArticleInfo.tsx | 6 +-
.../ContentParser/ContentParser.tsx | 3 +-
src/components/Header/Header.tsx | 6 +-
src/components/Headline/Headline.tsx | 14 +-
src/components/SeoGenerator/SeoGenerator.tsx | 7 +-
.../SettingsModal/SettingsModal.module.scss | 61 ++++
.../SettingsModal/SettingsModal.tsx | 193 +++++++++--
.../SupporterContainer/SupporterContainer.tsx | 6 +-
.../ToolContainer/ToolContainer.tsx | 4 +-
.../PyramidAuthors/PyramidAuthors.tsx | 4 +-
.../PyramidInfoSection/PyramidInfoSection.tsx | 10 +-
.../EnvironmentSubSection.tsx | 4 +-
.../longevity/FlipCard/FlipCard.tsx | 6 +-
.../longevity/HTMLClamp/HTMLClamp.tsx | 4 +-
.../LongevitySubSection.tsx | 4 +-
.../MainInfoSection/MainInfoSection.tsx | 4 +-
.../longevity/StudySection/StudySection.tsx | 6 +-
.../longevity/Supplement/Supplement.tsx | 4 +-
.../AboutTheProduct/AboutTheProduct.tsx | 4 +-
.../WhatToEatOrAvoid/WhatToEatOrAvoid.tsx | 4 +-
.../WhyDoThisTooltip/WhyDoThisTooltip.tsx | 4 +-
src/data/auth/en.ts | 44 +++
src/data/auth/ru.ts | 44 +++
.../ContributorsLayout/ContributorsLayout.tsx | 6 +-
src/layouts/ResultsLayout/ResultsLayout.tsx | 6 +-
src/layouts/WorkoutLayout/WorkoutLayout.tsx | 8 +-
src/lib/sanitizeHtml.ts | 9 +
src/lib/settings-helpers.ts | 10 +
src/pages/auth/email-change.tsx | 173 ++++++++++
yarn.lock | 310 +++++++++++++++++-
34 files changed, 1013 insertions(+), 73 deletions(-)
create mode 100644 src/lib/sanitizeHtml.ts
create mode 100644 src/pages/auth/email-change.tsx
diff --git a/next.config.js b/next.config.js
index a537c441..6243bfca 100644
--- a/next.config.js
+++ b/next.config.js
@@ -23,6 +23,65 @@ module.exports = withBundleAnalyzer({
{ source: '/robots.txt', destination: '/keepsimple_/robots.txt' },
];
},
+ async headers() {
+ const isDev = process.env.NODE_ENV !== 'production';
+ const scriptSrc = [
+ "'self'",
+ "'unsafe-inline'",
+ // Next.js dev mode (Fast Refresh) requires eval.
+ isDev ? "'unsafe-eval'" : '',
+ 'https://analytics.ahrefs.com',
+ 'https://www.googletagmanager.com',
+ 'https://www.google-analytics.com',
+ 'https://cdn.mxpnl.com',
+ ]
+ .filter(Boolean)
+ .join(' ');
+ const connectSrc = [
+ "'self'",
+ // Next.js dev HMR uses ws:// to localhost.
+ isDev ? 'ws:' : '',
+ 'https://*.keepsimple.io',
+ 'https://metrics.administration.ae',
+ 'https://api.mixpanel.com',
+ 'https://www.google-analytics.com',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return [
+ {
+ source: '/:path*',
+ headers: [
+ {
+ key: 'Strict-Transport-Security',
+ value: 'max-age=63072000; includeSubDomains; preload',
+ },
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ { key: 'X-Frame-Options', value: 'DENY' },
+ {
+ key: 'Permissions-Policy',
+ value: 'camera=(), microphone=(), geolocation=()',
+ },
+ {
+ key: 'Content-Security-Policy',
+ value: [
+ "default-src 'self'",
+ `script-src ${scriptSrc}`,
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob: https://lh3.googleusercontent.com https://cdn.discordapp.com https://strapi.keepsimple.io https://staging-strapi.keepsimple.io https://www.google-analytics.com",
+ "font-src 'self' data:",
+ `connect-src ${connectSrc}`,
+ "frame-ancestors 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ ].join('; '),
+ },
+ ],
+ },
+ ];
+ },
env: {
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
},
diff --git a/package.json b/package.json
index dcf50f54..16b5b140 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"eslint-plugin-simple-import-sort": "^12.1.1",
"geoip-lite": "1.4.2",
"html2canvas": "^1.4.1",
+ "isomorphic-dompurify": "^3.12.0",
"lodash.debounce": "4.0.8",
"lodash.unescape": "4.0.1",
"mixpanel-browser": "^2.65.0",
@@ -53,6 +54,7 @@
"react-slick": "0.29.0",
"react-tooltip": "5.27.1",
"rehype-raw": "6.1.1",
+ "rehype-sanitize": "^6.0.0",
"remark-breaks": "3.0.2",
"sass": "1.32.8",
"slick-carousel": "1.8.1",
diff --git a/src/api/auth.ts b/src/api/auth.ts
index b257248f..619a23e7 100644
--- a/src/api/auth.ts
+++ b/src/api/auth.ts
@@ -179,3 +179,52 @@ export const completeMagicLinkRegistration = async ({
return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message };
}
};
+
+const twitterEmailChangeUrl = (path: string) =>
+ `${process.env.NEXT_PUBLIC_STRAPI}/api/auth/twitter/email-change/${path}`;
+
+export const requestTwitterEmailChange = async ({
+ email,
+ locale,
+ token,
+}: {
+ email: string;
+ locale: MagicLinkLocale;
+ token: string;
+}): Promise> => {
+ try {
+ const response = await fetch(twitterEmailChangeUrl('request'), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ email, locale }),
+ });
+ if (!response.ok) {
+ return { ok: false, ...(await parseError(response)) };
+ }
+ return { ok: true, data: null };
+ } catch (e: any) {
+ return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message };
+ }
+};
+
+export const confirmTwitterEmailChange = async (
+ token: string,
+): Promise> => {
+ try {
+ const response = await fetch(twitterEmailChangeUrl('confirm'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token }),
+ });
+ if (!response.ok) {
+ return { ok: false, ...(await parseError(response)) };
+ }
+ const data = await response.json();
+ return { ok: true, data };
+ } catch (e: any) {
+ return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message };
+ }
+};
diff --git a/src/components/AboutProjects/AboutProjects.tsx b/src/components/AboutProjects/AboutProjects.tsx
index 2f1c8bb8..bd4e2641 100644
--- a/src/components/AboutProjects/AboutProjects.tsx
+++ b/src/components/AboutProjects/AboutProjects.tsx
@@ -2,6 +2,8 @@ import cn from 'classnames';
import { FC } from 'react';
import { useInView } from 'react-intersection-observer';
+import { sanitizeHtml } from '@lib/sanitizeHtml';
+
import styles from './AboutProjects.module.scss';
type projectsProps = {
@@ -35,7 +37,11 @@ const AboutProjects: FC = ({ projects, darkTheme }) => {
})}
>
{project.project_name}
-
+
))}
diff --git a/src/components/ArticleInfo/ArticleInfo.tsx b/src/components/ArticleInfo/ArticleInfo.tsx
index 1bc68da0..1e06f7f2 100644
--- a/src/components/ArticleInfo/ArticleInfo.tsx
+++ b/src/components/ArticleInfo/ArticleInfo.tsx
@@ -8,6 +8,8 @@ import { Tooltip as ReactTooltip } from 'react-tooltip';
import { useIsWidthLessThan } from '@hooks/useScreenSize';
+import { sanitizeHtml } from '@lib/sanitizeHtml';
+
import ArticleTag from '@components/articles/ArticleTag';
import { GlobalContext } from '@components/Context/GlobalContext';
@@ -133,7 +135,7 @@ const ArticleInfo: FC = ({
@@ -147,7 +149,7 @@ const ArticleInfo: FC = ({
})}
>
diff --git a/src/components/ContentParser/ContentParser.tsx b/src/components/ContentParser/ContentParser.tsx
index 643fa751..e3d1bca3 100644
--- a/src/components/ContentParser/ContentParser.tsx
+++ b/src/components/ContentParser/ContentParser.tsx
@@ -2,6 +2,7 @@ import unescape from 'lodash.unescape';
import { FC, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
+import rehypeSanitize from 'rehype-sanitize';
import remarkBreaks from 'remark-breaks';
import useContentType from '@hooks/useContentType';
@@ -31,7 +32,7 @@ const ContentParser: FC = ({
className={styles.content}
components={componentList}
remarkPlugins={[[remarkBreaks]]}
- rehypePlugins={[rehypeRaw]}
+ rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{modifiedData}
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
index 02a699a6..7d9ac8ae 100644
--- a/src/components/Header/Header.tsx
+++ b/src/components/Header/Header.tsx
@@ -76,7 +76,6 @@ const Header: FC = () => {
isEmailPublic: string,
isLinkedinPublic: string,
title?: string,
- email?: string,
) => {
const mailIsPublic = isEmailPublic === 'everyone';
const linkedInIsPublic = isLinkedinPublic === 'everyone';
@@ -88,10 +87,6 @@ const Header: FC = () => {
mailIsPublic,
linkedInIsPublic,
title,
- undefined,
- undefined,
- undefined,
- email,
);
const data = await getMyInfo();
@@ -269,6 +264,7 @@ const Header: FC = () => {
linkedin={accountData?.linkedIn}
linkedinStatus={accountData?.publicLinkedin}
provider={accountData?.provider}
+ token={token}
handleSaveClick={handleSaveClick}
setUsernameIsTakenError={setUsernameIsTakenError}
usernameIsTakenError={usernameIsTakenError}
diff --git a/src/components/Headline/Headline.tsx b/src/components/Headline/Headline.tsx
index d7fea65a..5222b59b 100644
--- a/src/components/Headline/Headline.tsx
+++ b/src/components/Headline/Headline.tsx
@@ -15,6 +15,8 @@ import { socialMediaLinks } from '@constants/common';
import { TRouter } from '@local-types/global';
+import { sanitizeHtml } from '@lib/sanitizeHtml';
+
import contributors from '@data/contributors';
import AudioPlayer from '@components/AudioPlayer';
@@ -264,7 +266,9 @@ const Headline: FC = ({ headline, darkTheme, russianView }) => {
[styles.fadeOut]: fadeOutIndexes.includes(1),
[styles.fadeIn]: fadeInIndexes.includes(1),
})}
- dangerouslySetInnerHTML={{ __html: highlightedText }}
+ dangerouslySetInnerHTML={{
+ __html: sanitizeHtml(highlightedText),
+ }}
>
)}
{title && secondDescription && (
@@ -277,7 +281,9 @@ const Headline: FC = ({ headline, darkTheme, russianView }) => {
[styles.fadeIn]:
fadeInIndexes.includes(2) && !fadeOutIndexes.includes(2),
})}
- dangerouslySetInnerHTML={{ __html: secondDescription }}
+ dangerouslySetInnerHTML={{
+ __html: sanitizeHtml(secondDescription),
+ }}
>
)}
{title && lastDescription && (
@@ -290,7 +296,9 @@ const Headline: FC = ({ headline, darkTheme, russianView }) => {
fadeOutIndexes.includes(3) && !fadeInIndexes.includes(3),
[styles.fadeIn]: fadeInIndexes.includes(3),
})}
- dangerouslySetInnerHTML={{ __html: lastDescription }}
+ dangerouslySetInnerHTML={{
+ __html: sanitizeHtml(lastDescription),
+ }}
>
)}
diff --git a/src/components/SeoGenerator/SeoGenerator.tsx b/src/components/SeoGenerator/SeoGenerator.tsx
index e66f745e..f7563830 100644
--- a/src/components/SeoGenerator/SeoGenerator.tsx
+++ b/src/components/SeoGenerator/SeoGenerator.tsx
@@ -294,7 +294,12 @@ const SeoGenerator: FC = ({
/g, '\\u003e')
+ .replace(/&/g, '\\u0026'),
+ }}
/>