From 881668a6c36a706c94fd251311250e10921b7224 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 Mar 2024 23:21:00 +0000 Subject: [PATCH] Support dynamic CSP rules to mitigate clickjacking (#5641) * support dynamic csp rules to mitigate clickjacking Signed-off-by: Tianle Huang * add unit tests for the provider class Signed-off-by: Tianle Huang * move request handler to its own class Signed-off-by: Tianle Huang * add license headers Signed-off-by: Tianle Huang * fix failed unit tests Signed-off-by: Tianle Huang * add unit tests for the handler Signed-off-by: Tianle Huang * add content to read me Signed-off-by: Tianle Huang * fix test error Signed-off-by: Tianle Huang * update readme Signed-off-by: Tianle Huang * update CHANGELOG.md Signed-off-by: Tianle Huang * update snap tests Signed-off-by: Tianle Huang * update snapshots Signed-off-by: Tianle Huang * fix a wrong import Signed-off-by: Tianle Huang * undo changes in listing snap Signed-off-by: Tianle Huang * improve wording Signed-off-by: Tianle Huang * set client after default client is created Signed-off-by: Tianle Huang * update return value and add a unit test Signed-off-by: Tianle Huang * remove unnecessary dependency Signed-off-by: Tianle Huang * make the name of the index configurable Signed-off-by: Tianle Huang * expose APIs and update file structures Signed-off-by: Tianle Huang * add header Signed-off-by: Tianle Huang * fix link error Signed-off-by: Tianle Huang * fix link error Signed-off-by: Tianle Huang * add more unit tests Signed-off-by: Tianle Huang * add more unit tests Signed-off-by: Tianle Huang * update api path Signed-off-by: Tianle Huang * remove logging Signed-off-by: Tianle Huang * update path Signed-off-by: Tianle Huang * rename index name Signed-off-by: Tianle Huang * update wording Signed-off-by: Tianle Huang * make the new plugin disabled by default Signed-off-by: Tianle Huang * do not update defaults to avoid breaking change Signed-off-by: Tianle Huang * update readme to reflect new API path Signed-off-by: Tianle Huang * update handler to append frame-ancestors conditionally Signed-off-by: Tianle Huang * update readme Signed-off-by: Tianle Huang * clean up code to prepare for application config Signed-off-by: Tianle Huang * reset change log Signed-off-by: Tianle Huang * reset change log again Signed-off-by: Tianle Huang * update accordingly to new changes in applicationConfig Signed-off-by: Tianle Huang * update changelog Signed-off-by: Tianle Huang * rename to a new plugin name Signed-off-by: Tianle Huang * rename Signed-off-by: Tianle Huang * rename more Signed-off-by: Tianle Huang * sync changelog from main Signed-off-by: Tianle Huang * onboard to app config Signed-off-by: Tianle Huang * fix comment Signed-off-by: Tianle Huang * update yml Signed-off-by: Tianle Huang * update readme Signed-off-by: Tianle Huang * update change log Signed-off-by: Tianle Huang * call out single quotes in readme Signed-off-by: Tianle Huang * update yml Signed-off-by: Tianle Huang * update default Signed-off-by: Tianle Huang * add reference link Signed-off-by: Tianle Huang * update js doc Signed-off-by: Tianle Huang * rename Signed-off-by: Tianle Huang * use new name Signed-off-by: Tianle Huang * redo changelog update Signed-off-by: Tianle Huang * remove link Signed-off-by: Tianle Huang * better name Signed-off-by: Tianle Huang --------- Signed-off-by: Tianle Huang (cherry picked from commit 58fb588f34ee932b5f51fcb17bacb368c766a69b) Signed-off-by: github-actions[bot] --- config/opensearch_dashboards.yml | 4 + .../application_config/server/index.ts | 6 +- src/plugins/csp_handler/README.md | 51 ++++ src/plugins/csp_handler/common/index.ts | 7 + src/plugins/csp_handler/config.ts | 12 + .../csp_handler/opensearch_dashboards.json | 11 + .../csp_handler/server/csp_handlers.test.ts | 273 ++++++++++++++++++ .../csp_handler/server/csp_handlers.ts | 85 ++++++ src/plugins/csp_handler/server/index.ts | 22 ++ src/plugins/csp_handler/server/plugin.ts | 42 +++ src/plugins/csp_handler/server/types.ts | 15 + 11 files changed, 527 insertions(+), 1 deletion(-) create mode 100755 src/plugins/csp_handler/README.md create mode 100644 src/plugins/csp_handler/common/index.ts create mode 100644 src/plugins/csp_handler/config.ts create mode 100644 src/plugins/csp_handler/opensearch_dashboards.json create mode 100644 src/plugins/csp_handler/server/csp_handlers.test.ts create mode 100644 src/plugins/csp_handler/server/csp_handlers.ts create mode 100644 src/plugins/csp_handler/server/index.ts create mode 100644 src/plugins/csp_handler/server/plugin.ts create mode 100644 src/plugins/csp_handler/server/types.ts diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index f0c789881337..963178bce3b7 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -36,6 +36,10 @@ # Set the value of this setting to true to enable plugin application config. By default it is disabled. # application_config.enabled: false +# Set the value of this setting to true to enable plugin CSP handler. By default it is disabled. +# It requires the application config plugin as its dependency. +# csp_handler.enabled: false + # The default application to load. #opensearchDashboards.defaultAppId: "home" diff --git a/src/plugins/application_config/server/index.ts b/src/plugins/application_config/server/index.ts index 1ef2bbc3baf9..3eb85b455afa 100644 --- a/src/plugins/application_config/server/index.ts +++ b/src/plugins/application_config/server/index.ts @@ -20,4 +20,8 @@ export function plugin(initializerContext: PluginInitializerContext) { return new ApplicationConfigPlugin(initializerContext); } -export { ApplicationConfigPluginSetup, ApplicationConfigPluginStart } from './types'; +export { + ApplicationConfigPluginSetup, + ApplicationConfigPluginStart, + ConfigurationClient, +} from './types'; diff --git a/src/plugins/csp_handler/README.md b/src/plugins/csp_handler/README.md new file mode 100755 index 000000000000..04a6ca34f0dd --- /dev/null +++ b/src/plugins/csp_handler/README.md @@ -0,0 +1,51 @@ +# CspHandler + +A OpenSearch Dashboards plugin + +This plugin is to support updating Content Security Policy (CSP) rules dynamically without requiring a server restart. It registers a pre-response handler to `HttpServiceSetup` which can get CSP rules from a dependent plugin `applicationConfig` and then rewrite to CSP header. Users are able to call the API endpoint exposed by the `applicationConfig` plugin directly, e.g through CURL. Currently there is no new OSD page for ease of user interactions with the APIs. Updates to the CSP rules will take effect immediately. As a comparison, modifying CSP rules through the key `csp.rules` in OSD YAML file would require a server restart. + +By default, this plugin is disabled. Once enabled, the plugin will first use what users have configured through `applicationConfig`. If not configured, it will check whatever CSP rules aggregated by the values of `csp.rules` from OSD YAML file and default values. If the aggregated CSP rules don't contain the CSP directive `frame-ancestors` which specifies valid parents that may embed OSD page, then the plugin will append `frame-ancestors 'self'` to prevent Clickjacking. + +--- + +## Configuration + +The plugin can be enabled by adding this line in OSD YML. + +``` +csp_handler.enabled: true + +``` + +Since it has a required dependency `applicationConfig`, make sure that the dependency is also enabled. + +``` +application_config.enabled: true +``` + +For OSD users who want to make changes to allow a new site to embed OSD pages, they can update CSP rules through CURL. (See the README of `applicationConfig` for more details about the APIs.) **Please note that use backslash as string wrapper for single quotes inside the `data-raw` parameter. E.g use `'\''` to represent `'`** + +``` +curl '{osd endpoint}/api/appconfig/csp.rules' -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' --data-raw '{"newValue":"script-src '\''unsafe-eval'\'' '\''self'\''; worker-src blob: '\''self'\''; style-src '\''unsafe-inline'\'' '\''self'\''; frame-ancestors '\''self'\'' {new site}"}' + +``` + +Below is the CURL command to delete CSP rules. + +``` +curl '{osd endpoint}/api/appconfig/csp.rules' -X DELETE -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' +``` + +Below is the CURL command to get the CSP rules. + +``` +curl '{osd endpoint}/api/appconfig/csp.rules' + +``` + +--- +## Development + +See the [OpenSearch Dashboards contributing +guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/CONTRIBUTING.md) for instructions +setting up your development environment. diff --git a/src/plugins/csp_handler/common/index.ts b/src/plugins/csp_handler/common/index.ts new file mode 100644 index 000000000000..23a8ca4bd730 --- /dev/null +++ b/src/plugins/csp_handler/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'cspHandler'; +export const PLUGIN_NAME = 'CspHandler'; diff --git a/src/plugins/csp_handler/config.ts b/src/plugins/csp_handler/config.ts new file mode 100644 index 000000000000..914dcf8b2792 --- /dev/null +++ b/src/plugins/csp_handler/config.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); + +export type CspHandlerConfigSchema = TypeOf; diff --git a/src/plugins/csp_handler/opensearch_dashboards.json b/src/plugins/csp_handler/opensearch_dashboards.json new file mode 100644 index 000000000000..8cc8f8e1f658 --- /dev/null +++ b/src/plugins/csp_handler/opensearch_dashboards.json @@ -0,0 +1,11 @@ +{ + "id": "cspHandler", + "version": "opensearchDashboards", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": true, + "ui": false, + "requiredPlugins": [ + "applicationConfig" + ], + "optionalPlugins": [] +} \ No newline at end of file diff --git a/src/plugins/csp_handler/server/csp_handlers.test.ts b/src/plugins/csp_handler/server/csp_handlers.test.ts new file mode 100644 index 000000000000..d6c2f8a16d49 --- /dev/null +++ b/src/plugins/csp_handler/server/csp_handlers.test.ts @@ -0,0 +1,273 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock, httpServerMock } from '../../../core/server/mocks'; +import { createCspRulesPreResponseHandler } from './csp_handlers'; +import { MockedLogger, loggerMock } from '@osd/logging/target/mocks'; + +const ERROR_MESSAGE = 'Service unavailable'; + +describe('CSP handlers', () => { + let toolkit: ReturnType; + let logger: MockedLogger; + + beforeEach(() => { + toolkit = httpServerMock.createToolkit(); + logger = loggerMock.create(); + }); + + it('adds the CSP headers provided by the client', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromIndex = "frame-ancestors 'self'"; + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockReturnValue(cspRulesFromIndex), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + const request = { + method: 'get', + headers: { 'sec-fetch-dest': 'document' }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'content-security-policy': cspRulesFromIndex, + }, + }); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('do not add CSP headers when the client returns empty and CSP from YML already has frame-ancestors', async () => { + const coreSetup = coreMock.createSetup(); + const emptyCspRules = ''; + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockReturnValue(emptyCspRules), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + const request = { + method: 'get', + headers: { 'sec-fetch-dest': 'document' }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({}); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('add frame-ancestors CSP headers when the client returns empty and CSP from YML has no frame-ancestors', async () => { + const coreSetup = coreMock.createSetup(); + const emptyCspRules = ''; + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockReturnValue(emptyCspRules), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + + const request = { + method: 'get', + headers: { 'sec-fetch-dest': 'document' }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'content-security-policy': "frame-ancestors 'self'; " + cspRulesFromYML, + }, + }); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('do not add CSP headers when the configuration does not exist and CSP from YML already has frame-ancestors', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockImplementation(() => { + throw new Error(ERROR_MESSAGE); + }), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + + const request = { + method: 'get', + headers: { 'sec-fetch-dest': 'document' }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toBeCalledTimes(1); + expect(toolkit.next).toBeCalledWith({}); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('add frame-ancestors CSP headers when the configuration does not exist and CSP from YML has no frame-ancestors', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn().mockImplementation(() => { + throw new Error(ERROR_MESSAGE); + }), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + const request = { method: 'get', headers: { 'sec-fetch-dest': 'document' } }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toBeCalledTimes(1); + expect(toolkit.next).toBeCalledWith({ + headers: { + 'content-security-policy': "frame-ancestors 'self'; " + cspRulesFromYML, + }, + }); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(1); + }); + + it('do not add CSP headers when request dest exists and shall skip', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn(), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + + const cssSecFetchDest = 'css'; + const request = { + method: 'get', + headers: { 'sec-fetch-dest': cssSecFetchDest }, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toBeCalledTimes(1); + expect(toolkit.next).toBeCalledWith({}); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(0); + }); + + it('do not add CSP headers when request dest does not exist', async () => { + const coreSetup = coreMock.createSetup(); + const cspRulesFromYML = "script-src 'unsafe-eval' 'self'"; + + const configurationClient = { + getEntityConfig: jest.fn(), + }; + + const getConfigurationClient = jest.fn().mockReturnValue(configurationClient); + + const handler = createCspRulesPreResponseHandler( + coreSetup, + cspRulesFromYML, + getConfigurationClient, + logger + ); + + const request = { + method: 'get', + headers: {}, + }; + + toolkit.next.mockReturnValue('next' as any); + + const result = await handler(request, {} as any, toolkit); + + expect(result).toEqual('next'); + + expect(toolkit.next).toBeCalledTimes(1); + expect(toolkit.next).toBeCalledWith({}); + + expect(configurationClient.getEntityConfig).toBeCalledTimes(0); + }); +}); diff --git a/src/plugins/csp_handler/server/csp_handlers.ts b/src/plugins/csp_handler/server/csp_handlers.ts new file mode 100644 index 000000000000..cc14da74aed5 --- /dev/null +++ b/src/plugins/csp_handler/server/csp_handlers.ts @@ -0,0 +1,85 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ConfigurationClient } from '../../application_config/server'; +import { + CoreSetup, + IScopedClusterClient, + Logger, + OnPreResponseHandler, + OnPreResponseInfo, + OnPreResponseToolkit, + OpenSearchDashboardsRequest, +} from '../../../core/server'; + +const CSP_RULES_CONFIG_KEY = 'csp.rules'; + +/** + * This function creates a pre-response handler to dynamically set the CSP rules. + * It give precedence to the rules from application config plugin over those from YML. + * In case no value from application config, it will ensure a default frame-ancestors is set. + * + * @param core Context passed to the plugins `setup` method + * @param cspHeader The CSP header from YML + * @param getConfigurationClient The function provided by application config plugin to retrieve configurations + * @param logger The logger + * @returns The pre-response handler + */ +export function createCspRulesPreResponseHandler( + core: CoreSetup, + cspHeader: string, + getConfigurationClient: (scopedClusterClient: IScopedClusterClient) => ConfigurationClient, + logger: Logger +): OnPreResponseHandler { + return async ( + request: OpenSearchDashboardsRequest, + response: OnPreResponseInfo, + toolkit: OnPreResponseToolkit + ) => { + try { + const shouldCheckDest = ['document', 'frame', 'iframe', 'embed', 'object']; + + const currentDest = request.headers['sec-fetch-dest']; + + if (!shouldCheckDest.includes(currentDest)) { + return toolkit.next({}); + } + + const [coreStart] = await core.getStartServices(); + + const client = getConfigurationClient(coreStart.opensearch.client.asScoped(request)); + + const cspRules = await client.getEntityConfig(CSP_RULES_CONFIG_KEY); + + if (!cspRules) { + return appendFrameAncestorsWhenMissing(cspHeader, toolkit); + } + + const additionalHeaders = { + 'content-security-policy': cspRules, + }; + + return toolkit.next({ headers: additionalHeaders }); + } catch (e) { + logger.error(`Failure happened in CSP rules pre response handler due to ${e}`); + return appendFrameAncestorsWhenMissing(cspHeader, toolkit); + } + }; +} + +/** + * Append frame-ancestors with default value 'self' when it is missing. + */ +function appendFrameAncestorsWhenMissing(cspHeader: string, toolkit: OnPreResponseToolkit) { + if (cspHeader.includes('frame-ancestors')) { + return toolkit.next({}); + } + + const additionalHeaders = { + 'content-security-policy': "frame-ancestors 'self'; " + cspHeader, + }; + + return toolkit.next({ headers: additionalHeaders }); +} diff --git a/src/plugins/csp_handler/server/index.ts b/src/plugins/csp_handler/server/index.ts new file mode 100644 index 000000000000..3cbe9b3b14ff --- /dev/null +++ b/src/plugins/csp_handler/server/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; +import { CspHandlerConfigSchema, configSchema } from '../config'; +import { CspHandlerPlugin } from './plugin'; + +/* +This exports static code and TypeScript types, +as well as, OpenSearch Dashboards Platform `plugin()` initializer. +*/ +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export function plugin(initializerContext: PluginInitializerContext) { + return new CspHandlerPlugin(initializerContext); +} + +export { CspHandlerPluginSetup, CspHandlerPluginStart } from './types'; diff --git a/src/plugins/csp_handler/server/plugin.ts b/src/plugins/csp_handler/server/plugin.ts new file mode 100644 index 000000000000..9f4094262452 --- /dev/null +++ b/src/plugins/csp_handler/server/plugin.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CoreSetup, + CoreStart, + Logger, + Plugin, + PluginInitializerContext, +} from '../../../core/server'; + +import { createCspRulesPreResponseHandler } from './csp_handlers'; +import { AppPluginSetupDependencies, CspHandlerPluginSetup, CspHandlerPluginStart } from './types'; + +export class CspHandlerPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public async setup(core: CoreSetup, { applicationConfig }: AppPluginSetupDependencies) { + core.http.registerOnPreResponse( + createCspRulesPreResponseHandler( + core, + core.http.csp.header, + applicationConfig.getConfigurationClient, + this.logger + ) + ); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/csp_handler/server/types.ts b/src/plugins/csp_handler/server/types.ts new file mode 100644 index 000000000000..730fec3f7c62 --- /dev/null +++ b/src/plugins/csp_handler/server/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApplicationConfigPluginSetup } from '../../application_config/server'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface CspHandlerPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface CspHandlerPluginStart {} + +export interface AppPluginSetupDependencies { + applicationConfig: ApplicationConfigPluginSetup; +}