diff --git a/api/package.json b/api/package.json index bcf94a6cad..8782457dbc 100644 --- a/api/package.json +++ b/api/package.json @@ -71,6 +71,7 @@ "firebase": "^11.0.2", "googleapis": "^126.0.1", "handlebars": "^4.7.7", + "helmet": "^8.1.0", "https-proxy-agent": "^7.0.6", "ioredis": "^5.3.2", "js-yaml": "^4.1.0", diff --git a/api/server/index.js b/api/server/index.js index cd0bdd3f88..3c8d3dd951 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -2,6 +2,7 @@ require('dotenv').config(); const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..') }); const cors = require('cors'); +const helmet = require('helmet'); const axios = require('axios'); const express = require('express'); const compression = require('compression'); @@ -22,7 +23,15 @@ const staticCache = require('./utils/staticCache'); const noIndex = require('./middleware/noIndex'); 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 host = HOST || 'localhost'; @@ -38,6 +47,8 @@ const startServer = async () => { const app = express(); app.disable('x-powered-by'); + app.set('trust proxy', trusted_proxy); + await AppService(app); const indexPath = path.join(app.locals.paths.dist, 'index.html'); @@ -49,23 +60,54 @@ const startServer = async () => { app.use(noIndex); app.use(errorController); app.use(express.json({ limit: '3mb' })); - app.use(mongoSanitize()); app.use(express.urlencoded({ extended: true, limit: '3mb' })); - app.use(staticCache(app.locals.paths.dist)); - app.use(staticCache(app.locals.paths.fonts)); - app.use(staticCache(app.locals.paths.assets)); - app.set('trust proxy', trusted_proxy); + app.use(mongoSanitize()); app.use(cors()); 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)) { 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) { - console.warn( - 'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.', - ); + console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.'); } /* OAUTH */ @@ -128,7 +170,7 @@ const startServer = async () => { }); app.listen(port, host, () => { - if (host == '0.0.0.0') { + if (host === '0.0.0.0') { logger.info( `Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`, ); diff --git a/api/server/services/start/turnstile.js b/api/server/services/start/turnstile.js index ffd4545dae..be9e5f83c7 100644 --- a/api/server/services/start/turnstile.js +++ b/api/server/services/start/turnstile.js @@ -26,7 +26,16 @@ function loadTurnstileConfig(config, configDefaults) { 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; } diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 030b6323f7..e34ec3c94d 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -16,6 +16,7 @@ type TLoginFormProps = { const LoginForm: React.FC = ({ onSubmit, startupConfig, error, setError }) => { const localize = useLocalize(); const { theme } = useContext(ThemeContext); + const { register, getValues, @@ -28,6 +29,7 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, const { data: config } = useGetStartupConfig(); const useUsernameLogin = config?.ldap?.username; const validTheme = theme === 'dark' ? 'dark' : 'light'; + const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey); useEffect(() => { if (error && error.includes('422') && !showResendLink) { @@ -100,20 +102,12 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, }, })} aria-invalid={!!errors.email} - 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 - " + 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" placeholder=" " />