mirror of
https://github.com/strapi/strapi.git
synced 2025-11-03 03:17:11 +00:00
fix: extending csp configs (#24571)
This commit is contained in:
parent
654757574d
commit
dd3fe6fd8a
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
|
||||
390
packages/core/utils/src/__tests__/security.test.ts
Normal file
390
packages/core/utils/src/__tests__/security.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -30,3 +30,4 @@ export * from './route-serialization';
|
||||
|
||||
export * from './primitives';
|
||||
export * from './content-api-router';
|
||||
export * from './security';
|
||||
|
||||
42
packages/core/utils/src/security.ts
Normal file
42
packages/core/utils/src/security.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user