diff --git a/packages/core/content-manager/server/src/preview/services/preview-config.ts b/packages/core/content-manager/server/src/preview/services/preview-config.ts index fc64479749..10205f0ef9 100644 --- a/packages/core/content-manager/server/src/preview/services/preview-config.ts +++ b/packages/core/content-manager/server/src/preview/services/preview-config.ts @@ -1,7 +1,5 @@ -import { mergeWith } from 'lodash/fp'; - import type { Core, UID } from '@strapi/types'; -import { errors } from '@strapi/utils'; +import { errors, extendMiddlewareConfiguration } from '@strapi/utils'; export type HandlerParams = { documentId: string; @@ -18,40 +16,6 @@ export interface PreviewConfig { }; } -/** - * Utility to extend Strapi configuration middlewares. Mainly used to extend the CSP directives from the security middleware. - */ -const extendMiddlewareConfiguration = (middleware = { name: '', config: {} }) => { - const middlewares = strapi.config.get('middlewares') as (string | object)[]; - - const configuredMiddlewares = middlewares.map((currentMiddleware) => { - if (currentMiddleware === middleware.name) { - // Use the new config object if the middleware has no config property yet - return middleware; - } - - // @ts-expect-error - currentMiddleware is not a string - if (currentMiddleware.name === middleware.name) { - // Deep merge (+ concat arrays) the new config with the current middleware config - return mergeWith( - (objValue, srcValue) => { - if (Array.isArray(objValue)) { - return objValue.concat(srcValue); - } - - return undefined; - }, - currentMiddleware, - middleware - ); - } - - return currentMiddleware; - }); - - strapi.config.set('middlewares', configuredMiddlewares); -}; - /** * Read configuration for static preview */ @@ -68,7 +32,12 @@ const createPreviewConfigService = ({ strapi }: { strapi: Core.Strapi }) => { * Register the allowed origins for CSP, so the preview URL can be displayed */ if (config.config?.allowedOrigins) { - extendMiddlewareConfiguration({ + const middlewares = strapi.config.get('middlewares') as ( + | string + | { name?: string; config?: any } + )[]; + + const configuredMiddlewares = extendMiddlewareConfiguration(middlewares, { name: 'strapi::security', config: { contentSecurityPolicy: { @@ -78,6 +47,8 @@ const createPreviewConfigService = ({ strapi }: { strapi: Core.Strapi }) => { }, }, }); + + strapi.config.set('middlewares', configuredMiddlewares); } }, diff --git a/packages/core/content-type-builder/server/src/register.ts b/packages/core/content-type-builder/server/src/register.ts index f6c985bf33..0a0701ae88 100644 --- a/packages/core/content-type-builder/server/src/register.ts +++ b/packages/core/content-type-builder/server/src/register.ts @@ -1,79 +1,37 @@ -/** - * This file ensures that the Strapi security middleware's Content Security Policy (CSP) - * allows images and media from both the default sources ("'self'", 'data:', 'blob:') - * and the required S3 domains for AI features. It checks for existing 'img-src' and 'media-src' - * directives and adds the S3 domains if not present. If no directives exist but useDefaults is true, - * it adds the defaults plus the S3 domains. This guarantees that all required sources are allowed - * without overwriting user configuration. - */ -export default async () => { - const s3Domains = [ - 'strapi-ai-staging.s3.us-east-1.amazonaws.com', - 'strapi-ai-production.s3.us-east-1.amazonaws.com', - ]; - const defaults = ["'self'", 'data:', 'blob:']; - const middlewares = strapi.config.get('middlewares') as ( - | string - | { name?: string; config?: any } - )[]; +import type { Core } from '@strapi/types'; +import { CSP_DEFAULTS, extendMiddlewareConfiguration } from '@strapi/utils'; - const configuredMiddlewares = middlewares.map((m) => { - // Handle case where middleware is a string 'strapi::security' - if (typeof m === 'string' && m === 'strapi::security') { - return { - name: 'strapi::security', - config: { - contentSecurityPolicy: { - useDefaults: true, - directives: { - 'img-src': Array.from(new Set([...defaults, ...s3Domains])), - 'media-src': Array.from(new Set([...defaults, ...s3Domains])), - }, +export default async ({ strapi }: { strapi: Core.Strapi }) => { + const aiEnabledConfig = strapi.config.get('admin.ai.enabled') !== false; + const isAIEnabled = aiEnabledConfig && strapi.ee.features.isEnabled('cms-ai'); + + if (isAIEnabled) { + const s3Domains = [ + 'strapi-ai-staging.s3.us-east-1.amazonaws.com', + 'strapi-ai-production.s3.us-east-1.amazonaws.com', + ]; + + const defaultImgSrc = CSP_DEFAULTS['img-src']; + const defaultMediaSrc = CSP_DEFAULTS['media-src']; + + // Extend the security middleware configuration to include S3 domains + defaults + const middlewares = strapi.config.get('middlewares') as ( + | string + | { name?: string; config?: any } + )[]; + + const configuredMiddlewares = extendMiddlewareConfiguration(middlewares, { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': [...defaultImgSrc, ...s3Domains], + 'media-src': [...defaultMediaSrc, ...s3Domains], }, }, - }; - } - // Handle case where middleware is an object with name 'strapi::security' - if (typeof m === 'object' && m.name === 'strapi::security') { - const config = m.config || {}; - const csp = config.contentSecurityPolicy || {}; - const directives = csp.directives || {}; - // img-src - let imgSrc = directives['img-src']; - if (!imgSrc && csp.useDefaults) { - imgSrc = [...defaults]; - } - if (!imgSrc) { - imgSrc = []; - } - imgSrc = Array.from(new Set([...imgSrc, ...s3Domains])); - // media-src - let mediaSrc = directives['media-src']; - if (!mediaSrc && csp.useDefaults) { - mediaSrc = [...defaults]; - } - if (!mediaSrc) { - mediaSrc = []; - } - mediaSrc = Array.from(new Set([...mediaSrc, ...s3Domains])); - // Set back - return { - ...m, - config: { - ...config, - contentSecurityPolicy: { - ...csp, - directives: { - ...directives, - 'img-src': imgSrc, - 'media-src': mediaSrc, - }, - }, - }, - }; - } - return m; - }); + }, + }); - strapi.config.set('middlewares', configuredMiddlewares); + strapi.config.set('middlewares', configuredMiddlewares); + } }; diff --git a/packages/core/core/src/middlewares/security.ts b/packages/core/core/src/middlewares/security.ts index f4cd23dbab..df20606735 100644 --- a/packages/core/core/src/middlewares/security.ts +++ b/packages/core/core/src/middlewares/security.ts @@ -1,5 +1,6 @@ import { defaultsDeep, mergeWith } from 'lodash/fp'; import helmet, { KoaHelmet } from 'koa-helmet'; +import { CSP_DEFAULTS } from '@strapi/utils'; import type { Core } from '@strapi/types'; @@ -13,9 +14,7 @@ const defaults: Config = { contentSecurityPolicy: { useDefaults: true, directives: { - 'connect-src': ["'self'", 'https:'], - 'img-src': ["'self'", 'data:', 'blob:', 'https://market-assets.strapi.io'], - 'media-src': ["'self'", 'data:', 'blob:'], + ...CSP_DEFAULTS, upgradeInsecureRequests: null, }, }, @@ -41,7 +40,6 @@ export const security: Core.MiddlewareFactory = (config, { strapi }) => (ctx, next) => { let helmetConfig: Config = defaultsDeep(defaults, config); - const specialPaths = ['/documentation']; const directives: { diff --git a/packages/core/utils/src/__tests__/security.test.ts b/packages/core/utils/src/__tests__/security.test.ts new file mode 100644 index 0000000000..f9cfc5b1d2 --- /dev/null +++ b/packages/core/utils/src/__tests__/security.test.ts @@ -0,0 +1,390 @@ +import { extendMiddlewareConfiguration, CSP_DEFAULTS } from '../security'; + +describe('security utilities', () => { + describe('extendMiddlewareConfiguration', () => { + describe('when middleware is a string', () => { + it('should replace string middleware with object configuration', () => { + const middlewares = ['strapi::logger', 'strapi::security', 'strapi::cors']; + + const newConfig = { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ['example.com'], + }, + }, + }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result).toEqual([ + 'strapi::logger', + { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ['example.com'], + }, + }, + }, + }, + 'strapi::cors', + ]); + }); + + it('should not modify other string middlewares', () => { + const middlewares = ['strapi::logger', 'strapi::security', 'strapi::cors']; + + const newConfig = { + name: 'strapi::security', + config: { test: 'value' }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result[0]).toBe('strapi::logger'); + expect(result[2]).toBe('strapi::cors'); + }); + }); + + describe('when middleware is an object', () => { + it('should merge configurations with array concatenation', () => { + const middlewares = [ + { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ["'self'", 'data:'], + 'script-src': ["'self'"], + }, + }, + }, + }, + ]; + + const newConfig = { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ['example.com', 'another.com'], + 'media-src': ['media.com'], + }, + }, + }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result[0]).toEqual({ + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ["'self'", 'data:', 'example.com', 'another.com'], + 'script-src': ["'self'"], + 'media-src': ['media.com'], + }, + }, + }, + }); + }); + + it('should merge deep nested objects', () => { + const middlewares = [ + { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + useDefaults: true, + directives: { + 'frame-src': ["'self'"], + }, + }, + hsts: { + maxAge: 31536000, + }, + }, + }, + ]; + + const newConfig = { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ['example.com'], + }, + }, + xssFilter: false, + }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result[0]).toEqual({ + name: 'strapi::security', + config: { + contentSecurityPolicy: { + useDefaults: true, + directives: { + 'frame-src': ["'self'"], + 'img-src': ['example.com'], + }, + }, + hsts: { + maxAge: 31536000, + }, + xssFilter: false, + }, + }); + }); + + it('should handle empty arrays correctly', () => { + const middlewares = [ + { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': [], + }, + }, + }, + }, + ]; + + const newConfig = { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ['example.com'], + }, + }, + }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result[0].config.contentSecurityPolicy.directives['img-src']).toEqual([ + 'example.com', + ]); + }); + + it('should not mutate original arrays', () => { + const originalImgSrc = ["'self'", 'data:']; + const middlewares = [ + { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': originalImgSrc, + }, + }, + }, + }, + ]; + + const newConfig = { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ['example.com'], + }, + }, + }, + }; + + extendMiddlewareConfiguration(middlewares, newConfig); + + // Original array should not be modified + expect(originalImgSrc).toEqual(["'self'", 'data:']); + }); + }); + + describe('when middleware name does not match', () => { + it('should return middlewares unchanged', () => { + const middlewares = [ + 'strapi::logger', + { + name: 'strapi::cors', + config: { origin: true }, + }, + ]; + + const newConfig = { + name: 'strapi::security', + config: { test: 'value' }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result).toEqual(middlewares); + expect(result).not.toBe(middlewares); // Should be a new array + }); + }); + + describe('edge cases', () => { + it('should handle empty middlewares array', () => { + const middlewares: any[] = []; + const newConfig = { + name: 'strapi::security', + config: { test: 'value' }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result).toEqual([]); + }); + + it('should handle middleware with no config', () => { + const middlewares = [ + { + name: 'strapi::security', + }, + ]; + + const newConfig = { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ['example.com'], + }, + }, + }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result[0]).toEqual({ + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': ['example.com'], + }, + }, + }, + }); + }); + + it('should handle middleware with undefined name', () => { + const middlewares = [ + { + config: { test: 'value' }, + }, + ]; + + const newConfig = { + name: 'strapi::security', + config: { newTest: 'newValue' }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result[0]).toEqual({ + config: { test: 'value' }, + }); + }); + }); + + describe('real-world scenarios', () => { + it('should handle typical CSP extension for AI features', () => { + const middlewares = [ + { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + useDefaults: true, + directives: { + 'frame-src': ["'self'"], + 'script-src': ["'self'", "'unsafe-inline'"], + }, + }, + }, + }, + ]; + + const s3Domains = [ + 'strapi-ai-staging.s3.us-east-1.amazonaws.com', + 'strapi-ai-production.s3.us-east-1.amazonaws.com', + ]; + + const newConfig = { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'img-src': [...CSP_DEFAULTS['img-src'], ...s3Domains], + 'media-src': [...CSP_DEFAULTS['media-src'], ...s3Domains], + }, + }, + }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result[0].config.contentSecurityPolicy.directives['img-src']).toEqual([ + "'self'", + 'data:', + 'blob:', + 'https://market-assets.strapi.io', + 'strapi-ai-staging.s3.us-east-1.amazonaws.com', + 'strapi-ai-production.s3.us-east-1.amazonaws.com', + ]); + + expect(result[0].config.contentSecurityPolicy.directives['media-src']).toEqual([ + "'self'", + 'data:', + 'blob:', + 'strapi-ai-staging.s3.us-east-1.amazonaws.com', + 'strapi-ai-production.s3.us-east-1.amazonaws.com', + ]); + }); + + it('should handle preview frame-src configuration', () => { + const middlewares = [ + { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'frame-src': ["'self'"], + }, + }, + }, + }, + ]; + + const allowedOrigins = ['https://preview.example.com', 'https://staging.example.com']; + + const newConfig = { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + directives: { + 'frame-src': allowedOrigins, + }, + }, + }, + }; + + const result = extendMiddlewareConfiguration(middlewares, newConfig); + + expect(result[0].config.contentSecurityPolicy.directives['frame-src']).toEqual([ + "'self'", + 'https://preview.example.com', + 'https://staging.example.com', + ]); + }); + }); + }); +}); diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index aa52659305..aa075de11f 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -30,3 +30,4 @@ export * from './route-serialization'; export * from './primitives'; export * from './content-api-router'; +export * from './security'; diff --git a/packages/core/utils/src/security.ts b/packages/core/utils/src/security.ts new file mode 100644 index 0000000000..b7dd1074b8 --- /dev/null +++ b/packages/core/utils/src/security.ts @@ -0,0 +1,42 @@ +import { mergeWith } from 'lodash/fp'; + +export const CSP_DEFAULTS = { + 'connect-src': ["'self'", 'https:'], + 'img-src': ["'self'", 'data:', 'blob:', 'https://market-assets.strapi.io'], + 'media-src': ["'self'", 'data:', 'blob:'], +} as const; + +/** + * Utility to extend Strapi middleware configuration. Mainly used to extend the CSP directives from the security middleware. + * + * @param middlewares - Array of middleware configurations + * @param middleware - Middleware configuration to merge/add + * @returns Modified middlewares array with the new configuration merged + */ +export const extendMiddlewareConfiguration = ( + middlewares: (string | { name?: string; config?: any })[], + middleware: { name: string; config?: any } +) => { + return middlewares.map((currentMiddleware) => { + if (typeof currentMiddleware === 'string' && currentMiddleware === middleware.name) { + // Use the new config object if the middleware has no config property yet + return middleware; + } + + if (typeof currentMiddleware === 'object' && currentMiddleware.name === middleware.name) { + // Deep merge (+ concat arrays) the new config with the current middleware config + return mergeWith( + (objValue, srcValue) => { + if (Array.isArray(objValue)) { + return Array.from(new Set(objValue.concat(srcValue))); + } + return undefined; + }, + currentMiddleware, + middleware + ); + } + + return currentMiddleware; + }); +};