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 = ({