🔒 feat: Add Content Security Policy using Helmet middleware (#7377)

* 🔒 feat: Add Content Security Policy using Helmet middleware

* 🔒 feat: Set trust proxy and refine Content Security Policy directives

* 🎨 feat: add `copy-tex` to improve copying KaTeX (#7308)

When selecting equations and using copy paste, uses the correct latex code.

Co-authored-by: Ruben Talstra <RubenTalstra1211@outlook.com>

* 🔃 refactor: `AgentFooter` to conditionally render buttons based on `activePanel` (#7306)

* 🚀 feat: Add `Cloudflare Turnstile` support (#5987)

* 🚀 feat: Add @marsidev/react-turnstile dependency to package.json and package-lock.json

* 🚀 feat: Integrate Cloudflare Turnstile configuration support in AppService and add schema validation

* 🚀 feat: Implemented Cloudflare Turnstile integration in Login and Registration forms

* 🚀 feat: Enhance AppService tests with additional mocks and configuration setups

* 🚀 feat: Comment out outdated config version warning tests in AppService.spec.js

* 🚀 feat: Remove outdated warning tests and add new checks for environment variables and API health

* 🔧 test: Update AppService.spec.js to use expect.anything() for paths validation

* 🔧 test: Refactor AppService.spec.js to streamline mocks and enhance clarity

* 🔧 chore: removed not needed test

* Potential fix for code scanning alert no. 5638: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5629: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5642: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Update turnstile.js

* Potential fix for code scanning alert no. 5634: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5646: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5647: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* 🔒 feat: Refactor Content Security Policy setup to use Helmet middleware with custom directives

* 🔒 feat: Enhance Content Security Policy to include Sandpack Bundler URL

* 🔒 feat: Update Content Security Policy and integrate Turnstile captcha support

---------

Co-authored-by: andresgit <9771158+andresgit@users.noreply.github.com>
Co-authored-by: matt burnett <mawburn@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Talstra 2025-05-15 22:25:10 +02:00 committed by GitHub
parent fe311df969
commit 7a91f6ca62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 93 additions and 60 deletions

View file

@ -71,6 +71,7 @@
"firebase": "^11.0.2", "firebase": "^11.0.2",
"googleapis": "^126.0.1", "googleapis": "^126.0.1",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"helmet": "^8.1.0",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",

View file

@ -2,6 +2,7 @@ require('dotenv').config();
const path = require('path'); const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..') }); require('module-alias')({ base: path.resolve(__dirname, '..') });
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet');
const axios = require('axios'); const axios = require('axios');
const express = require('express'); const express = require('express');
const compression = require('compression'); const compression = require('compression');
@ -22,7 +23,15 @@ const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex'); const noIndex = require('./middleware/noIndex');
const routes = require('./routes'); const routes = require('./routes');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {}; const {
PORT,
HOST,
ALLOW_SOCIAL_LOGIN,
DISABLE_COMPRESSION,
TRUST_PROXY,
SANDPACK_BUNDLER_URL,
SANDPACK_STATIC_BUNDLER_URL,
} = process.env ?? {};
const port = Number(PORT) || 3080; const port = Number(PORT) || 3080;
const host = HOST || 'localhost'; const host = HOST || 'localhost';
@ -38,6 +47,8 @@ const startServer = async () => {
const app = express(); const app = express();
app.disable('x-powered-by'); app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
await AppService(app); await AppService(app);
const indexPath = path.join(app.locals.paths.dist, 'index.html'); const indexPath = path.join(app.locals.paths.dist, 'index.html');
@ -49,23 +60,54 @@ const startServer = async () => {
app.use(noIndex); app.use(noIndex);
app.use(errorController); app.use(errorController);
app.use(express.json({ limit: '3mb' })); app.use(express.json({ limit: '3mb' }));
app.use(mongoSanitize());
app.use(express.urlencoded({ extended: true, limit: '3mb' })); app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(staticCache(app.locals.paths.dist)); app.use(mongoSanitize());
app.use(staticCache(app.locals.paths.fonts));
app.use(staticCache(app.locals.paths.assets));
app.set('trust proxy', trusted_proxy);
app.use(cors()); app.use(cors());
app.use(cookieParser()); app.use(cookieParser());
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://challenges.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'"],
fontSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
imgSrc: ["'self'", 'data:'],
mediaSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"],
frameSrc: [
"'self'",
'https://challenges.cloudflare.com',
'https://codesandbox.io',
...(SANDPACK_BUNDLER_URL ? [SANDPACK_BUNDLER_URL] : []),
...(SANDPACK_STATIC_BUNDLER_URL ? [SANDPACK_STATIC_BUNDLER_URL] : []),
],
frameAncestors: [
"'self'",
'https://codesandbox.io',
...(SANDPACK_BUNDLER_URL ? [SANDPACK_BUNDLER_URL] : []),
...(SANDPACK_STATIC_BUNDLER_URL ? [SANDPACK_STATIC_BUNDLER_URL] : []),
],
},
},
}),
);
if (!isEnabled(DISABLE_COMPRESSION)) { if (!isEnabled(DISABLE_COMPRESSION)) {
app.use(compression()); app.use(compression());
} else {
console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
} }
// Serve static assets with aggressive caching
app.use(staticCache(app.locals.paths.dist));
app.use(staticCache(app.locals.paths.fonts));
app.use(staticCache(app.locals.paths.assets));
if (!ALLOW_SOCIAL_LOGIN) { if (!ALLOW_SOCIAL_LOGIN) {
console.warn( console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.',
);
} }
/* OAUTH */ /* OAUTH */
@ -128,7 +170,7 @@ const startServer = async () => {
}); });
app.listen(port, host, () => { app.listen(port, host, () => {
if (host == '0.0.0.0') { if (host === '0.0.0.0') {
logger.info( logger.info(
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`, `Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
); );

View file

@ -26,7 +26,16 @@ function loadTurnstileConfig(config, configDefaults) {
options: customTurnstile.options ?? defaults.options, options: customTurnstile.options ?? defaults.options,
}); });
logger.info('Turnstile configuration loaded:\n' + JSON.stringify(loadedTurnstile, null, 2)); const enabled = Boolean(loadedTurnstile.siteKey);
if (enabled) {
logger.info(
'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2),
);
} else {
logger.info('Turnstile is DISABLED (no siteKey provided).');
}
return loadedTurnstile; return loadedTurnstile;
} }

View file

@ -16,6 +16,7 @@ type TLoginFormProps = {
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => { const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
const localize = useLocalize(); const localize = useLocalize();
const { theme } = useContext(ThemeContext); const { theme } = useContext(ThemeContext);
const { const {
register, register,
getValues, getValues,
@ -28,6 +29,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
const { data: config } = useGetStartupConfig(); const { data: config } = useGetStartupConfig();
const useUsernameLogin = config?.ldap?.username; const useUsernameLogin = config?.ldap?.username;
const validTheme = theme === 'dark' ? 'dark' : 'light'; const validTheme = theme === 'dark' ? 'dark' : 'light';
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
useEffect(() => { useEffect(() => {
if (error && error.includes('422') && !showResendLink) { if (error && error.includes('422') && !showResendLink) {
@ -100,20 +102,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
}, },
})} })}
aria-invalid={!!errors.email} aria-invalid={!!errors.email}
className=" className="peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary transition-colors duration-200 focus:border-green-500 focus:outline-none"
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
placeholder=" " placeholder=" "
/> />
<label <label
htmlFor="email" htmlFor="email"
className=" className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500"
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
> >
{useUsernameLogin {useUsernameLogin
? localize('com_auth_username').replace(/ \(.*$/, '') ? localize('com_auth_username').replace(/ \(.*$/, '')
@ -135,20 +129,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
maxLength: { value: 128, message: localize('com_auth_password_max_length') }, maxLength: { value: 128, message: localize('com_auth_password_max_length') },
})} })}
aria-invalid={!!errors.password} aria-invalid={!!errors.password}
className=" className="peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary transition-colors duration-200 focus:border-green-500 focus:outline-none"
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
placeholder=" " placeholder=" "
/> />
<label <label
htmlFor="password" htmlFor="password"
className=" className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500"
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
> >
{localize('com_auth_password')} {localize('com_auth_password')}
</label> </label>
@ -164,16 +150,15 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
</a> </a>
)} )}
{/* Render Turnstile only if enabled in startupConfig */} {requireCaptcha && (
{startupConfig.turnstile && (
<div className="my-4 flex justify-center"> <div className="my-4 flex justify-center">
<Turnstile <Turnstile
siteKey={startupConfig.turnstile.siteKey} siteKey={startupConfig.turnstile!.siteKey}
options={{ options={{
...startupConfig.turnstile.options, ...startupConfig.turnstile!.options,
theme: validTheme, theme: validTheme,
}} }}
onSuccess={(token) => setTurnstileToken(token)} onSuccess={setTurnstileToken}
onError={() => setTurnstileToken(null)} onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)} onExpire={() => setTurnstileToken(null)}
/> />
@ -185,11 +170,8 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
aria-label={localize('com_auth_continue')} aria-label={localize('com_auth_continue')}
data-testid="login-button" data-testid="login-button"
type="submit" type="submit"
disabled={startupConfig.turnstile ? !turnstileToken : false} disabled={requireCaptcha && !turnstileToken}
className=" className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
"
> >
{localize('com_auth_continue')} {localize('com_auth_continue')}
</button> </button>

View file

@ -33,6 +33,9 @@ const Registration: React.FC = () => {
const token = queryParams.get('token'); const token = queryParams.get('token');
const validTheme = theme === 'dark' ? 'dark' : 'light'; const validTheme = theme === 'dark' ? 'dark' : 'light';
// only require captcha if we have a siteKey
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
const registerUser = useRegisterUserMutation({ const registerUser = useRegisterUserMutation({
onMutate: () => { onMutate: () => {
setIsSubmitting(true); setIsSubmitting(true);
@ -73,21 +76,13 @@ const Registration: React.FC = () => {
validation, validation,
)} )}
aria-invalid={!!errors[id]} aria-invalid={!!errors[id]}
className=" className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
placeholder=" " placeholder=" "
data-testid={id} data-testid={id}
/> />
<label <label
htmlFor={id} htmlFor={id}
className=" className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
> >
{localize(label)} {localize(label)}
</label> </label>
@ -183,8 +178,7 @@ const Registration: React.FC = () => {
value === password || localize('com_auth_password_not_match'), value === password || localize('com_auth_password_not_match'),
})} })}
{/* Render Turnstile only if enabled in startupConfig */} {startupConfig?.turnstile?.siteKey && (
{startupConfig?.turnstile && (
<div className="my-4 flex justify-center"> <div className="my-4 flex justify-center">
<Turnstile <Turnstile
siteKey={startupConfig.turnstile.siteKey} siteKey={startupConfig.turnstile.siteKey}
@ -204,16 +198,11 @@ const Registration: React.FC = () => {
disabled={ disabled={
Object.keys(errors).length > 0 || Object.keys(errors).length > 0 ||
isSubmitting || isSubmitting ||
(startupConfig?.turnstile ? !turnstileToken : false) (requireCaptcha && !turnstileToken)
} }
type="submit" type="submit"
aria-label="Submit registration" aria-label="Submit registration"
className=" className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
> >
{isSubmitting ? <Spinner /> : localize('com_auth_continue')} {isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</button> </button>

10
package-lock.json generated
View file

@ -87,6 +87,7 @@
"firebase": "^11.0.2", "firebase": "^11.0.2",
"googleapis": "^126.0.1", "googleapis": "^126.0.1",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"helmet": "^8.1.0",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@ -31510,6 +31511,15 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/highlight.js": { "node_modules/highlight.js": {
"version": "11.8.0", "version": "11.8.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",