forked from opensearch-project/OpenSearch-Dashboards
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support dynamic CSP rules to mitigate clickjacking (opensearch-projec…
…t#5641) (opensearch-project#6101) * support dynamic csp rules to mitigate clickjacking * add unit tests for the provider class * move request handler to its own class * add license headers * fix failed unit tests * add unit tests for the handler * add content to read me * fix test error * update readme * update CHANGELOG.md * update snap tests * update snapshots * fix a wrong import * undo changes in listing snap * improve wording * set client after default client is created * update return value and add a unit test * remove unnecessary dependency * make the name of the index configurable * expose APIs and update file structures * add header * fix link error * fix link error * add more unit tests * add more unit tests * update api path * remove logging * update path * rename index name * update wording * make the new plugin disabled by default * do not update defaults to avoid breaking change * update readme to reflect new API path * update handler to append frame-ancestors conditionally * update readme * clean up code to prepare for application config * reset change log * reset change log again * update accordingly to new changes in applicationConfig * update changelog * rename to a new plugin name * rename * rename more * sync changelog from main * onboard to app config * fix comment * update yml * update readme * update change log * call out single quotes in readme * update yml * update default * add reference link * update js doc * rename * use new name * redo changelog update * remove link * better name --------- (cherry picked from commit 58fb588) Signed-off-by: Tianle Huang <tianleh@amazon.com> Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
- Loading branch information
1 parent
8ccc90e
commit b338dc9
Showing
11 changed files
with
527 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
/* | ||
* Copyright OpenSearch Contributors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
export const PLUGIN_ID = 'cspHandler'; | ||
export const PLUGIN_NAME = 'CspHandler'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<typeof configSchema>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"id": "cspHandler", | ||
"version": "opensearchDashboards", | ||
"opensearchDashboardsVersion": "opensearchDashboards", | ||
"server": true, | ||
"ui": false, | ||
"requiredPlugins": [ | ||
"applicationConfig" | ||
], | ||
"optionalPlugins": [] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<typeof httpServerMock.createToolkit>; | ||
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); | ||
}); | ||
}); |
Oops, something went wrong.