fix: extending csp configs (#24571)

This commit is contained in:
Bassel Kanso 2025-10-20 16:16:17 +03:00 committed by GitHub
parent 654757574d
commit dd3fe6fd8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 476 additions and 116 deletions

View File

@ -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);
}
},

View File

@ -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);
}
};

View File

@ -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> =
(config, { strapi }) =>
(ctx, next) => {
let helmetConfig: Config = defaultsDeep(defaults, config);
const specialPaths = ['/documentation'];
const directives: {

View File

@ -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',
]);
});
});
});
});

View File

@ -30,3 +30,4 @@ export * from './route-serialization';
export * from './primitives';
export * from './content-api-router';
export * from './security';

View File

@ -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;
});
};